Skip to content

Commit a272e11

Browse files
authored
Merge branch '3.x' into feat/ci-cd-release-improvements
2 parents 26cdc4c + 93542da commit a272e11

73 files changed

Lines changed: 1782 additions & 48 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.editorconfig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
[*]
2+
end_of_line = lf
3+
insert_final_newline = true
4+
max_line_length = 100
5+
6+
[*.py]
7+
indent_style = space
8+
indent_size = 4
9+
110
[*.json]
211
indent_style = space
312
indent_size = 4

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ docs/_deploy_globals.rst
3131

3232
.idea/
3333
.vscode/
34+
.claude/settings.local.json
3435

3536
pyinfra-debug.log
3637
Makefile

AGENTS.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# AGENTS.md
2+
3+
This file provides guidance to coding agents, like Claude Code (claude.ai/code), when working with
4+
code in this repository.
5+
6+
## About pyinfra
7+
8+
pyinfra turns Python code into shell commands and runs them on servers. Think Ansible, but Python
9+
instead of YAML, and much faster. It supports SSH, local machine, Docker, and more via connectors.
10+
11+
## Development Setup
12+
13+
```bash
14+
uv sync # Install all dependencies into managed venv
15+
```
16+
17+
## Common Commands
18+
19+
```bash
20+
# Run tests
21+
scripts/dev-test.sh
22+
# or directly:
23+
uv run pytest --cov --disable-warnings -m 'not end_to_end'
24+
25+
# Run fixtures for a single operation or fact
26+
uv run pytest tests/test_operations.py -k "apt.packages"
27+
uv run pytest tests/test_facts.py -k "LinuxHardware"
28+
29+
# End-to-end tests (require Docker/SSH/local targets)
30+
uv run pytest -m end_to_end_local
31+
uv run pytest -m end_to_end_docker
32+
33+
# Lint and type check
34+
scripts/dev-lint.sh
35+
# Individually:
36+
uv run ruff check
37+
uv run ruff format --check
38+
uv run mypy
39+
uv run python scripts/lint_arguments_sync.py
40+
41+
# Auto-format
42+
scripts/dev-format.sh
43+
```
44+
45+
## Architecture
46+
47+
The repo has two top-level packages under `src/`:
48+
49+
- **`pyinfra/`** — core library (operations, facts, connectors, API)
50+
- **`pyinfra_cli/`** — CLI wrapper using Click + gevent
51+
52+
### Core Concepts
53+
54+
**Operations** (`src/pyinfra/operations/`) — Declarative functions (e.g. `apt.packages`,
55+
`files.put`) that describe desired state. Each operation uses the `@operation` decorator from
56+
`api/operation.py`, generates a list of commands, and is idempotent. Operations call facts to
57+
read current state, then return commands to reach desired state.
58+
59+
**Facts** (`src/pyinfra/facts/`) — Read system state (e.g. `AptPackages`, `LinuxHardware`). Each
60+
fact is a class extending `FactBase` with a `command` attribute and a `process()` method that
61+
parses command output. Facts are cached per-host per-run.
62+
63+
**Connectors** (`src/pyinfra/connectors/`) — Abstractions for how to connect to and execute on a
64+
target (SSH, local, Docker, chroot, Terraform, Vagrant, etc.). New connectors should be separate
65+
packages, not added to this repo.
66+
67+
**API** (`src/pyinfra/api/`) — The core engine:
68+
- `state.py` — Global deploy state, callbacks, host grouping
69+
- `host.py` — Per-host metadata and fact access
70+
- `inventory.py` — Collection of hosts and groups
71+
- `operation.py``@operation` decorator that wraps functions into deploy operations
72+
- `operations.py` — Execution engine that runs operations across hosts
73+
- `command.py` — Command types: `StringCommand`, `FileUploadCommand`, `RsyncCommand`,
74+
`QuoteString`, `MaskString`, etc.
75+
- `facts.py` — Fact base classes and execution logic
76+
- `deploy.py``@deploy` decorator for grouping operations
77+
- `connect.py` — Connector lifecycle (connect/disconnect)
78+
- `config.py``Config` object with all configuration options
79+
- `arguments.py` / `arguments_typed.py` — Global operation arguments (e.g. `_sudo`, `_su_user`);
80+
**these two files must stay in sync** — CI enforces this via `scripts/lint_arguments_sync.py`,
81+
so touching one requires touching the other
82+
- `output.py` — Pluggable output functions (decoupled from Click for testability)
83+
84+
**Context** (`src/pyinfra/context.py`) — Thread-local (gevent-safe) context objects: `host`,
85+
`state`, `config`, `inventory`. Operations access the current host via `pyinfra.context.host`
86+
rather than explicit passing.
87+
88+
**Concurrency** — Uses gevent greenlets for parallel host execution. `pyinfra_cli/main.py`
89+
monkey-patches stdlib at startup.
90+
91+
### Adding Operations / Facts
92+
93+
Operations and facts are auto-discovered from their respective directories. A new
94+
`src/pyinfra/operations/mytool.py` is immediately available as `from pyinfra.operations import
95+
mytool`.
96+
97+
- Operations must be idempotent and use facts to check current state
98+
- Facts must implement `command` (shell command to run) and `process(output)` (parse result)
99+
- Both need corresponding tests (see fixture convention below)
100+
- Every operation/fact module must be registered in `pyinfra-metadata.toml` as a plugin with
101+
tags; omitting this won't break tests but will break docs generation
102+
103+
**Operation / fact tests are YAML or JSON fixtures, not Python tests.** Drop a file under
104+
`tests/operations/<module>.<op>/` or `tests/facts/<module>.<Fact>/` — it is auto-discovered by
105+
the `testgen` metaclass. Prefer YAML for new fixtures. To cover a new code path, add a fixture —
106+
do not write a new Python test.
107+
108+
Operation fixture structure (`tests/operations/<module>.<op>/<name>.yaml`):
109+
110+
```yaml
111+
args:
112+
- positional_arg
113+
kwargs:
114+
param: value
115+
facts:
116+
module.FactClass: {} # a dict of mock values keyed by object_id and attribute
117+
commands:
118+
- shell command that should be produced
119+
```
120+
121+
Optional keys: `exception` (e.g. `{name: OperationError, message: "..."}`), `noop_description`.
122+
123+
Fact fixture structure (`tests/facts/<module>.<Fact>/<name>.yaml`):
124+
125+
```yaml
126+
command: shell command the fact runs
127+
requires_command: binary # optional
128+
output: |
129+
raw stdout to parse
130+
fact:
131+
item:
132+
key: value # expected return value of process()
133+
```
134+
135+
**Docstring format** — pyinfra uses `+ param: description` bullets (parsed by
136+
`scripts/generate_operations_docs.py`). Do not use Google/NumPy/Sphinx style — it will silently
137+
break docs generation.
138+
139+
**Shell safety** — user-supplied values must be composed into shell commands using `StringCommand`
140+
+ `QuoteString` / `MaskString` from `pyinfra.api`. Do not use plain string formatting (e.g.
141+
`"rm -f {}".format(path)`) for user-controlled values.
142+
143+
**Optional parameter defaults** — optional parameters must default to `None`, not `""`. Older
144+
operations in the codebase use `""` defaults; do not replicate this pattern.
145+
146+
## Branch Strategy
147+
148+
PRs target the latest major branch (`3.x`). One branch per major version exists (`2.x`, `1.x`,
149+
etc.).
150+
151+
## PR Checklist
152+
153+
- Tests pass (`scripts/dev-test.sh`)
154+
- Lint/types pass (`scripts/dev-lint.sh`)
155+
- New operations/facts include tests and documentation

