Skip to content

Commit 64a17fd

Browse files
authored
Extended plugin docs lint (#12)
* Move load_collection_name to new module. * Call plugin docs linter. * Allow to disable plugin docs linting. * Add --plugin-docs flag for lint-collection-docs. * Make new code work with rstcheck 6. * Linting.
1 parent 6d79596 commit 64a17fd

File tree

11 files changed

+357
-60
lines changed

11 files changed

+357
-60
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
minor_changes:
2+
- "The ``lint-collection-docs`` subcommand has a new boolean flag ``--plugin-docs`` which renders the plugin docs
3+
to RST and validates them with rstcheck. This can be used as a lighter version of rendering the docsite in CI
4+
(https://github.com/ansible-community/antsibull-docs/pull/12)."

src/antsibull_docs/cli/antsibull_docs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,12 @@ def parse_args(program_name: str, args: List[str]) -> argparse.Namespace:
360360
metavar='/path/to/collection',
361361
help='path to collection (directory that includes'
362362
' galaxy.yml)')
363+
lint_collection_docs_parser.add_argument('--plugin-docs',
364+
dest='plugin_docs', action=BooleanOptionalAction,
365+
default=False,
366+
help='Determine whether to also check RST file'
367+
' generation and validation for plugins and roles'
368+
' in this collection. (default: True)')
363369

364370
flog.debug('Argument parser setup')
365371

src/antsibull_docs/cli/doc_commands/lint_collection_docs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from ...collection_links import lint_collection_links
1010
from ...lint_extra_docs import lint_collection_extra_docs_files
11+
from ...lint_plugin_docs import lint_collection_plugin_docs
1112

1213

1314
mlog = log.fields(mod=__name__)
@@ -26,13 +27,18 @@ def lint_collection_docs() -> int:
2627
app_ctx = app_context.app_ctx.get()
2728

2829
collection_root = app_ctx.extra['collection_root_path']
30+
plugin_docs = app_ctx.extra['plugin_docs']
2931

3032
flog.notice('Linting extra docs files')
3133
errors = lint_collection_extra_docs_files(collection_root)
3234

3335
flog.notice('Linting collection links')
3436
errors.extend(lint_collection_links(collection_root))
3537

38+
if plugin_docs:
39+
flog.notice('Linting plugin docs')
40+
errors.extend(lint_collection_plugin_docs(collection_root))
41+
3642
messages = sorted(set(f'{error[0]}:{error[1]}:{error[2]}: {error[3]}' for error in errors))
3743

3844
for message in messages:

src/antsibull_docs/docs_parsing/ansible_doc.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,31 @@ def _extract_ansible_builtin_metadata(stdout: str) -> AnsibleCollectionMetadata:
184184
return AnsibleCollectionMetadata(path=path, version=version)
185185

186186

187+
def parse_ansible_galaxy_collection_list(raw_output: str,
188+
collection_names: t.Optional[t.List[str]] = None,
189+
) -> t.List[t.Tuple[str, str, str, t.Optional[str]]]:
190+
result = []
191+
current_base_path = None
192+
for line in raw_output.splitlines():
193+
parts = line.split()
194+
if len(parts) >= 2:
195+
if parts[0] == '#':
196+
current_base_path = parts[1]
197+
elif current_base_path is not None:
198+
collection_name = parts[0]
199+
version = parts[1]
200+
if '.' in collection_name:
201+
if collection_names is None or collection_name in collection_names:
202+
namespace, name = collection_name.split('.', 2)
203+
result.append((
204+
namespace,
205+
name,
206+
os.path.join(current_base_path, namespace, name),
207+
None if version == '*' else version
208+
))
209+
return result
210+
211+
187212
def get_collection_metadata(venv: t.Union['VenvRunner', 'FakeVenvRunner'],
188213
env: t.Dict[str, str],
189214
collection_names: t.Optional[t.List[str]] = None,
@@ -201,21 +226,9 @@ def get_collection_metadata(venv: t.Union['VenvRunner', 'FakeVenvRunner'],
201226
venv_ansible_galaxy = venv.get_command('ansible-galaxy')
202227
ansible_collection_list_cmd = venv_ansible_galaxy('collection', 'list', _env=env)
203228
raw_result = ansible_collection_list_cmd.stdout.decode('utf-8', errors='surrogateescape')
204-
current_base_path = None
205-
for line in raw_result.splitlines():
206-
parts = line.split()
207-
if len(parts) >= 2:
208-
if parts[0] == '#':
209-
current_base_path = parts[1]
210-
else:
211-
collection_name = parts[0]
212-
version = parts[1]
213-
if '.' in collection_name:
214-
if collection_names is None or collection_name in collection_names:
215-
namespace, name = collection_name.split('.', 2)
216-
collection_metadata[collection_name] = AnsibleCollectionMetadata(
217-
path=os.path.join(current_base_path, namespace, name),
218-
version=None if version == '*' else version)
229+
for namespace, name, path, version in parse_ansible_galaxy_collection_list(raw_result):
230+
collection_metadata[f'{namespace}.{name}'] = AnsibleCollectionMetadata(
231+
path=path, version=version)
219232

220233
return collection_metadata
221234

src/antsibull_docs/lint_extra_docs.py

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
# coding: utf-8
2-
# Author: Felix Fontein <[email protected]>
2+
# Author: Felix Fontein <[email protected]>
33
# License: GPLv3+
44
# Copyright: Ansible Project, 2021
55
"""Lint extra collection documentation in docs/docsite/."""
66

7-
import json
87
import os
98
import os.path
109
import re
1110
import typing as t
1211

13-
from antsibull_core.yaml import load_yaml_file
14-
1512
from .extra_docs import (
1613
find_extra_docs,
1714
lint_required_conditions,
@@ -20,28 +17,12 @@
2017
)
2118
from .rstcheck import check_rst_content
2219

20+
from .lint_helpers import (
21+
load_collection_name,
22+
)
2323

24-
_RST_LABEL_DEFINITION = re.compile(r'''^\.\. _([^:]+):''')
25-
26-
27-
def load_collection_name(path_to_collection: str) -> str:
28-
'''Load collection name (namespace.name) from collection's galaxy.yml.'''
29-
manifest_json_path = os.path.join(path_to_collection, 'MANIFEST.json')
30-
if os.path.isfile(manifest_json_path):
31-
with open(manifest_json_path, 'rb') as f:
32-
manifest_json = json.load(f)
33-
# pylint:disable-next=consider-using-f-string
34-
collection_name = '{namespace}.{name}'.format(**manifest_json['collection_info'])
35-
return collection_name
36-
37-
galaxy_yml_path = os.path.join(path_to_collection, 'galaxy.yml')
38-
if os.path.isfile(galaxy_yml_path):
39-
galaxy_yml = load_yaml_file(galaxy_yml_path)
40-
# pylint:disable-next=consider-using-f-string
41-
collection_name = '{namespace}.{name}'.format(**galaxy_yml)
42-
return collection_name
4324

44-
raise Exception(f'Cannot find files {manifest_json_path} and {galaxy_yml_path}')
25+
_RST_LABEL_DEFINITION = re.compile(r'''^\.\. _([^:]+):''')
4526

4627

4728
# pylint:disable-next=unused-argument

src/antsibull_docs/lint_helpers.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# coding: utf-8
2+
# Author: Felix Fontein <[email protected]>
3+
# License: GPLv3+
4+
# Copyright: Ansible Project, 2022
5+
"""Lint plugin docs."""
6+
7+
import json
8+
import os
9+
import os.path
10+
import typing as t
11+
12+
from antsibull_core.yaml import load_yaml_file
13+
14+
15+
def load_collection_info(path_to_collection: str) -> t.Dict[str, t.Any]:
16+
'''Load collection name (namespace.name) from collection's galaxy.yml.'''
17+
manifest_json_path = os.path.join(path_to_collection, 'MANIFEST.json')
18+
if os.path.isfile(manifest_json_path):
19+
with open(manifest_json_path, 'rb') as f:
20+
manifest_json = json.load(f)
21+
return manifest_json['collection_info']
22+
23+
galaxy_yml_path = os.path.join(path_to_collection, 'galaxy.yml')
24+
if os.path.isfile(galaxy_yml_path):
25+
galaxy_yml = load_yaml_file(galaxy_yml_path)
26+
return galaxy_yml
27+
28+
raise Exception(f'Cannot find files {manifest_json_path} and {galaxy_yml_path}')
29+
30+
31+
def load_collection_name(path_to_collection: str) -> str:
32+
'''Load collection name (namespace.name) from collection's galaxy.yml.'''
33+
info = load_collection_info(path_to_collection)
34+
# pylint:disable-next=consider-using-f-string
35+
collection_name = '{namespace}.{name}'.format(**info)
36+
return collection_name
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# coding: utf-8
2+
# Author: Felix Fontein <[email protected]>
3+
# License: GPLv3+
4+
# Copyright: Ansible Project, 2022
5+
"""Lint plugin docs."""
6+
7+
import os
8+
import shutil
9+
import tempfile
10+
import typing as t
11+
12+
import sh
13+
14+
from antsibull_core.compat import asyncio_run
15+
from antsibull_core.venv import FakeVenvRunner
16+
17+
from .lint_helpers import (
18+
load_collection_info,
19+
)
20+
21+
from .docs_parsing.ansible_doc import (
22+
parse_ansible_galaxy_collection_list,
23+
)
24+
25+
from .augment_docs import augment_docs
26+
from .cli.doc_commands.stable import (
27+
normalize_all_plugin_info,
28+
get_plugin_contents,
29+
get_collection_contents,
30+
)
31+
from .collection_links import load_collections_links
32+
from .docs_parsing.parsing import get_ansible_plugin_info
33+
from .docs_parsing.routing import (
34+
load_all_collection_routing,
35+
remove_redirect_duplicates,
36+
)
37+
from .jinja2.environment import doc_environment
38+
from .write_docs import create_plugin_rst
39+
from .rstcheck import check_rst_content
40+
41+
42+
class CollectionCopier:
43+
dir: t.Optional[str]
44+
45+
def __init__(self):
46+
self.dir = None
47+
48+
def __enter__(self):
49+
if self.dir is None:
50+
raise AssertionError('Collection copier already initialized')
51+
self.dir = os.path.realpath(tempfile.mkdtemp(prefix='antsibull-docs-'))
52+
return self
53+
54+
def add_collection(self, collecion_source_path: str, namespace: str, name: str) -> None:
55+
self_dir = self.dir
56+
if self_dir is None:
57+
raise AssertionError('Collection copier not initialized')
58+
collection_container_dir = os.path.join(
59+
self_dir, 'ansible_collections', namespace)
60+
os.makedirs(collection_container_dir, exist_ok=True)
61+
62+
collection_dir = os.path.join(collection_container_dir, name)
63+
shutil.copytree(collecion_source_path, collection_dir, symlinks=True)
64+
65+
def __exit__(self, type_, value, traceback_):
66+
self_dir = self.dir
67+
if self_dir is None:
68+
raise AssertionError('Collection copier not initialized')
69+
shutil.rmtree(self_dir, ignore_errors=True)
70+
self.dir = None
71+
72+
73+
class CollectionFinder:
74+
def __init__(self):
75+
self.collections = {}
76+
stdout = sh.Command('ansible-galaxy')('collection', 'list').stdout
77+
raw_output = stdout.decode('utf-8', errors='surrogateescape')
78+
for namespace, name, path, _ in reversed(parse_ansible_galaxy_collection_list(raw_output)):
79+
self.collections[f'{namespace}.{name}'] = path
80+
81+
def find(self, namespace, name):
82+
return self.collections.get(f'{namespace}.{name}')
83+
84+
85+
def _lint_collection_plugin_docs(collections_dir: str, collection_name: str,
86+
original_path_to_collection: str,
87+
) -> t.List[t.Tuple[str, int, int, str]]:
88+
# Load collection docs
89+
venv = FakeVenvRunner()
90+
plugin_info, collection_metadata = asyncio_run(get_ansible_plugin_info(
91+
venv, collections_dir, collection_names=[collection_name]))
92+
# Load routing information
93+
collection_routing = asyncio_run(load_all_collection_routing(collection_metadata))
94+
# Process data
95+
remove_redirect_duplicates(plugin_info, collection_routing)
96+
plugin_info, nonfatal_errors = asyncio_run(normalize_all_plugin_info(plugin_info))
97+
augment_docs(plugin_info)
98+
# Load link data
99+
link_data = asyncio_run(load_collections_links(
100+
{name: data.path for name, data in collection_metadata.items()}))
101+
# More processing
102+
plugin_contents = get_plugin_contents(plugin_info, nonfatal_errors)
103+
collection_to_plugin_info = get_collection_contents(plugin_contents)
104+
for collection in collection_metadata:
105+
collection_to_plugin_info[collection] # pylint:disable=pointless-statement
106+
# Collect non-fatal errors
107+
result = []
108+
for plugin_type, plugins in sorted(nonfatal_errors.items()):
109+
for plugin_name, errors in sorted(plugins.items()):
110+
for error in errors:
111+
result.append((
112+
os.path.join(original_path_to_collection, 'plugins', plugin_type, plugin_name),
113+
0,
114+
0,
115+
error,
116+
))
117+
# Compose RST files and check for errors
118+
# Setup the jinja environment
119+
env = doc_environment(('antsibull_docs.data', 'docsite'))
120+
# Get the templates
121+
plugin_tmpl = env.get_template('plugin.rst.j2')
122+
role_tmpl = env.get_template('role.rst.j2')
123+
error_tmpl = env.get_template('plugin-error.rst.j2')
124+
125+
for collection_name_, plugins_by_type in collection_to_plugin_info.items():
126+
for plugin_type, plugins in plugins_by_type.items():
127+
plugin_type_tmpl = plugin_tmpl
128+
if plugin_type == 'role':
129+
plugin_type_tmpl = role_tmpl
130+
for plugin_short_name, dummy_ in plugins.items():
131+
plugin_name = '.'.join((collection_name_, plugin_short_name))
132+
rst_content = create_plugin_rst(
133+
collection_name_, collection_metadata[collection_name_],
134+
link_data[collection_name_],
135+
plugin_short_name, plugin_type,
136+
plugin_info[plugin_type].get(plugin_name),
137+
nonfatal_errors[plugin_type][plugin_name],
138+
plugin_type_tmpl, error_tmpl,
139+
use_html_blobs=False,
140+
)
141+
path = os.path.join(
142+
original_path_to_collection, 'plugins', plugin_type,
143+
f'{plugin_short_name}.rst')
144+
rst_results = check_rst_content(
145+
rst_content, filename=path,
146+
ignore_directives=['rst-class'],
147+
)
148+
result.extend([(path, result[0], result[1], result[2]) for result in rst_results])
149+
return result
150+
151+
152+
def lint_collection_plugin_docs(path_to_collection: str) -> t.List[t.Tuple[str, int, int, str]]:
153+
try:
154+
info = load_collection_info(path_to_collection)
155+
namespace = info['namespace']
156+
name = info['name']
157+
dependencies = info.get('dependencies') or {}
158+
except Exception: # pylint:disable=broad-except
159+
return [(
160+
path_to_collection, 0, 0,
161+
'Cannot identify collection with galaxy.yml or MANIFEST.json at this path')]
162+
result = []
163+
collection_name = f'{namespace}.{name}'
164+
done_dependencies = {collection_name}
165+
dependencies = sorted(dependencies)
166+
with CollectionCopier() as copier:
167+
# Copy collection
168+
copier.add_collection(path_to_collection, namespace, name)
169+
# Copy all dependencies
170+
if dependencies:
171+
collection_finder = CollectionFinder()
172+
while dependencies:
173+
dependency = dependencies.pop(0)
174+
if dependency in done_dependencies:
175+
continue
176+
dep_namespace, dep_name = dependency.split('.', 2)
177+
dep_collection_path = collection_finder.find(dep_namespace, dep_name)
178+
if dep_collection_path:
179+
copier.add_collection(dep_collection_path, dep_namespace, dep_name)
180+
try:
181+
info = load_collection_info(dep_collection_path)
182+
dependencies.extend(sorted(info.get('dependencies') or {}))
183+
except Exception: # pylint:disable=broad-except
184+
result.append((
185+
dep_collection_path, 0, 0,
186+
'Cannot identify collection with galaxy.yml or MANIFEST.json'
187+
' at this path'))
188+
# Load docs
189+
result.extend(_lint_collection_plugin_docs(
190+
copier.dir, collection_name, path_to_collection))
191+
return result

0 commit comments

Comments
 (0)