Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9d38329
feat(brew): add support for brew (un)trust
morrison12 Jun 16, 2026
3295548
undo erroneous commit
morrison12 Jun 16, 2026
399436e
test: support packages of facts
morrison12 Jun 18, 2026
a6b061f
feat(facts.openwrt, operations.openwrt) - create openwrt package for …
morrison12 Jun 18, 2026
0dd972b
feat(facts.openwrt): add OpenWrtHasFeature fact
morrison12 Jun 18, 2026
ee07a8e
feat(operations.openwrt): add packages operation to hide opkg vs. apk…
morrison12 Jun 18, 2026
f82b5f3
feat(operations.opkg): replace now-moved to openwrt opkg with a depre…
morrison12 Jun 18, 2026
91cf18f
feature(api.facts): allow facts (incl. ShortFactBase) to be marked as…
morrison12 Jun 19, 2026
263072a
feature(facts.opkg): add deprecated opkg facts that forward to openwr…
morrison12 Jun 19, 2026
9d14e13
feature(facts.openwrt, operations.openwrt): add facts and operations …
morrison12 Jun 20, 2026
098d652
fix(facts, operations): generate docs for fact and implementations th…
morrison12 Jun 20, 2026
0475bb2
chore: clean up documentation for openwrt and opkg
morrison12 Jun 21, 2026
a01838a
fix: sort operations by import sorting order in doc pages
morrison12 Jun 21, 2026
a70815e
refactor: change ALL to __ALL__ in package facts and operations __ini…
morrison12 Jun 21, 2026
cc145a2
chore: correct a couple of formatting issues.
morrison12 Jun 21, 2026
082a2c8
chore: remove line in test fixture that causes a spelling error
morrison12 Jun 21, 2026
3ffebda
refactor: correct typing for default method of OpenWrtHasFeature
morrison12 Jun 21, 2026
686e6a0
fix: correct has_dsa_is_false_for_19_07 test
morrison12 Jul 14, 2026
c0f449b
refactor: restore freebsd operations __init__.py
morrison12 Jul 14, 2026
a353138
chore: add test fixtures for openwrt.update
morrison12 Jul 14, 2026
8c93dc2
fix: correct __getattr__ docstring for openwrt operations __init__.py
morrison12 Jul 14, 2026
a0fffda
fix: correct packages param to latest idiom
morrison12 Jul 14, 2026
a5207ec
chore: improve typing
morrison12 Jul 14, 2026
b1433ac
chore: convert tests from json to yaml
morrison12 Jul 14, 2026
d2e5087
chore: fix some formatting
morrison12 Jul 14, 2026
53513cf
chore: rework typing
morrison12 Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions pyinfra-metadata.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ path = "src/pyinfra/facts/freebsd.py"
type = "fact"
tags = ["package-manager"]

[pyinfra.plugins."freebsd-ops"]
name = "freebsd"
path = "src/pyinfra/operations/freebsd"
type = "operation"
tags = ["system"]

[pyinfra.plugins."openwrt-ops"]
name = "openwrt"
path = "src/pyinfra/operations/openwrt"
type = "operation"
tags = ["system"]

[pyinfra.plugins."openwrt-facts"]
name = "openwrt"
path = "src/pyinfra/facts/openwrt"
type = "fact"
tags = ["system"]

[pyinfra.plugins."opkg-ops"]
name = "opkg"
path = "src/pyinfra/operations/opkg.py"
Expand Down
5 changes: 5 additions & 0 deletions repros/brew-fixes-2026-02-repro.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#! /bin/bash
pyinfra --version
brew list --versions | grep openssl
pyinfra @local fact brew.BrewPackages |& grep openssl -A 3
pyinfra --dry @local brew.packages openssl@3.6.1
78 changes: 59 additions & 19 deletions scripts/docs_utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import re
from inspect import cleandoc, getmembers, ismodule
from collections.abc import Callable
from inspect import cleandoc, getmembers, isfunction, ismodule
from pathlib import Path
from types import ModuleType
from typing import Any
from collections.abc import Generator

from pyinfra.api import FactBase, ShortFactBase


def format_doc_line(line: str) -> str:
Expand Down Expand Up @@ -114,19 +115,6 @@ def prepare_docstring(doc: str | None) -> str:
return rst_to_md_docstring(cleandoc(doc))


def including_sub_modules(module: ModuleType) -> Generator[ModuleType, None, None]:
"""Yield all modules to be examined, including the base modules."""
yield module
module_name = module.__name__
for key, value in getmembers(module):
if (
ismodule(value)
and value.__name__.startswith(module_name)
and (not key.startswith("__"))
):
yield from including_sub_modules(value)