AI_POLICY.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# AI Usage Policy
2+
3+
The pyinfra project has strict rules for AI usage:
4+
5+
- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude
6+
Code, Cursor, Amp) along with the extent that the work was AI-assisted.
7+
8+
- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes
9+
do and how they interact with the greater system without the aid of AI tools, do not contribute
10+
to this project.
11+
12+
- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This
13+
means that any content generated with AI must have been reviewed _and edited_ by a human before
14+
submission. AI is very good at being overly verbose and including noise that distracts from the
15+
main point. Humans must do their research and trim this down.
16+
17+
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the
18+
only acceptable AI-generated content, per the other rules in this policy.
19+
20+
## There are Humans Here
21+
22+
Please remember that pyinfra is maintained by humans.
23+
24+
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines,
25+
too). It is a boundary point at which people interact with each other and the work done. It is
26+
rude and disrespectful to approach this boundary with low-effort, unqualified work, since it puts
27+
the burden of validation on the maintainer.
28+
29+
In a perfect world, AI would produce high-quality, accurate work every time. But today, that
30+
reality depends on the driver of the AI. And today, most drivers of AI are just not good enough.
31+
So, until either the people get better, the AI gets better, or both, we have to have strict rules
32+
to protect maintainers.
33+
34+
## AI is Welcome Here
35+
36+
pyinfra is written with plenty of AI assistance, and many maintainers embrace AI tools as a
37+
productive tool in their workflow. As a project, we welcome AI as a tool!
38+
39+
**Our reason for the strict AI policy is not due to an anti-AI stance**, but instead due to the
40+
number of highly unqualified people using AI. It's the people, not the tools, that are the problem.
41+
42+
I include this section to be transparent about the project's usage about AI for people who may
43+
disagree with it, and to address the misconception that this policy is anti-AI in nature.
44+
45+
## Attribution
46+
47+
This policy is based on the AI Usage Policy of the
48+
[Ghostty project](https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md).

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
# Unreleased
2+
3+
Core:
4+
5+
- api.facts: `requires_command` now uses an if/then/else shell guard with a sentinel marker
6+
(`##PYINFRA_NOCMD##`) so that "binary absent" can be distinguished from "binary returned no data"
7+
- api.facts: add `check_preconditions(state, host)` hook on `FactBase` for runtime prerequisite checks
8+
(e.g. kernel module loaded, service running) — return a reason string or `None`
9+
- api.exceptions: add `FactNotCollected`, `MissingCommandError`, `FactPreconditionError` exception
10+
hierarchy; both exceptions are phase-aware (silent during prepare, raised during execute)
11+
- facts.zfs: fix `ZfsDatasets.requires_command` returning `"zpool"` instead of `"zfs"`; add
12+
`ZfsPools` fact; add `ZfsDatasets.check_preconditions()` checking kernel module via `server.KernelModules`
13+
114
# v3.7
215

