antsibull-nox 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
antsibull_nox/config.py CHANGED
@@ -275,6 +275,7 @@ class SessionAnsibleTestIntegrationWDefaultContainer(_BaseModel):
275
275
  except_versions: list[PAnsibleCoreVersion] = []
276
276
  core_python_versions: dict[t.Union[PAnsibleCoreVersion, str], list[PVersion]] = {}
277
277
  controller_python_versions_only: bool = False
278
+ ansible_vars_from_env_vars: dict[str, str] = {}
278
279
 
279
280
  @p.model_validator(mode="after")
280
281
  def _validate_core_keys(self) -> t.Self:
@@ -340,6 +341,9 @@ class Config(_BaseModel):
340
341
  """
341
342
 
342
343
  collection_sources: dict[CollectionName, CollectionSource] = {}
344
+ collection_sources_per_ansible: dict[
345
+ PAnsibleCoreVersion, dict[CollectionName, CollectionSource]
346
+ ] = {}
343
347
  sessions: Sessions = Sessions()
344
348
 
345
349
 
@@ -113,7 +113,6 @@ def scan(config: list[ActionGroup], errors: list[str]) -> None:
113
113
  modules_directory = "plugins/modules/"
114
114
  modules_suffix = ".py"
115
115
 
116
- errors = []
117
116
  for file in os.listdir(modules_directory):
118
117
  if not file.endswith(modules_suffix):
119
118
  continue
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python
2
+
3
+ # Copyright (c) 2025, Felix Fontein <felix@fontein.de>
4
+ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt
5
+ # or https://www.gnu.org/licenses/gpl-3.0.txt)
6
+ # SPDX-License-Identifier: GPL-3.0-or-later
7
+
8
+ """Make sure all plugin and module documentation adheres to yamllint."""
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ import os
14
+ import sys
15
+ import traceback
16
+ import typing as t
17
+
18
+ from antsibull_nox_data_util import setup # type: ignore
19
+ from yamllint import linter
20
+ from yamllint.cli import find_project_config_filepath
21
+ from yamllint.config import YamlLintConfig
22
+ from yamllint.linter import PROBLEM_LEVELS
23
+
24
+ REPORT_LEVELS: set[PROBLEM_LEVELS] = {
25
+ "warning",
26
+ "error",
27
+ }
28
+
29
+
30
+ def lint(
31
+ *,
32
+ errors: list[dict[str, t.Any]],
33
+ path: str,
34
+ data: str,
35
+ config: YamlLintConfig,
36
+ ) -> None:
37
+ try:
38
+ problems = linter.run(
39
+ io.StringIO(data),
40
+ config,
41
+ path,
42
+ )
43
+ for problem in problems:
44
+ if problem.level not in REPORT_LEVELS:
45
+ continue
46
+ msg = f"{problem.level}: {problem.desc}"
47
+ if problem.rule:
48
+ msg += f" ({problem.rule})"
49
+ errors.append(
50
+ {
51
+ "path": path,
52
+ "line": problem.line,
53
+ "col": problem.column,
54
+ "message": msg,
55
+ }
56
+ )
57
+ except Exception as exc:
58
+ error = str(exc).replace("\n", " / ")
59
+ errors.append(
60
+ {
61
+ "path": path,
62
+ "line": 1,
63
+ "col": 1,
64
+ "message": (
65
+ f"Internal error while linting YAML: exception {type(exc)}:"
66
+ f" {error}; traceback: {traceback.format_exc()!r}"
67
+ ),
68
+ }
69
+ )
70
+
71
+
72
+ def process_yaml_file(
73
+ errors: list[dict[str, t.Any]],
74
+ path: str,
75
+ config: YamlLintConfig,
76
+ ) -> None:
77
+ try:
78
+ with open(path, "rt", encoding="utf-8") as stream:
79
+ data = stream.read()
80
+ except Exception as exc:
81
+ errors.append(
82
+ {
83
+ "path": path,
84
+ "line": 1,
85
+ "col": 1,
86
+ "message": (
87
+ f"Error while parsing Python code: exception {type(exc)}:"
88
+ f" {exc}; traceback: {traceback.format_exc()!r}"
89
+ ),
90
+ }
91
+ )
92
+ return
93
+
94
+ lint(
95
+ errors=errors,
96
+ path=path,
97
+ data=data,
98
+ config=config,
99
+ )
100
+
101
+
102
+ def main() -> int:
103
+ """Main entry point."""
104
+ paths, extra_data = setup()
105
+ config: str | None = extra_data.get("config")
106
+
107
+ if config is None:
108
+ config = find_project_config_filepath()
109
+
110
+ if config:
111
+ yamllint_config = YamlLintConfig(file=config)
112
+ else:
113
+ yamllint_config = YamlLintConfig(content="extends: default")
114
+
115
+ errors: list[dict[str, t.Any]] = []
116
+ for path in paths:
117
+ if not os.path.isfile(path):
118
+ continue
119
+ process_yaml_file(errors, path, yamllint_config)
120
+
121
+ errors.sort(
122
+ key=lambda error: (error["path"], error["line"], error["col"], error["message"])
123
+ )
124
+ for error in errors:
125
+ prefix = f"{error['path']}:{error['line']}:{error['col']}: "
126
+ msg = error["message"]
127
+ if "note" in error:
128
+ msg = f"{msg}\nNote: {error['note']}"
129
+ for i, line in enumerate(msg.splitlines()):
130
+ print(f"{prefix}{line}")
131
+ if i == 0:
132
+ prefix = " " * len(prefix)
133
+
134
+ return len(errors) > 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python
2
2
 
3
- # Copyright (c) 2024, Felix Fontein <felix@fontein.de>
3
+ # Copyright (c) 2025, Felix Fontein <felix@fontein.de>
4
4
  # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt
5
5
  # or https://www.gnu.org/licenses/gpl-3.0.txt)
6
6
  # SPDX-License-Identifier: GPL-3.0-or-later
@@ -44,6 +44,13 @@ def lint(
44
44
  config: YamlLintConfig,
45
45
  extra_for_errors: dict[str, t.Any] | None = None,
46
46
  ) -> None:
47
+ # If the string start with optional whitespace + linebreak, skip that line
48
+ idx = data.find("\n")
49
+ if idx >= 0 and (idx == 0 or data[:idx].isspace()):
50
+ data = data[idx + 1 :]
51
+ row_offset += 1
52
+ col_offset = 0
53
+
47
54
  try:
48
55
  problems = linter.run(
49
56
  io.StringIO(data),
@@ -84,6 +91,20 @@ def lint(
84
91
  errors[-1].update(extra_for_errors)
85
92
 
86
93
 
94
+ def iterate_targets(
95
+ assignment: ast.Assign,
96
+ ) -> t.Iterable[tuple[ast.Constant, str, str]]:
97
+ if not isinstance(assignment.value, ast.Constant):
98
+ return
99
+ if not isinstance(assignment.value.value, str):
100
+ return
101
+ for target in assignment.targets:
102
+ try:
103
+ yield assignment.value, assignment.value.value, target.id # type: ignore
104
+ except AttributeError:
105
+ continue
106
+
107
+
87
108
  def process_python_file(
88
109
  errors: list[dict[str, t.Any]],
89
110
  path: str,
@@ -107,33 +128,39 @@ def process_python_file(
107
128
  )
108
129
  return
109
130
 
110
- # We look for top-level assignments
131
+ is_doc_fragment = path.startswith("plugins/doc_fragments/")
132
+
133
+ # We look for top-level assignments and classes
111
134
  for child in root.body:
135
+ if (
136
+ is_doc_fragment
137
+ and isinstance(child, ast.ClassDef)
138
+ and child.name == "ModuleDocFragment"
139
+ ):
140
+ for fragment in child.body:
141
+ if not isinstance(fragment, ast.Assign):
142
+ continue
143
+ for constant, data, fragment_name in iterate_targets(fragment):
144
+ lint(
145
+ errors=errors,
146
+ path=path,
147
+ data=data,
148
+ row_offset=constant.lineno - 1,
149
+ col_offset=constant.col_offset - 1,
150
+ section=fragment_name,
151
+ config=config,
152
+ )
112
153
  if not isinstance(child, ast.Assign):
113
154
  continue
114
- if not isinstance(child.value, ast.Constant):
115
- continue
116
- if not isinstance(child.value.value, str):
117
- continue
118
- for target in child.targets:
119
- try:
120
- section = target.id # type: ignore
121
- except AttributeError:
122
- continue
155
+ for constant, data, section in iterate_targets(child):
123
156
  if section not in ("DOCUMENTATION", "EXAMPLES", "RETURN"):
124
157
  continue
125
158
 
126
- # Extract value and offsets
127
- data = child.value.value
128
- row_offset = child.value.lineno - 1
129
- col_offset = child.value.col_offset - 1
130
-
131
- # If the string start with optional whitespace + linebreak, skip that line
132
- idx = data.find("\n")
133
- if idx >= 0 and (idx == 0 or data[:idx].isspace()):
134
- data = data[idx + 1 :]
135
- row_offset += 1
136
- col_offset = 0
159
+ # Handle special values
160
+ if data in ("#", " # ") and section == "RETURN":
161
+ # Not skipping it here could result in all kind of linting errors,
162
+ # like no document start, or trailing space.
163
+ continue
137
164
 
138
165
  # Check for non-YAML examples
139
166
  if section == EXAMPLES_SECTION:
@@ -146,8 +173,8 @@ def process_python_file(
146
173
  errors=errors,
147
174
  path=path,
148
175
  data=data,
149
- row_offset=row_offset,
150
- col_offset=col_offset,
176
+ row_offset=constant.lineno - 1,
177
+ col_offset=constant.col_offset - 1,
151
178
  section=section,
152
179
  config=config_examples if section == EXAMPLES_SECTION else config,
153
180
  )
@@ -46,6 +46,18 @@ def _interpret_config(config: Config) -> None:
46
46
  for name, source in config.collection_sources.items()
47
47
  }
48
48
  )
49
+ if config.collection_sources_per_ansible:
50
+ for (
51
+ ansible_core_version,
52
+ collection_sources,
53
+ ) in config.collection_sources_per_ansible.items():
54
+ setup_collection_sources(
55
+ {
56
+ name: CollectionSource(name=name, source=source.source)
57
+ for name, source in collection_sources.items()
58
+ },
59
+ ansible_core_version=ansible_core_version,
60
+ )
49
61
 
50
62
 
51
63
  def _convert_action_groups(
@@ -226,6 +238,7 @@ def _add_sessions(sessions: Sessions) -> None:
226
238
  cfg.core_python_versions
227
239
  ),
228
240
  controller_python_versions_only=cfg.controller_python_versions_only,
241
+ ansible_vars_from_env_vars=cfg.ansible_vars_from_env_vars,
229
242
  )
230
243
  if sessions.ansible_lint:
231
244
  add_ansible_lint(
@@ -44,6 +44,8 @@ def add_ansible_lint(
44
44
  command = ["ansible-lint", "--offline"]
45
45
  if strict:
46
46
  command.append("--strict")
47
+ if session.posargs:
48
+ command.extend(session.posargs)
47
49
  session.run(*command, env=env)
48
50
 
49
51
  ansible_lint.__doc__ = "Run ansible-lint."
@@ -16,12 +16,14 @@ from collections.abc import Callable
16
16
  from pathlib import Path
17
17
 
18
18
  import nox
19
+ from antsibull_fileutils.yaml import store_yaml_file
19
20
 
20
21
  from ..ansible import (
21
22
  AnsibleCoreVersion,
22
23
  get_ansible_core_info,
23
24
  get_ansible_core_package_name,
24
25
  get_supported_core_versions,
26
+ parse_ansible_core_version,
25
27
  )
26
28
  from ..paths import copy_directory_tree_into
27
29
  from ..python import get_installed_python_versions
@@ -33,17 +35,6 @@ from .utils import (
33
35
  )
34
36
 
35
37
 
36
- def _parse_ansible_core_version(
37
- version: str | AnsibleCoreVersion,
38
- ) -> AnsibleCoreVersion:
39
- if version in ("devel", "milestone"):
40
- # For some reason mypy doesn't notice that
41
- return t.cast(AnsibleCoreVersion, version)
42
- if isinstance(version, Version):
43
- return version
44
- return Version.parse(version)
45
-
46
-
47
38
  def add_ansible_test_session(
48
39
  *,
49
40
  name: str,
@@ -67,7 +58,7 @@ def add_ansible_test_session(
67
58
 
68
59
  Returns a list of Python versions set for this session.
69
60
  """
