Skip to content

Commit cfbd2a7

Browse files
authored
Merge branch '3.x' into feat/fact-preconditions-and-requires-command
2 parents a26c050 + 185f7db commit cfbd2a7

52 files changed

Lines changed: 1286 additions & 125 deletions

Some content is hidden

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

.github/workflows/test.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ jobs:
1919
- uses: actions/checkout@v5
2020
- uses: actions/setup-python@v5
2121
with:
22-
python-version: "3.13"
22+
python-version: "3.14"
2323
- name: Install uv
2424
uses: astral-sh/setup-uv@v6
2525
- uses: astral-sh/ruff-action@v3
26+
with:
27+
args: "check --diff"
2628
- uses: astral-sh/ruff-action@v3
2729
with:
28-
args: "format --check"
30+
args: "format --diff"
2931

3032
type-check:
3133
runs-on: ubuntu-24.04
3234
steps:
3335
- uses: actions/checkout@v5
3436
- uses: actions/setup-python@v5
3537
with:
36-
python-version: "3.13"
38+
python-version: "3.14"
3739
- name: Install uv
3840
uses: astral-sh/setup-uv@v6
3941
- run: uv sync --group test --no-default-groups
@@ -55,9 +57,11 @@ jobs:
5557
strategy:
5658
matrix:
5759
os: [macos-26, macos-15, macos-14, windows-2025, windows-2022, ubuntu-24.04, ubuntu-22.04]
58-
# Test every OS vs. Python 3.13, and only one for 3.1[012]
59-
python-version: ["3.13"]
60+
# Test every OS vs. Python 3.14, and only one for 3.1[012]
61+
python-version: ["3.14"]
6062
include:
63+
- os: ubuntu-24.04
64+
python-version: "3.13"
6165
- os: ubuntu-24.04
6266
python-version: "3.12"
6367
- os: ubuntu-24.04
@@ -75,7 +79,7 @@ jobs:
7579
- run: uv sync --group test --no-default-groups --python=${{ matrix.python-version }}
7680
- run: uv run pytest --cov --disable-warnings
7781
- name: Upload coverage report to codecov
78-
if: ${{ matrix.os == 'ubuntu-24.04' && matrix.python-version == '3.13' }}
82+
if: ${{ matrix.os == 'ubuntu-24.04' && matrix.python-version == '3.14' }}
7983
uses: codecov/codecov-action@v5
8084

8185
# End-to-end tests

docs/utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import re
2+
from inspect import getmembers, ismodule
3+
from pathlib import Path
4+
from types import ModuleType
5+
from typing import Generator, Any
26

37

48
def title_line(char, string):
@@ -15,3 +19,47 @@ def _bold_arg(m):
1519

