Skip to content

Commit

Permalink
Add initial support for rule profiles (#2245)
Browse files Browse the repository at this point in the history
* Introduce experimental support for rule profiles

Fixes: #2109

* Update docs/profiles.md

Co-authored-by: Jacob Floyd <[email protected]>

* Fixes from review

Co-authored-by: Jacob Floyd <[email protected]>
  • Loading branch information
ssbarnea and cognifloyd authored Jul 28, 2022
1 parent 0e66999 commit 04f808a
Show file tree
Hide file tree
Showing 14 changed files with 309 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tox.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ jobs:
WSLENV: FORCE_COLOR:PYTEST_REQPASS:TOXENV:TOX_PARALLEL_NO_SPINNER
# Number of expected test passes, safety measure for accidental skip of
# tests. Update value if you add/remove tests.
PYTEST_REQPASS: 655
PYTEST_REQPASS: 657

steps:
- name: Activate WSL1
Expand Down
7 changes: 7 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ usage
configuring
```

```{toctree}
:caption: Profiles
:maxdepth: 2
profiles
```

```{toctree}
:caption: Rules
:maxdepth: 4
Expand Down
17 changes: 17 additions & 0 deletions docs/profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Profiles

One of the best ways to run `ansible-lint` is by specifying which rule profile
you want to use. These profiles stack on top of each other, allowing you to
gradually raise the quality bar.

To run it with the most strict profile just type `ansible-lint -P production`.

If you want to consult the list of rules from each profile, type
`ansible-lint -P`. For your convenience, we also list the same output below.

The rules that have a '\*' suffix, are not implemented yet but we documented
them with links to their issues.

```{ansible-lint-profile-list}
```
20 changes: 19 additions & 1 deletion docs/rules_table_generator_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from ansiblelint import __version__
from ansiblelint.constants import DEFAULT_RULESDIR
from ansiblelint.generate_docs import rules_as_md
from ansiblelint.generate_docs import profiles_as_md, rules_as_md
from ansiblelint.rules import RulesCollection


Expand All @@ -39,6 +39,20 @@ def _nodes_from_md(
return node.children


class AnsibleLintProfilesDirective(SphinxDirective):
"""Directive ``ansible-lint-profile-list`` definition."""

has_content = False

def run(self) -> List[nodes.Node]:
"""Generate a node tree in place of the directive."""
self.env.note_reread() # rebuild the current doc unconditionally

md_rules_table = profiles_as_md()

return _nodes_from_md(state=self.state, md_source=md_rules_table)


class AnsibleLintDefaultRulesDirective(SphinxDirective):
"""Directive ``ansible-lint-default-rules-list`` definition."""

Expand All @@ -60,6 +74,10 @@ def setup(app: Sphinx) -> Dict[str, Union[bool, str]]:
"ansible-lint-default-rules-list",
AnsibleLintDefaultRulesDirective,
)
app.add_directive(
"ansible-lint-profile-list",
AnsibleLintProfilesDirective,
)

return {
"parallel_read_safe": True,
Expand Down
2 changes: 1 addition & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ disallow_any_generics = True
; warn_return_any = True
; warn_unused_configs = True
# site-packages is here to help vscode mypy integration getting confused
exclude = (build|test/local-content|site-packages)
exclude = (build|dist|test/local-content|site-packages)

# 3rd party ignores
[mypy-ansible]
Expand Down
10 changes: 9 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ python_requires = >=3.8
package_dir =
= src
packages = find:
# Do not use include_package_data as we mention them explicitly.
# see https://setuptools.pypa.io/en/latest/userguide/datafiles.html
# include_package_data = True
zip_safe = False

# These are required in actual runtime:
Expand Down Expand Up @@ -110,7 +113,12 @@ test =
where = src

[options.package_data]
* = py.typed, **/*.json, **/*.md
* =
py.typed
**/*.json
**/*.yml
**/*.yaml
**/*.md

[codespell]
skip = .tox,.mypy_cache,build,.git,.eggs,pip-wheel-metadata
Expand Down
13 changes: 12 additions & 1 deletion src/ansiblelint/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def _do_transform(result: "LintResult", opts: "Namespace") -> None:
transformer.run()


def main(argv: Optional[List[str]] = None) -> int:
def main(argv: Optional[List[str]] = None) -> int: # noqa: C901
"""Linter CLI entry point."""
# alter PATH if needed (venv support)
path_inject()
Expand All @@ -170,6 +170,17 @@ def main(argv: Optional[List[str]] = None) -> int:

rules = RulesCollection(options.rulesdirs)

if options.profile == []:
from ansiblelint.generate_docs import profiles_as_rich

console.print(profiles_as_rich())
return 0
if options.profile:
from ansiblelint.rules import filter_rules_with_profile

filter_rules_with_profile(rules, options.profile[0])
# When profile is mentioned, we filter-out the rules based on it

if options.listrules or options.listtags:
return _do_list(rules)

Expand Down
14 changes: 13 additions & 1 deletion src/ansiblelint/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import yaml

from ansiblelint.config import DEFAULT_KINDS
from ansiblelint.config import DEFAULT_KINDS, PROFILES
from ansiblelint.constants import (
CUSTOM_RULESDIR_ENVVAR,
DEFAULT_RULESDIR,
Expand Down Expand Up @@ -258,6 +258,18 @@ def get_cli_parser() -> argparse.ArgumentParser:
action="count",
help="quieter, reduce verbosity, can be specified twice.",
)
parser.add_argument(
"-P",
"--profile",
# * allow to distinguish between calling without -P, with -P and
# with -P=foo, while '?' does not. We do not support a real list.
nargs="*",
dest="profile",
default=None, # when called with -P but no arg will load as empty list []
action="store",
choices=PROFILES.keys(),
help="Specify which rules profile to be used, or displays available profiles when no argument is given.",
)
parser.add_argument(
"-p",
"--parseable",
Expand Down
5 changes: 5 additions & 0 deletions src/ansiblelint/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import re
from argparse import Namespace
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from ansiblelint.loaders import yaml_from_file

DEFAULT_KINDS = [
# Do not sort this list, order matters.
{"jinja2": "**/*.j2"}, # jinja2 templates are not always parsable as something else
Expand Down Expand Up @@ -80,6 +83,8 @@
"arg_specs": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-argument-specs.json",
}

PROFILES = yaml_from_file(Path(__file__).parent / "data" / "profiles.yml")

options = Namespace(
cache_dir=None,
colored=True,
Expand Down
127 changes: 127 additions & 0 deletions src/ansiblelint/data/profiles.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
# Do not change sorting order of the primary keys as they also represent how
# progressive the profiles are, each one extending the one before it.
min:
description: >
Ensures that Ansible would be able to load that content, nothing else.
None of the rules can be disabled as they are considered fatal errors. You
can still add files to exclude list or add missing dependencies, so the
right files can be loaded.
extends: null
rules:
internal-error:
load-failure:
parser-error:
syntax-check:
basic:
description: >
Basic profile enables a set of rules designed to spot very common coding
mistakes, deprecations and require user to follow standardized style and
formatting.
extends: min
rules:
command-instead-of-module:
command-instead-of-shell:
deprecated-bare-vars:
deprecated-command-syntax:
deprecated-local-action:
deprecated-module:
inline-env-var:
key-order:
literal-compare:
no-jinja-nesting:
jinja: # [jinja[readability]]
url: https://github.com/ansible/ansible-lint/issues/2120
no-jinja-when:
no-tabs:
partial-become:
playbook-extension:
role-name:
unnamed-task:
var-naming:
var-spacing:
yaml:
moderate:
description: >
The moderate profile is raising the bar a little bit by requiring you to
follow few other proven good practices that make the content easier to read
and maintain.
extends: basic
rules:
name-templated:
url: https://github.com/ansible/ansible-lint/issues/2037
name-imperative:
url: https://github.com/ansible/ansible-lint/issues/2170
name-capital: # schema-related
url: https://github.com/ansible/ansible-lint/issues/2169
no-shorthand: # schema-related
url: https://github.com/ansible/ansible-lint/issues/2117
schema: # can cover lots of rules, but not really be able to give best error messages
spell-var-name:
url: https://github.com/ansible/ansible-lint/issues/2168
safety:
description: >
The safety profile require user to avoid risky module calls that can have
a non-determinant outcomes and cause security issues in some cases.
extends: moderate
rules:
git-latest:
hg-latest:
package-latest:
risky-file-permissions:
risky-octal:
risky-shell-pipe:
shared:
extends: safety
description: >
The shared profile is for those that want to publish their content, roles
or collection to either [galaxy](https://galaxy.ansible.com) or to another
automation-hub instance. In addition to all the rules related to packaging
and publishing, like metadata checks, this also includes some rules that
are known to be good practices for keeping the code readable and
maintainable.
rules:
ignore-errors:
layout:
url: https://github.com/ansible/ansible-lint/issues/1900
meta-incorrect:
meta-no-info:
meta-no-tags:
meta-video-links:
meta-version:
url: https://github.com/ansible/ansible-lint/issues/2103
meta-unsupported-ansible:
url: https://github.com/ansible/ansible-lint/issues/2102
no-changed-when:
no-changelog:
url: https://github.com/ansible/ansible-lint/issues/2101
no-handler:
no-relative-paths:
max-block-depth:
url: https://github.com/ansible/ansible-lint/issues/2173
max-tasks:
url: https://github.com/ansible/ansible-lint/issues/2172
unsafe-loop:
# unsafe-loop[prefix] (currently named "no-var-prefix")
# [unsafe-loop[var-prefix|iterator]]
url: https://github.com/ansible/ansible-lint/issues/2038
production:
description: >
The production profile represents the top level of verification and it
is required for inclusion in [Ansible Automation Platform (AAP)](https://www.redhat.com/en/technologies/management/ansible)
as either validated or certified content.
extends: shared
rules:
avoid-dot-notation:
url: https://github.com/ansible/ansible-lint/issues/2174
disallowed-ignore: # [sanity]
url: https://github.com/ansible/ansible-lint/issues/2121
fqcn-builtins:
import-task-no-when:
url: https://github.com/ansible/ansible-lint/issues/2219
meta-no-dependencies:
url: https://github.com/ansible/ansible-lint/issues/2159
single-entry-point:
url: https://github.com/ansible/ansible-lint/issues/2242
use-loop:
url: https://github.com/ansible/ansible-lint/issues/2204
34 changes: 34 additions & 0 deletions src/ansiblelint/generate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from rich.markdown import Markdown
from rich.table import Table

from ansiblelint.config import PROFILES
from ansiblelint.constants import DEFAULT_RULESDIR
from ansiblelint.rules import RulesCollection

DOC_HEADER = """
Expand Down Expand Up @@ -78,3 +80,35 @@ def rules_as_rich(rules: RulesCollection) -> Iterable[Table]:
if rule.severity:
table.add_row("severity", rule.severity)
yield table


def profiles_as_md() -> str:
"""Return markdown representation of supported profiles."""
result = ""
default_rules = RulesCollection([DEFAULT_RULESDIR])

for name, profile in PROFILES.items():
extends = ""
if profile.get("extends", None):
extends = (
f" It extends [{profile['extends']}](#{profile['extends']}) profile."
)
result += f"## {name}\n\n{profile['description']}{extends}\n"
for rule, rule_data in profile["rules"].items():
for default_rule in default_rules.rules:
if default_rule.id == rule:
result += f"- [{rule}](default_rules.md/#{rule})\n"
break
else:
url = rule_data.get("url", None)
if url:
result += f"- [{rule}]({url})*\n"
else:
raise RuntimeError("All planned rules must have an 'url' entry.")
result += "\n"
return result


def profiles_as_rich() -> Markdown:
"""Return rich representation of supported profiles."""
return Markdown(profiles_as_md())
7 changes: 4 additions & 3 deletions src/ansiblelint/loaders.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""Utilities for loading various files."""
from typing import Any
from pathlib import Path
from typing import Any, Union

import yaml


def yaml_from_file(filepath: str) -> Any:
def yaml_from_file(filepath: Union[str, Path]) -> Any:
"""Return a loaded YAML file."""
with open(filepath, encoding="utf-8") as content:
with open(str(filepath), encoding="utf-8") as content:
return yaml.load(content, Loader=yaml.FullLoader)
Loading

0 comments on commit 04f808a

Please sign in to comment.