70
- parsed_ansible_core_version = _parse_ansible_core_version(ansible_core_version)
61
+ parsed_ansible_core_version = parse_ansible_core_version(ansible_core_version)
71
62
 
72
63
  def compose_dependencies() -> list[str]:
73
64
  deps = [
@@ -84,6 +75,7 @@ def add_ansible_test_session(
84
75
  install(session, *compose_dependencies())
85
76
  prepared_collections = prepare_collections(
86
77
  session,
78
+ ansible_core_version=parsed_ansible_core_version,
87
79
  install_in_site_packages=False,
88
80
  extra_deps_files=extra_deps_files,
89
81
  install_out_of_tree=True,
@@ -177,7 +169,7 @@ def add_ansible_test_sanity_test_session(
177
169
  """
178
170
  Add generic ansible-test sanity test session.
179
171
  """
180
- command = ["sanity", "--docker", "-v", "--color"]
172
+ command = ["sanity", "--color", "-v", "--docker"]
181
173
  if skip_tests:
182
174
  for test in skip_tests:
183
175
  command.extend(["--skip", test])
@@ -210,7 +202,7 @@ def _parse_min_max_except(
210
202
  max_version = Version.parse(max_version)
211
203
  if except_versions is None:
212
204
  return min_version, max_version, None
213
- evs = tuple(_parse_ansible_core_version(version) for version in except_versions)
205
+ evs = tuple(parse_ansible_core_version(version) for version in except_versions)
214
206
  return min_version, max_version, evs
215
207
 
216
208
 
@@ -311,7 +303,7 @@ def add_ansible_test_unit_test_session(
311
303
  add_ansible_test_session(
312
304
  name=name,
313
305
  description=description,
314
- ansible_test_params=["units", "--docker", "-v", "--color"],
306
+ ansible_test_params=["units", "--color", "-v", "--docker"],
315
307
  extra_deps_files=["tests/unit/requirements.yml"],
316
308
  default=default,
317
309
  ansible_core_version=ansible_core_version,
@@ -406,6 +398,7 @@ def add_ansible_test_integration_sessions_default_container(
406
398
  dict[str | AnsibleCoreVersion, list[str | Version]] | None
407
399
  ) = None,
408
400
  controller_python_versions_only: bool = False,
401
+ ansible_vars_from_env_vars: dict[str, str] | None = None,
409
402
  default: bool = False,
410
403
  ) -> None:
411
404
  """
@@ -418,6 +411,18 @@ def add_ansible_test_integration_sessions_default_container(
418
411
  controller Python versions.
419
412
  """
420
413
 
414
+ def callback_before() -> None:
415
+ if not ansible_vars_from_env_vars:
416
+ return
417
+
418
+ path = Path("tests", "integration", "integration_config.yml")
419
+ content: dict[str, t.Any] = {}
420
+ for ans_var, env_var in ansible_vars_from_env_vars.items():
421
+ value = os.environ.get(env_var)
422
+ if value is not None:
423
+ content[ans_var] = env_var
424
+ store_yaml_file(path, content, nice=True, sort_keys=True)
425
+
421
426
  def add_integration_tests(
422
427
  ansible_core_version: AnsibleCoreVersion,
423
428
  repo_name: str | None = None,
@@ -468,10 +473,10 @@ def add_ansible_test_integration_sessions_default_container(
468
473
  description=description,
469
474
  ansible_test_params=[
470
475
  "integration",
476
+ "--color",
477
+ "-v",
471
478
  "--docker",
472
479
  "default",
473
- "-v",
474
- "--color",
475
480
  "--python",
476
481
  str(py_version),
477
482
  ],
@@ -479,6 +484,7 @@ def add_ansible_test_integration_sessions_default_container(
479
484
  ansible_core_version=ansible_core_version,
480
485
  ansible_core_repo_name=repo_name,
481
486
  ansible_core_branch_name=branch_name,
487
+ callback_before=callback_before,
482
488
  default=False,
483
489
  register_name="integration",
484
490
  register_extra_data={
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
 
19
19
  import nox
20
20
 
21
+ from ..ansible import AnsibleCoreVersion, parse_ansible_core_version
21
22
  from ..collection import (
22
23
  CollectionData,
23
24
  setup_collections,
@@ -76,6 +77,7 @@ def _run_subprocess(args: list[str]) -> tuple[bytes, bytes]:
76
77
  def prepare_collections(
77
78
  session: nox.Session,
78
79
  *,
80
+ ansible_core_version: AnsibleCoreVersion | str | None = None,
79
81
  install_in_site_packages: bool,
80
82
  extra_deps_files: list[str | os.PathLike] | None = None,
81
83
  extra_collections: list[str] | None = None,
@@ -84,6 +86,11 @@ def prepare_collections(
84
86
  """
85
87
  Install collections in site-packages.
86
88
  """
89
+ parsed_ansible_core_version = (
90
+ parse_ansible_core_version(ansible_core_version)
91
+ if ansible_core_version is not None
92
+ else "devel"
93
+ )
87
94
  if install_out_of_tree and install_in_site_packages:
88
95
  raise ValueError(
89
96
  "install_out_of_tree=True cannot be combined with install_in_site_packages=True"
@@ -116,6 +123,7 @@ def prepare_collections(
116
123
  setup = setup_collections(
117
124
  place,
118
125
  _run_subprocess,
126
+ ansible_core_version=parsed_ansible_core_version,
119
127
  extra_deps_files=extra_deps_files,
120
128
  extra_collections=extra_collections,
121
129
  with_current=False,
@@ -10,6 +10,7 @@ Create nox lint sessions.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import json
13
14
  import os
14
15
  import shlex
15
16
  from pathlib import Path
@@ -29,6 +30,7 @@ from .utils import (
29
30
  compose_description,
30
31
  install,
31
32
  run_bare_script,
33
+ silence_run_verbosity,
32
34
  )
33
35
 
34
36
  CODE_FILES = [
@@ -189,6 +191,28 @@ def add_formatters(
189
191
  nox.session(name="formatters", default=False)(formatters)
190
192
 
191
193
 
194
+ def process_pylint_errors(
195
+ session: nox.Session,
196
+ prepared_collections: CollectionSetup,
197
+ output: str,
198
+ ) -> None:
199
+ """
200
+ Process errors reported by pylint in 'json2' format.
201
+ """
202
+ data = json.loads(output)
203
+ found_error = False
204
+ if data["messages"]:
205
+ for message in data["messages"]:
206
+ path = os.path.relpath(
207
+ message["absolutePath"], prepared_collections.current_path
208
+ )
209
+ prefix = f"{path}:{message['line']}:{message['column']}: [{message['messageId']}]"
210
+ print(f"{prefix} {message['message']} [{message['symbol']}]")
211
+ found_error = True
212
+ if found_error:
213
+ session.error("Pylint failed")
214
+
215
+
192
216
  def add_codeqa( # noqa: C901
193
217
  *,
194
218
  extra_code_files: list[str],
@@ -249,9 +273,17 @@ def add_codeqa( # noqa: C901
249
273
  ]
250
274
  )
251
275
  command.extend(["--source-roots", "."])
276
+ command.extend(["--output-format", "json2"])
252
277
  command.extend(session.posargs)
253
278
  command.extend(prepared_collections.prefix_current_paths(paths))
254
- session.run(*command)
279
+ with silence_run_verbosity():
280
+ # Exit code is OR of some of 1, 2, 4, 8, 16
281
+ output = session.run(
282
+ *command, silent=True, success_codes=list(range(0, 32))
283
+ )
284
+
285
+ if output:
286
+ process_pylint_errors(session, prepared_collections, output)
255
287
 
256
288
  def execute_pylint(
257
289
  session: nox.Session, prepared_collections: CollectionSetup
@@ -335,29 +367,22 @@ def add_yamllint(
335
367
  def execute_yamllint(session: nox.Session) -> None:
336
368
  # Run yamllint
337
369
  all_files = list_all_files()
338
- cwd = Path.cwd()
339
370
  all_yaml_filenames = [
340
- str(file.relative_to(cwd))
341
- for file in all_files
342
- if file.name.lower().endswith((".yml", ".yaml"))
371
+ file for file in all_files if file.name.lower().endswith((".yml", ".yaml"))
343
372
  ]
344
373
  if not all_yaml_filenames:
345
374
  session.warn("Skipping yamllint since no YAML file was found...")
346
375
  return
347
376
 
348
- command = ["yamllint"]
349
- if yamllint_config is not None:
350
- command.extend(
351
- [
352
- "-c",
353
- str(yamllint_config),
354
- ]
355
- )
356
- command.append("--strict")
357
- command.append("--")
358
- command.extend(all_yaml_filenames)
359
- command.extend(session.posargs)
360
- session.run(*command)
377
+ run_bare_script(
378
+ session,
379
+ "file-yamllint",
380
+ use_session_python=True,
381
+ files=all_yaml_filenames,
382
+ extra_data={
383
+ "config": to_str(yamllint_config),
384
+ },
385
+ )
361
386
 
362
387
  def execute_plugin_yamllint(session: nox.Session) -> None:
363
388
  # Run yamllint
@@ -415,6 +440,40 @@ def add_yamllint(
415
440
  nox.session(name="yamllint", default=False)(yamllint)
416
441
 
417
442
 
443
+ def process_mypy_errors(
444
+ session: nox.Session,
445
+ prepared_collections: CollectionSetup,
446
+ output: str,
447
+ ) -> None:
448
+ """
449
+ Process errors reported by mypy in 'json' format.
450
+ """
451
+ found_error = False
452
+ for line in output.splitlines():
453
+ if not line.strip():
454
+ continue
455
+ try:
456
+ data = json.loads(line)
457
+ path = os.path.relpath(
458
+ prepared_collections.current_place / data["file"],
459
+ prepared_collections.current_path,
460
+ )
461
+ prefix = f"{path}:{data['line']}:{data['column']}: [{data['severity']}]"
462
+ if data["code"]:
463
+ print(f"{prefix} {data['message']} [{data['code']}]")
464
+ else:
465
+ print(f"{prefix} {data['message']}")
466
+ if data["hint"]:
467
+ prefix = " " * len(prefix)
468
+ for hint in data["hint"].splitlines():
469
+ print(f"{prefix} {hint}")
470
+ except Exception: # pylint: disable=broad-exception-caught
471
+ session.warn(f"Cannot parse mypy output: {line}")
472
+ found_error = True
473
+ if found_error:
474
+ session.error("Type checking failed")
475
+
476
+
418
477
  def add_typing(
419
478
  *,
420
479
  extra_code_files: list[str],
@@ -459,13 +518,21 @@ def add_typing(
459
518
  )
460
519
  command.append("--namespace-packages")
461
520
  command.append("--explicit-package-bases")
521
+ command.extend(["--output", "json"])
462
522
  command.extend(session.posargs)
463
523
  command.extend(
464
524
  prepared_collections.prefix_current_paths(CODE_FILES + extra_code_files)
465
525
  )
466
- session.run(
467
- *command, env={"MYPYPATH": str(prepared_collections.current_place)}
468
- )
526
+ with silence_run_verbosity():
527
+ output = session.run(
528
+ *command,
529
+ env={"MYPYPATH": str(prepared_collections.current_place)},
530
+ silent=True,
531
+ success_codes=(0, 1, 2),
532
+ )
533
+
534
+ if output:
535
+ process_mypy_errors(session, prepared_collections, output)
469
536
 
470
537
  def typing(session: nox.Session) -> None:
471
538
  install(session, *compose_dependencies())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: antsibull-nox
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Changelog tool for Ansible-core and Ansible collections
5
5
  Project-URL: Documentation, https://ansible.readthedocs.io/projects/antsibull-nox/
6
6
  Project-URL: Source code, https://github.com/ansible-community/antsibull-nox/