Skip to content

Commit c87554f

Browse files
authored
ansible-output subcommand: refactor; add post-processor support (#402)
* Improve validation. * Refactoring. * Update changelog fragment. * Prevent error reporting. * Add post-processor support. * Improve docs. * Fix indentation, add link.
1 parent 4f6eabd commit c87554f

File tree

7 files changed

+302
-36
lines changed

7 files changed

+302
-36
lines changed
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
minor_changes:
22
- "Add a new subcommand ``ansible-output`` which allows to render Ansible output into RST code blocks
3-
(https://github.com/ansible-community/antsibull-docs/pull/397, https://github.com/ansible-community/antsibull-docs/pull/401)."
3+
(https://github.com/ansible-community/antsibull-docs/pull/397,
4+
https://github.com/ansible-community/antsibull-docs/pull/401,
5+
https://github.com/ansible-community/antsibull-docs/pull/402)."

docs/ansible-output.md

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ Also take a look at the example further below which demonstrates all of them.
9191

9292
* The `prepend_lines` key allows to prepend a multi-line YAML string to the `ansible-playbook` output.
9393

94+
* The `postprocessors` key allows to define a list of post-processors.
95+
This is explained in more detail in the [Post-processing ansible-playbook output section](#post-processing-ansible-playbook-output).
96+
9497
An example looks like this. The `console` code block contains the generated result:
9598
```rst
9699
This is an Ansible task we're going to reference in the playbook:
@@ -146,6 +149,9 @@ This is an Ansible task we're going to reference in the playbook:
146149
previous_code_block: yaml+jinja
147150
previous_code_block_index: -1
148151
152+
# No post-processors
153+
postprocessors: []
154+
149155
# The actual playbook to run:
150156
playbook: |-
151157
@{# Use the 'hosts' variable defined above #}@
@@ -177,6 +183,131 @@ The task produces the following output:
177183
}
178184
```
179185

186+
## Post-processing ansible-playbook output
187+
188+
Out of the box, you can post-process the `ansible-playbook` output in some ways:
189+
190+
* Skip a fixed number of lines at the top (`skip_first_lines`) or bottom (`skip_last_lines`).
191+
* Prepend lines to the output (`prepend_lines`).
192+
193+
This, together with chosing an appropriate callback plugin
194+
(like [community.general.tasks_only](https://docs.ansible.com/ansible/devel/collections/community/general/tasks_only_callback.html))
195+
gives you a lot of freedom to get the output you want.
196+
197+
In some cases, it is not sufficient though.
198+
For example, if you want to extract YAML output, and present it in a way that [matches your yamllint configuration](https://ansible.readthedocs.io/projects/antsibull-nox/config-file/#yamllint-part-of-the-yamllint-session).
199+
The default callback's YAML output suffers from [PyYAML's list indentation issue](https://github.com/yaml/pyyaml/issues/234),
200+
which causes problems with many yamllint configurations.
201+
Also, the [ansible.builtin.default callback's YAML output](https://docs.ansible.com/ansible/devel/collections/ansible/builtin/default_callback.html#parameter-result_format) is indented by 4 spaces,
202+
while most YAML is expected to be indented by 2 spaces.
203+
204+
If you use the above settings (`skip_first_lines` / `skip_last_lines`) to extract only the YAML content of one task of the playbook's output,
205+
you can for example use [Pretty YAML (pyaml)](https://pypi.org/project/pyaml/) to reformat it.
206+
For that, you can use the `postprocessors` list to specify a post-processor command:
207+
```yaml
208+
postprocessors:
209+
- command:
210+
- python
211+
- "-m"
212+
- pyaml
213+
```
214+
This tells `antsibull-docs ansible-output` to feed the extracted output
215+
(with `skip_first_lines`, `skip_last_lines`, and `prepend_lines` already processed)
216+
through standard input into the `python -m pyaml` process,
217+
and use its output instead.
218+
219+
A full example looks like this:
220+
```rst
221+
.. code-block:: yaml+jinja
222+
223+
input:
224+
- k0_x0: A0
225+
k1_x1: B0
226+
k2_x2: [C0]
227+
k3_x3: foo
228+
- k0_x0: A1
229+
k1_x1: B1
230+
k2_x2: [C1]
231+
k3_x3: bar
232+
233+
target:
234+
- {after: a0, before: k0_x0}
235+
- {after: a1, before: k1_x1}
236+
237+
result: "{{ input | community.general.replace_keys(target=target) }}"
238+
239+
.. ansible-output-data::
240+
241+
env:
242+
ANSIBLE_CALLBACK_RESULT_FORMAT: yaml
243+
variables:
244+
data:
245+
previous_code_block: yaml+jinja
246+
postprocessors:
247+
- command:
248+
- python
249+
- "-m"
250+
- pyaml
251+
language: yaml
252+
skip_first_lines: 4
253+
skip_last_lines: 3
254+
playbook: |-
255+
- hosts: localhost
256+
gather_facts: false
257+
tasks:
258+
- vars:
259+
@{{ data | indent(8) }}@
260+
ansible.builtin.debug:
261+
var: result
262+
263+
This results in:
264+
265+
.. code-block:: yaml
266+
267+
result:
268+
- a0: A0
269+
a1: B0
270+
k2_x2:
271+
- C0
272+
k3_x3: foo
273+
- a0: A1
274+
a1: B1
275+
k2_x2:
276+
- C1
277+
k3_x3: bar
278+
```
279+
280+
Right now there are two kind of post-processor entries in `postprocessors`:
281+
282+
1. Command-based post-processors:
283+
284+
You can provide a list `command`.
285+
This command is executed,
286+
the input fed in through standard input,
287+
and its standard output is taken as the output.
288+
289+
Example:
290+
```yaml
291+
postprocessors:
292+
- command:
293+
- python
294+
- "-m"
295+
- pyaml
296+
```
297+
298+
2. Name-reference post-processors:
299+
300+
You can use `name` to reference a named globally defined post-processor.
301+
This is right now only possible in collections,
302+
since you need to define these in the collection's config file
303+
(`docs/docsite/config.yml` - see the [Collection usage section](#collection-usage)).
304+
305+
Example:
306+
```yaml
307+
postprocessors:
308+
- name: reformat-yaml
309+
```
310+
180311
## Standalone usage
181312

182313
If you want to update a RST file, or all RST files in a directory, you can run antsibull-docs as follows:
@@ -196,7 +327,8 @@ If you run `antsibull-docs ansible-output` without a path, it assumes that you a
196327
It will check all `.rst` files in `docs/docsite/rst/`, if that directory exists,
197328
and load configuration from `docs/docsite/config.yml`.
198329
(See [more information on that configuration file](../collection-docs/#configuring-the-docsite).)
199-
The configuration allows you to specify entries for `env` for all code blocks:
330+
The configuration allows you to specify entries for `env` for all code blocks,
331+
and you can define global post-processors that can be referenced in `postprocessors`:
200332

201333
```yaml
202334
---
@@ -206,9 +338,21 @@ ansible_output:
206338
global_env:
207339
ANSIBLE_STDOUT_CALLBACK: community.general.tasks_only
208340
ANSIBLE_COLLECTIONS_TASKS_ONLY_NUMBER_OF_COLUMNS: 80
341+
342+
# Global post-processors for Ansible output
343+
global_postprocessors:
344+
# Keys are the names that can be referenced in ansible-output-data directives
345+
reformat-yaml:
346+
# For CLI tools, you can specify a command that accepts input on stdin
347+
# and outputs the result on stdout:
348+
command:
349+
- python
350+
- "-m"
351+
- pyaml
209352
```
210353

211-
This is useful to standardize the callback and its settings for most code blocks in a collection's extra docs.
354+
This is useful to standardize the callback and its settings for most code blocks in a collection's extra docs,
355+
and set up a pre-defined set of post-processors that can be used everywhere.
212356

213357
## Usage in CI
214358

src/antsibull_docs/cli/doc_commands/ansible_output.py

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@
3232
from sphinx_antsibull_ext.directive_helper import YAMLDirective
3333
from sphinx_antsibull_ext.schemas.ansible_output_data import (
3434
AnsibleOutputData,
35+
Postprocessor,
36+
PostprocessorCLI,
37+
PostprocessorNameRef,
3538
VariableSource,
39+
VariableSourceCodeBlock,
40+
VariableSourceValue,
3641
)
3742

3843
from ... import app_context
@@ -163,14 +168,15 @@ def _find_blocks(
163168
@dataclass
164169
class Environment:
165170
env: dict[str, str]
171+
global_postprocessors: dict[str, Postprocessor]
166172

167173

168174
def _get_variable_value(
169175
*, key: str, value: VariableSource, previous_blocks: list[CodeBlockInfo]
170176
) -> str:
171-
if value.value is not None:
177+
if isinstance(value, VariableSourceValue):
172178
return value.value
173-
if value.previous_code_block is None:
179+
if not isinstance(value, VariableSourceCodeBlock):
174180
raise AssertionError( # pragma: no cover
175181
"Implementation error: cannot handle {value!r}"
176182
)
@@ -240,6 +246,70 @@ def _strip_common_indent(lines: list[str]) -> list[str]:
240246
return [line[indent:] for line in lines]
241247

242248

249+
def _massage_stdout(
250+
stdout: str,
251+
*,
252+
skip_first_lines: int = 0,
253+
skip_last_lines: int = 0,
254+
prepend_lines: str | None = None,
255+
) -> list[str]:
256+
# Compute result lines
257+
lines = [line.rstrip() for line in stdout.split("\n")]
258+
lines = _strip_empty_lines(lines)
259+
260+
# Skip lines
261+
if skip_first_lines > 0:
262+
lines = lines[skip_first_lines:]
263+
if skip_last_lines > 0:
264+
lines = lines[:-skip_last_lines]
265+
266+
# Prepend lines
267+
if prepend_lines:
268+
lines = prepend_lines.split("\n") + lines
269+
270+
return _strip_common_indent(_strip_empty_lines(lines))
271+
272+
273+
def _apply_postprocessor(
274+
lines: list[str],
275+
*,
276+
cwd: Path,
277+
env: dict[str, str],
278+
postprocessor: Postprocessor,
279+
environment: Environment,
280+
) -> list[str]:
281+
flog = mlog.fields(func="_apply_postprocessor")
282+
283+
if isinstance(postprocessor, PostprocessorNameRef):
284+
ref = postprocessor.name
285+
try:
286+
postprocessor = environment.global_postprocessors[ref]
287+
except KeyError:
288+
raise ValueError( # pylint: disable=raise-missing-from
289+
f"No global postprocessor of name {ref!r} defined"
290+
)
291+
292+
if isinstance(postprocessor, PostprocessorCLI):
293+
flog.notice("Run postprocessor command: {}", postprocessor.command)
294+
try:
295+
result = subprocess.run(
296+
postprocessor.command,
297+
capture_output=True,
298+
input="\n".join(lines) + "\n",
299+
cwd=cwd,
300+
env=env,
301+
check=True,
302+
encoding="utf-8",
303+
)
304+
except subprocess.CalledProcessError as exc:
305+
raise ValueError(
306+
f"{exc}\nError output:\n{exc.stderr}\n\nStandard output:\n{exc.stdout}"
307+
) from exc
308+
lines = _massage_stdout(result.stdout)
309+
310+
return lines
311+
312+
243313
def _compute_code_block_content(
244314
data: _AnsibleOutputDataExt,
245315
*,
@@ -277,22 +347,27 @@ def _compute_code_block_content(
277347
) from exc
278348

279349
flog.notice("Post-process result")
280-
281-
# Compute result lines
282-
lines = [line.rstrip() for line in result.stdout.split("\n")]
283-
lines = _strip_empty_lines(lines)
284-
285-
# Skip lines
286-
if data.data.skip_first_lines > 0:
287-
lines = lines[data.data.skip_first_lines :]
288-
if data.data.skip_last_lines > 0:
289-
lines = lines[: -data.data.skip_last_lines]
290-
291-
# Prepend lines
292-
prepend_lines = (
293-
data.data.prepend_lines.split("\n") if data.data.prepend_lines else []
350+
lines = _massage_stdout(
351+
result.stdout,
352+
skip_first_lines=data.data.skip_first_lines,
353+
skip_last_lines=data.data.skip_last_lines,
354+
prepend_lines=data.data.prepend_lines,
294355
)
295-
return _strip_common_indent(_strip_empty_lines(prepend_lines + lines))
356+
for postprocessor in data.data.postprocessors:
357+
flog.notice("Run post-processor {}", postprocessor)
358+
try:
359+
lines = _apply_postprocessor(
360+
lines,
361+
cwd=directory,
362+
env=env,
363+
postprocessor=postprocessor,
364+
environment=environment,
365+
)
366+
except ValueError as exc:
367+
raise ValueError(
368+
f"Error while running post-processor {postprocessor}:\n{exc}"
369+
) from exc
370+
return lines
296371

297372

298373
def _replace(
@@ -581,10 +656,12 @@ def get_environment(
581656
else:
582657
collections_path = f"{collection_path}"
583658
env["ANSIBLE_COLLECTIONS_PATH"] = collections_path
659+
postprocessors = {}
584660
if collection_config is not None:
585661
env.update(collection_config.ansible_output.global_env)
662+
postprocessors.update(collection_config.ansible_output.global_postprocessors)
586663
flog.notice("Environment template: {}", env)
587-
return Environment(env=env)
664+
return Environment(env=env, global_postprocessors=postprocessors)
588665

589666

590667
def detect_color(*, force: bool | None = None) -> bool:

src/antsibull_docs/schemas/collection_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,23 @@
55
# SPDX-FileCopyrightText: 2023, Ansible Project
66
"""Schemas for collection config files."""
77

8+
import typing as t
9+
810
import pydantic as p
911

12+
from sphinx_antsibull_ext.schemas.ansible_output_data import (
13+
Postprocessor,
14+
PostprocessorNameRef,
15+
)
16+
17+
18+
def _is_not_name_ref(value: Postprocessor) -> Postprocessor:
19+
if isinstance(value, PostprocessorNameRef):
20+
raise ValueError(
21+
"Cannot define name reference postprocessors in collection config"
22+
)
23+
return value
24+
1025

1126
class ChangelogConfig(p.BaseModel):
1227
# Whether to write the changelog
@@ -17,6 +32,11 @@ class AnsibleOutputConfig(p.BaseModel):
1732
# Environment variables to inject for every ansible-output-data
1833
global_env: dict[str, str] = {}
1934

35+
# Named postprocessors
36+
global_postprocessors: dict[
37+
str, t.Annotated[Postprocessor, p.AfterValidator(_is_not_name_ref)]
38+
] = {}
39+
2040
@p.field_validator("global_env", mode="before")
2141
@classmethod
2242
def convert_dict_values(cls, obj):

src/sphinx_antsibull_ext/directives.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ class _AnsibleOutputDataDirective(YAMLDirective[AnsibleOutputData]):
7676
wrap_as_data = True
7777
schema = AnsibleOutputData
7878

79+
def _handle_error(self, message: str, from_exc: Exception) -> list[nodes.Node]:
80+
# Do not report errors when simply building docs.
81+
# Errors should be reported when running 'antsibull-docs lint-collection-docs'.
82+
return []
83+
7984
def _run(self, content_str: str, content: AnsibleOutputData) -> list[nodes.Node]:
8085
# This directive should produce no output. It is used in the ansible-output subcommand.
8186
return []

0 commit comments

Comments
 (0)