1620
# Python's __doc__ attribute already dedents docstrings, so we don't need to strip anything
1721
return line
22+
23+
24+
def including_sub_modules(module: ModuleType) -> Generator[ModuleType]:
25+
"""Yield all modules to be examined, including the base modules."""
26+
yield module
27+
module_name = module.__name__
28+
for key, value in getmembers(module):
29+
if (
30+
ismodule(value)
31+
and value.__name__.startswith(module_name)
32+
and (not key.startswith("__"))
33+
):
34+
yield from including_sub_modules(value)
35+
36+
37+
def get_module_names(
38+
src_dir: Path,
39+
*,
40+
exclude_dir: str | list[str] | None = None,
41+
exclude_file: str | list[str] | None = None,
42+
) -> list[str]:
43+
"""Return file names of all modules found in src_dir."""
44+
exclude_path = set(exclude_dir or ["util", "__pycache__"])
45+
exclude_name = set(exclude_file or ["__init__.py"])
46+
47+
module_names = [
48+
path.stem
49+
for path in (src_dir.iterdir())
50+
if (
51+
(path.is_dir() and (path.name not in exclude_path))
52+
or ((path.suffix == ".py") and (path.name not in exclude_name))
53+
)
54+
]
55+
return module_names
56+
57+
58+
def remove_dups(all: list[tuple[str, Any]]) -> list[tuple[str, Any]]:
59+
"""Remove items with duplicate values, i.e. the same function or module found again."""
60+
unique, seen = [], set()
61+
for key, value in all:
62+
if value not in seen:
63+
seen.add(value)
64+
unique.append((key, value))
65+
return unique

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ classifiers = [
3636
"Programming Language :: Python :: 3.11",
3737
"Programming Language :: Python :: 3.12",
3838
"Programming Language :: Python :: 3.13",
39+
"Programming Language :: Python :: 3.14",
3940
"Topic :: System :: Systems Administration",
4041
"Topic :: System :: Installation/Setup",
4142
"Topic :: Utilities",
@@ -134,7 +135,6 @@ markers = [
134135
]
135136

136137
[tool.coverage.run]
137-
concurrency = ["gevent"]
138138

139139
[tool.coverage.report]
140140
show_missing = true

scripts/dev-lint.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
set -euo pipefail
44

55
echo "Execute ruff check..."
6-
uv run ruff check
6+
uv run ruff check --diff
7+
uv run ruff format --diff
78

89
echo "Execute mypy..."
910
uv run mypy

scripts/generate_facts_docs.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,32 @@
11
#!/usr/bin/env python
22

33
import sys
4-
from glob import glob
54
from importlib import import_module
65
from inspect import getfullargspec, getmembers, isclass
76
from os import makedirs, path
7+
from pathlib import Path
88
from types import FunctionType, MethodType
99

1010
from pyinfra.api.facts import FactBase, ShortFactBase
1111

1212
sys.path.append(".")
13-
from docs.utils import format_doc_line, title_line # noqa: E402
13+
from docs.utils import (
14+
format_doc_line,
15+
title_line,
16+
including_sub_modules,
17+
get_module_names,
18+
remove_dups,
19+
) # noqa: E402
1420

1521

1622
def build_facts_docs():
1723
this_dir = path.dirname(path.realpath(__file__))
1824
docs_dir = path.abspath(path.join(this_dir, "..", "docs"))
19-
facts_dir = path.join(this_dir, "..", "src", "pyinfra", "facts", "*.py")
25+
pyinfra_dir = Path(docs_dir).parent / "src" / "pyinfra"
2026

2127
makedirs(path.join(docs_dir, "facts"), exist_ok=True)
2228

23-
fact_module_names = [
24-
path.basename(name)[:-3] for name in glob(facts_dir) if not name.endswith("__init__.py")
25-
]
26-
27-
for module_name in sorted(fact_module_names):
29+
for module_name in sorted(get_module_names(pyinfra_dir / "facts")):
2830
lines = []
2931
print("--> Doing fact module: {0}".format(module_name))
3032
module = import_module("pyinfra.facts.{0}".format(module_name))
@@ -37,22 +39,27 @@ def build_facts_docs():
3739
if module.__doc__:
3840
lines.append(module.__doc__)
3941

40-
if path.exists(path.join(this_dir, "..", "pyinfra", "operations", f"{module_name}.py")):
42+
ops_paths = {
43+
pyinfra_dir / "operations" / name for name in [module_name, f"{module_name}.py"]
44+
}
45+
if any(p.exists() for p in ops_paths):
4146
lines.append(f"See also: :doc:`../operations/{module_name}`.")
4247
lines.append("")
4348

44-
fact_classes = [
49+
all_fact_classes = [
4550
(key, value)
46-
for key, value in getmembers(module)
51+
for m in including_sub_modules(module)
52+
for key, value in getmembers(m)
4753
if (
4854
isclass(value)
4955
and (issubclass(value, FactBase) or issubclass(value, ShortFactBase))
50-
and value.__module__ == module.__name__
56+
and value.__module__.startswith(m.__name__)
5157
and value is not FactBase
5258
and not value.__name__.endswith("Base") # hacky!
5359
)
5460
]
5561

62+
fact_classes = remove_dups(all_fact_classes)
5663
for fact, cls in fact_classes:
5764
# FactClass -> fact_accessor on host object
5865
name = fact

scripts/generate_operations_docs.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,34 @@
11
#!/usr/bin/env python
22

33
import sys
4-
from glob import glob
54
from importlib import import_module
65
from inspect import getmembers, isclass, signature
76
from os import makedirs, path
7+
from pathlib import Path
88
from types import FunctionType
99

1010
from pyinfra.api.facts import FactBase
1111

1212
sys.path.append(".")
13-
from docs.utils import format_doc_line, title_line # noqa: E402
13+
from docs.utils import (
14+
format_doc_line,
15+
get_module_names,
16+
including_sub_modules,
17+
remove_dups,
18+
title_line,
19+
) # noqa: E402
1420

1521
MODULE_DEF_LINE_MAX = 90
1622

1723

1824
def build_operations_docs():
1925
this_dir = path.dirname(path.realpath(__file__))
2026
docs_dir = path.abspath(path.join(this_dir, "..", "docs"))
21-
operations_dir = path.join(this_dir, "..", "src", "pyinfra", "operations", "*.py")
27+
pyinfra_dir = Path(docs_dir).parent / "src" / "pyinfra"
2228

2329
makedirs(path.join(docs_dir, "operations"), exist_ok=True)
2430

25-
op_module_names = [
26-
path.basename(name)[:-3]
27-
for name in glob(operations_dir)
28-
if not name.endswith("__init__.py")
29-
]
30-
31-
for module_name in op_module_names:
31+
for module_name in get_module_names(pyinfra_dir / "operations"):
3232
lines = []
3333

3434
print("--> Doing module: {0}".format(module_name))
@@ -44,7 +44,8 @@ def build_operations_docs():
4444

4545
operation_facts = [
4646
(key, value)
47-
for key, value in getmembers(module)
47+
for m in including_sub_modules(module)
48+
for key, value in getmembers(m)
4849
if (isclass(value) and issubclass(value, FactBase))
4950
]
5051

@@ -59,17 +60,20 @@ def build_operations_docs():
5960
lines.append("Facts used in these operations: {0}.".format(", ".join(items)))
6061
lines.append("")
6162

62-
operation_functions = [
63-
(key, value._inner)
64-
for key, value in getmembers(module)
63+
all_operation_functions = [
64+
(f"{m.__name__.split('.')[-1]}.{key}" if m != module else key, value._inner)
65+
for m in including_sub_modules(module)
66+
for key, value in getmembers(m)
6567
if (
6668
isinstance(value, FunctionType)
67-
and value.__module__ == module.__name__
69+
and value.__module__.startswith(m.__name__)
6870
and getattr(value, "_inner", False)
6971
and not value.__name__.startswith("_")
7072
and not key.startswith("_")
7173
)
7274
]
75+
# remove duplicates in case functions imported by __init__.py and then also seen where defined
76+
operation_functions = remove_dups(all_operation_functions)
7377

7478
for name, func in operation_functions:
7579
decorated_func = getattr(func, "_inner", None)

src/pyinfra/api/arguments.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ class ConnectorArguments(TypedDict, total=False):
6161
_su_password: str
6262
_doas: bool
6363
_doas_user: str
64+
_dzdo: bool
65+
_dzdo_user: str
6466

6567
# Shell arguments
6668
_shell_executable: str
@@ -139,6 +141,14 @@ def generate_env(config: "Config", value: dict) -> dict:
139141
"Execute/apply any changes with doas as a non-root user.",
140142
default=lambda config: config.DOAS_USER,
141143
),
144+
"_dzdo": ArgumentMeta(
145+
"Execute/apply any changes with dzdo.",
146+
default=lambda config: config.DZDO,
147+
),
148+
"_dzdo_user": ArgumentMeta(
149+
"Execute/apply any changes with dzdo as a non-root user.",
150+
default=lambda config: config.DZDO_USER,
151+
),
142152
}
143153

144154
shell_argument_meta: dict[str, ArgumentMeta] = {
@@ -282,7 +292,7 @@ def all_global_arguments() -> List[tuple[str, Type]]:
282292
"""
283293
.. caution::
284294
When combining privilege escalation arguments it is important to know the order they
285-
are applied: ``doas`` -> ``sudo`` -> ``su``. For example
295+
are applied: ``doas`` -> ``dzdo`` -> ``sudo`` -> ``su``. For example
286296
``_sudo=True,_su_user="pyinfra"`` yields a command like ``sudo su pyinfra..``.
287297
""",
288298
"""

src/pyinfra/api/arguments_typed.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ def __call__(
3737
_su_password: None | str = None,
3838
_doas: bool = False,
3939
_doas_user: None | str = None,
40+
_dzdo: bool = False,
41+
_dzdo_user: None | str = None,
4042
# Shell arguments
4143
_shell_executable: None | str = None,
4244
_chdir: None | str = None,

src/pyinfra/api/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ class ConfigDefaults:
5050
# Use doas and optional user
5151
DOAS: bool = False
5252
DOAS_USER: Optional[str] = None
53+
# Use dzdo and optional user
54+
DZDO: bool = False
55+
DZDO_USER: Optional[str] = None
5356
# Only show errors but don't count as failure
5457
IGNORE_ERRORS: bool = False
5558
# Shell to use to execute commands

src/pyinfra/connectors/chroot.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import shlex
23
from tempfile import mkstemp
34
from typing import TYPE_CHECKING, Optional
45

@@ -64,7 +65,7 @@ def connect(self) -> None:
6465
try:
6566
with progress_spinner({"chroot run"}):
6667
local.shell(
67-
"chroot {0} ls".format(chroot_directory),
68+
"chroot {0} ls".format(shlex.quote(chroot_directory)),
6869
splitlines=True,
6970
)
7071
except PyinfraError as e:
@@ -91,7 +92,7 @@ def run_shell_command(
9192

9293
chroot_command = StringCommand(
9394
"chroot",
94-
chroot_directory,
95+
QuoteString(chroot_directory),
9596
"sh",
9697
"-c",
9798
command,
@@ -130,8 +131,8 @@ def put_file(
130131
chroot_directory = self.host.connector_data["chroot_directory"]
131132
chroot_command = StringCommand(
132133
"cp",
133-
temp_filename,
134-
f"{chroot_directory}/{remote_filename}",
134+
QuoteString(temp_filename),
135+
QuoteString(f"{chroot_directory}/{remote_filename}"),
135136
)
136137

137138
status, output = self.local.run_shell_command(
@@ -172,8 +173,8 @@ def get_file(
172173
chroot_directory = self.host.connector_data["chroot_directory"]
173174
chroot_command = StringCommand(
174175
"cp",
175-
f"{chroot_directory}/{remote_filename}",
176-
temp_filename,
176+
QuoteString(f"{chroot_directory}/{remote_filename}"),
177+
QuoteString(temp_filename),
177178
)
178179

179180
status, output = self.local.run_shell_command(

0 commit comments

Comments
 (0)