316
Thank you to all contributors - particular shout out to @wowi42 for an incredible run of PRs!

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# CLAUDE.md
2+
3+
See @AGENTS.md for guidance.

docs/api/facts.md

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,80 @@ and a ``process`` function. The command is executed on the target host and the o
55
passed (as a ``list`` of lines) to the ``process`` handler to generate fact data. Facts can output anything, normally a ``list`` or ``dict``.
66

77
Fact classes may provide a ``default`` function that takes no arguments (except ``self``). The return value of this function is used if an error
8-
occurs during fact collection. Additionally, a ``requires_command`` variable can be set on the fact that specifies a command that must be available
9-
on the host to collect the fact. If this command is not present on the host, the fact will be set to the default, or empty if no ``default`` function
10-
is available.
8+
occurs during fact collection.
9+
10+
## Guarding against missing binaries: `requires_command`
11+
12+
Override `requires_command` to declare a binary that must be present on the remote host before
13+
the fact command is run. When the binary is absent pyinfra emits a unique sentinel instead of
14+
executing the command, then raises `MissingCommandError` internally:
15+
16+
```py
17+
from pyinfra.api import FactBase
18+
19+
class ZfsPools(FactBase):
20+
def requires_command(self) -> str:
21+
return "zpool"
22+
23+
def command(self) -> str:
24+
return "zpool get -H all"
25+
```
26+
27+
**Phase-aware behaviour** — The exception is handled differently depending on the deploy phase:
28+
29+
- **Prepare phase**: the fact returns `default()` silently. The binary may simply not be
30+
installed yet; a later operation will install it.
31+
- **Execute phase** (v3): a warning is logged and `default()` is returned. This preserves
32+
backwards compatibility for deploys that rely on `default()` being returned when a binary
33+
is absent.
34+
- **Execute phase** (v4): `MissingCommandError` will be raised so the developer knows the
35+
deploy is incorrectly ordered (the install step must come first).
36+
37+
## Checking runtime prerequisites: `check_preconditions()`
38+
39+
Some facts require more than just a binary — they need a specific runtime state (e.g. a kernel
40+
module loaded, a service running). Override `check_preconditions` to express these checks:
41+
42+
```py
43+
from pyinfra.api import FactBase
44+
45+
class ZfsDatasets(FactBase):
46+
def requires_command(self) -> str:
47+
return "zfs"
48+
49+
def check_preconditions(self, state, host):
50+
from pyinfra.facts.server import KernelModules
51+
modules = host.get_fact(KernelModules) or {}
52+
if "zfs" not in modules:
53+
return "kernel module 'zfs' is not loaded"
54+
55+
def command(self) -> str:
56+
return "zfs get -H all"
57+
```
58+
59+
Return values:
60+
61+
| Return value | Meaning |
62+
|---|---|
63+
| `None` (or no return) | Prerequisites satisfied — proceed normally |
64+
| `"reason"` | Prerequisite not satisfied with a human-readable explanation |
65+
66+
The framework raises `FactPreconditionError` automatically and applies the same
67+
phase-aware behaviour as `requires_command`: silent during prepare, raised during execute.
68+
Fact authors never need to import any exception class.
69+
70+
## Exception hierarchy
71+
72+
All "fact skipped" situations use a common base class so callers can catch at any level:
73+
74+
```
75+
FactError
76+
└── FactNotCollected # base: fact could not be collected
77+
├── MissingCommandError # requires_command binary absent
78+
└── FactPreconditionError # check_preconditions() not satisfied
79+
```
80+
81+
All three are exported from `pyinfra.api`.
1182