def get_module_names(
src_dir: Path,
*,
Expand All @@ -148,11 +136,63 @@ def get_module_names(
return module_names


def remove_dups(all: list[tuple[str, Any]]) -> list[tuple[str, Any]]:
"""Remove items with duplicate values, i.e. the same function or module found again."""
def is_fact_class(m: ModuleType, _key: str, value: object) -> bool:
return (
isinstance(value, type)
and (issubclass(value, FactBase) or issubclass(value, ShortFactBase))
and value.__module__.startswith(m.__name__)
and value is not FactBase
and not value.__name__.endswith("Base") # hacky!
)


def is_fact_class_in_op(_m: ModuleType, _key: str, value: object) -> bool:
return isinstance(value, type) and (
issubclass(value, FactBase) or issubclass(value, ShortFactBase)
)


def function_of_interest(module: ModuleType, key: str, value: object) -> bool:
return (
isfunction(value)
and value.__module__.startswith(module.__name__)
and getattr(value, "_inner", False)
and not value.__name__.startswith("_")
and not key.startswith("_")
)


def get_objects_from_module(
module: ModuleType,
predicate: Callable[[object], bool],
filt: Callable[[ModuleType, str, object], bool],
) -> list[tuple[str, type]]:
if not hasattr(module, "__path__"): # not a package thus a single file
found = [
(key, value) for key, value in getmembers(module, predicate) if filt(module, key, value)
]
else:
# for packages, deferred import mechanism gives zero members so need to use __all__
found = [
(name, item)
for sym in module.__all__
for name, item in (
(
(f"{sym}.{key}", value)
for key, value in getmembers(getattr(module, sym), predicate)
if filt(module, key, value)
)
if ismodule(getattr(module, sym))
else ([(sym, getattr(module, sym))] if predicate(getattr(module, sym)) else [])
)
if filt(module, name, item)
]

# Remove items with duplicate values, i.e. the same function or module found again
unique, seen = [], set()
for key, value in all:
for key, value in found:
if value not in seen:
seen.add(value)
unique.append((key, value))

return unique
21 changes: 4 additions & 17 deletions scripts/generate_facts_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@

import sys
from importlib import import_module
from inspect import getfullargspec, getmembers, isclass
from inspect import getfullargspec, isclass
from os import makedirs, path
from pathlib import Path
from types import FunctionType, MethodType

from pyinfra.api.facts import FactBase, ShortFactBase
from pyinfra.api.metadata import ALLOWED_TAGS, parse_plugins

sys.path.append(path.dirname(path.realpath(__file__)))
from docs_utils import (
format_doc_line,
get_module_names,
including_sub_modules,
get_objects_from_module,
is_fact_class,
prepare_docstring,
remove_dups,
) # noqa: E402

CARD_SCRIPT = """\
Expand Down Expand Up @@ -119,20 +118,8 @@ def build_facts_docs():
lines.append(f"See also: [operations/{module_name}](../operations/{module_name}.md).")
lines.append("")

all_fact_classes = [
(key, value)
for m in including_sub_modules(module)
for key, value in getmembers(m)
if (
isclass(value)
and (issubclass(value, FactBase) or issubclass(value, ShortFactBase))
and value.__module__.startswith(m.__name__)
and value is not FactBase
and not value.__name__.endswith("Base") # hacky!
)
]
fact_classes = get_objects_from_module(module, isclass, is_fact_class)

fact_classes = remove_dups(all_fact_classes)
for fact, cls in fact_classes:
name = fact
args_string_and_brackets = ""
Expand Down
45 changes: 10 additions & 35 deletions scripts/generate_llms_txt.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@
from __future__ import annotations

import sys
from inspect import getdoc, getfullargspec, getmembers, isclass, signature
from importlib import import_module
from inspect import getdoc, getfullargspec, isclass, isfunction, signature
from os import environ, makedirs, path
from pathlib import Path
from types import FunctionType, MethodType, ModuleType
from importlib import import_module

from pyinfra.api import metadata
from pyinfra.api.connectors import get_all_connectors
from pyinfra.api.facts import FactBase, ShortFactBase

sys.path.append(path.dirname(path.realpath(__file__)))
from docs_utils import including_sub_modules, prepare_docstring, remove_dups # noqa: E402

from docs_utils import (
function_of_interest,
get_objects_from_module,
is_fact_class,
prepare_docstring,
) # noqa: E402

BASE_URL = "https://docs.pyinfra.com/en"
VERSION = environ.get("DOCS_VERSION", "latest")
Expand Down Expand Up @@ -199,39 +202,11 @@ def section(title: str, pages: list[tuple[str, str, str]]) -> None:


def _extract_operation_funcs(module: ModuleType) -> list[tuple[str, FunctionType]]:
funcs = [
(
f"{m.__name__.split('.')[-1]}.{key}" if m != module else key,
getattr(value, "_inner"),
)
for m in including_sub_modules(module)
for key, value in getmembers(m)
if (
isinstance(value, FunctionType)
and value.__module__.startswith(m.__name__)
and getattr(value, "_inner", False)
and not value.__name__.startswith("_")
and not key.startswith("_")
)
]
return remove_dups(funcs)
return get_objects_from_module(module, isfunction, function_of_interest)


def _extract_fact_classes(module: ModuleType) -> list[tuple[str, type]]:
classes = [
(key, value)
for m in including_sub_modules(module)
for key, value in getmembers(m)
if (
isclass(value)
and (issubclass(value, FactBase) or issubclass(value, ShortFactBase))
and value.__module__.startswith(m.__name__)
and value is not FactBase
and value is not ShortFactBase
and not value.__name__.endswith("Base")
)
]
return remove_dups(classes)
return get_objects_from_module(module, isclass, is_fact_class)


def render_operation_section(module_name: str) -> list[str]:
Expand Down
35 changes: 8 additions & 27 deletions scripts/generate_operations_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@

import sys
from importlib import import_module
from inspect import getmembers, isclass, signature
from inspect import isclass, isfunction, signature
from os import makedirs, path
from pathlib import Path
from types import FunctionType

from pyinfra.api.facts import FactBase
from pyinfra.api.metadata import ALLOWED_TAGS, parse_plugins

sys.path.append(path.dirname(path.realpath(__file__)))
from docs_utils import (
format_doc_line,
function_of_interest,
get_module_names,
including_sub_modules,
get_objects_from_module,
is_fact_class_in_op,
prepare_docstring,
remove_dups,
) # noqa: E402

MODULE_DEF_LINE_MAX = 90
Expand Down Expand Up @@ -118,39 +117,21 @@ def build_operations_docs():
lines.append(module_doc)
lines.append("")

operation_facts = [
(key, value)
for m in including_sub_modules(module)
for key, value in getmembers(m)
if (isclass(value) and issubclass(value, FactBase))
]
unique_facts = get_objects_from_module(module, isclass, is_fact_class_in_op)

unique_facts = remove_dups(operation_facts)
if unique_facts:
items = []
for key, value in unique_facts:
fact_module = value.__module__.replace("pyinfra.facts.", "")
fact_module = value.__module__.replace("pyinfra.facts.", "").split(".")[0]
items.append(
f"[`{fact_module}.{key}`](../facts/{fact_module}.md#{fact_module}-{key})"
)
lines.append("Facts used in these operations: {}.".format(", ".join(items)))
lines.append("")

all_operation_functions = [
(f"{m.__name__.split('.')[-1]}.{key}" if m != module else key, value._inner)
for m in including_sub_modules(module)
for key, value in getmembers(m)
if (
isinstance(value, FunctionType)
and value.__module__.startswith(m.__name__)
and getattr(value, "_inner", False)
and not value.__name__.startswith("_")
and not key.startswith("_")
)
]
operation_functions = remove_dups(all_operation_functions)
operation_functions = get_objects_from_module(module, isfunction, function_of_interest)

for name, func in operation_functions:
for name, func in sorted(operation_functions, key=lambda x: (x[0].count("."), x[0])):
decorated_func = getattr(func, "_inner", None)
while decorated_func:
func = decorated_func
Expand Down
17 changes: 17 additions & 0 deletions src/pyinfra/api/facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@

T = TypeVar("T")

already_logged_as_deprecated = set() # used to ensure only one warning per deprecated fact


class FactBase(Generic[T]):
name: str
Expand All @@ -63,6 +65,10 @@ class FactBase(Generic[T]):

command: Callable[..., str | StringCommand]

is_deprecated = False

deprecated_for: str | None = None

def requires_command(self, *args, **kwargs) -> str | None:
"""Return the binary name that must exist on the remote host for this fact to run.
If the binary is absent the fact returns its ``default()`` value silently.
Expand Down Expand Up @@ -118,6 +124,8 @@ def process_pipeline(self, args, output):
class ShortFactBase(Generic[T]):
name: str
fact: type[FactBase]
is_deprecated = False
deprecated_for: str | None = None

@override
def __init_subclass__(cls) -> None:
Expand Down Expand Up @@ -195,6 +203,15 @@ def get_fact(
ensure_hosts: Any | None = None,
apply_failed_hosts: bool = True,
) -> Any:
global already_logged_as_deprecated

if cls.is_deprecated and (cls not in already_logged_as_deprecated):
already_logged_as_deprecated.add(cls)
msg = f"The {cls.__name__} fact is deprecated"
if cls.deprecated_for is not None:
msg = f"{msg}, please use: {cls.deprecated_for}"
logger.warning(msg)

if issubclass(cls, ShortFactBase):
return get_short_facts(
state,
Expand Down
31 changes: 31 additions & 0 deletions src/pyinfra/facts/openwrt/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
Facts specific to the [OpenWrt](https://www.openwrt.org) distribution.
"""

import importlib
import sys
from typing import Any

__ALL__ = {
"OpenWrtFeature": "features.OpenWrtFeature",
"OpenWrtHasFeature": "features.OpenWrtHasFeature",
"opkg": "opkg",
}

__all__ = list(__ALL__.keys())


def __getattr__(name: str) -> Any:
# On-demand import of OpenWrt facts, so we don't have to import them all at once
# this forces py3.7>=, but that's fine as py2 is EOL and py3.6 is also EOL
# Also, pyinfra is py3.11>=, so this is not a breaking change.
if name in __all__:
pieces = __ALL__[name].split(".")
module = importlib.import_module(f".{pieces[0]}", package=__name__)
if len(pieces) < 2:
return module
del sys.modules[module.__name__]
del sys.modules[__name__].__dict__[pieces[0]]
return getattr(module, pieces[1])

raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Loading