1283
## Importing & Using Facts
1384

scripts/generate_facts_docs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ def build_facts_docs():
104104
)
105105

106106
lines.append(".. _facts:{0}.{1}:".format(module_name, name))
107+
# Modules that re-export classes under an alias (e.g. facts/zfs.py
108+
# exposes both ZfsDatasets and Datasets pointing at the same class)
109+
# end up keyed by whichever name getmembers sees first. Emit the
110+
# canonical class name as an extra label so cross-refs from the
111+
# operations docs resolve regardless of import style.
112+
if cls.__name__ != name:
113+
lines.append(".. _facts:{0}.{1}:".format(module_name, cls.__name__))
107114
lines.append("")
108115

109116
title = ":code:`{0}.{1}`".format(module_name, name)

src/pyinfra/api/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
from .deploy import deploy # noqa: F401 # pragma: no cover
1212
from .exceptions import ( # noqa: F401
1313
DeployError, # noqa: F401 # pragma: no cover
14+
FactPreconditionError,
1415
FactError,
16+
FactNotCollected,
1517
FactProcessError,
1618
FactTypeError,
1719
FactValueError,
1820
InventoryError,
21+
MissingCommandError,
1922
OperationError,
2023
OperationTypeError,
2124
OperationValueError,

src/pyinfra/api/exceptions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,38 @@ class NestedOperationError(OperationError):
6060
"""
6161

6262

63+
class FactNotCollected(FactError):
64+
"""
65+
Base exception raised when a fact could not be collected (e.g. binary absent
66+
on the remote host, or the fact was skipped by a condition).
67+
"""
68+
69+
70+
class MissingCommandError(FactNotCollected):
71+
"""
72+
Exception raised when ``requires_command`` specifies a binary that is
73+
not present on the remote host. The fact returns its ``default()`` value
74+
instead of raising, unless explicitly configured otherwise.
75+
"""
76+
77+
def __init__(self, command: str) -> None:
78+
self.command = command
79+
super().__init__(f"Command not found on remote host: {command}")
80+
81+
82+
class FactPreconditionError(FactNotCollected):
83+
"""
84+
Exception raised when a fact's ``check_preconditions()`` returns a reason string
85+
(e.g. a kernel module is not loaded). Like ``MissingCommandError``, this is
86+
silenced during the prepare phase and re-raised during execute.
87+
"""
88+
89+
def __init__(self, fact_cls: type, reason: str) -> None:
90+
self.fact_cls = fact_cls
91+
self.reason = reason
92+
super().__init__(f"Fact precondition not satisfied ({fact_cls.__name__}): {reason}")
93+
94+
6395
class DeployError(PyinfraError):
6496
"""
6597
User exception for raising in deploys or sub deploys.

0 commit comments

Comments
 (0)