Skip to content

feat: add requires_command guard sentinel and check_preconditions() hook#1647

Merged
Fizzadar merged 2 commits intopyinfra-dev:3.xfrom
maisim:feat/fact-preconditions-and-requires-command
Apr 25, 2026
Merged

feat: add requires_command guard sentinel and check_preconditions() hook#1647
Fizzadar merged 2 commits intopyinfra-dev:3.xfrom
maisim:feat/fact-preconditions-and-requires-command

Conversation

@maisim
Copy link
Copy Markdown
Contributor

@maisim maisim commented Mar 31, 2026

Background

Two related reliability gaps existed in the fact-collection layer:

  1. The old shell guard for requires_command used:

    ! command -v <bin> >/dev/null || (<fact-command>)
    

    When the binary is absent this produces no stdout, making it impossible to distinguish "binary not installed" from "binary installed but returned no data". Both cases silently return default(), masking real errors.

  2. There was no way for a fact to express runtime prerequisites beyond "this binary must exist" — e.g. ZFS datasets cannot be listed when the zfs kernel module is not loaded, even if the zfs binary is present.

Changes

Replace the old ! … || guard with an explicit if/then/else that emits a unique marker when the binary is absent:

if command -v <bin> >/dev/null 2>&1; then
    (<fact-command>);
else
    echo '##PYINFRA_NOCMD##';
fi

The marker _MISSING_COMMAND_MARKER = "##PYINFRA_NOCMD##" is detected in _get_fact and converted into a MissingCommandError exception so callers can tell the two cases apart.

FactError
└── FactNotCollected          # base: fact skipped, not a data error
    ├── MissingCommandError   # requires_command binary absent
    └── FactPreconditionError # check_preconditions() returned a reason

All three are exported from pyinfra.api.

Both exceptions obey the deploy phase in get_fact():

  • Prepare phase: silently return default(). The binary / module may simply not be installed yet; a later operation will install it.
  • Execute phase: re-raise. The binary / module should already be present; its absence indicates incorrect ordering in the deploy.

New optional override on FactBase:

def check_preconditions(self, state, host) -> str | None:

Return None (or no explicit return) to proceed, or a reason string to skip with context. The framework raises FactPreconditionError automatically — fact authors never need to import exception classes.

As an example:

  • ZfsPools added with requires_command = "zpool".
  • ZfsDatasets.check_preconditions() uses the existing server.KernelModules fact to verify the zfs kernel module is loaded before attempting zfs get -H all — a real-world scenario where requires_command alone is not sufficient.

Tests

  • tests/test_api/test_api_facts_missing_command.py — phase-aware behaviour for MissingCommandError and the ##PYINFRA_NOCMD## sentinel.
  • tests/test_api/test_api_facts_preconditions.py — phase-aware behaviour for FactPreconditionError via check_preconditions().
  • Pull request is based on the default branch (3.x at this time)
  • Pull request includes tests for any new/updated operations/facts
  • Pull request includes documentation for any new/updated operations/facts
  • Tests pass (see scripts/dev-test.sh)
  • Type checking & code style passes (see scripts/dev-lint.sh)

@maisim maisim force-pushed the feat/fact-preconditions-and-requires-command branch from b47a385 to 8aadf42 Compare March 31, 2026 13:21
Copy link
Copy Markdown
Collaborator

@DonDebonair DonDebonair left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this PR! Left a few small suggestions.

Comment thread src/pyinfra/api/facts.py Outdated
"""
return None

def preconditions(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the return value, I would make the function name a bit more descriptive. Something preconditions_met?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems relevant to me.

Comment thread src/pyinfra/api/facts.py Outdated

def preconditions(
self, state: "State", host: "Host"
) -> bool | tuple[Literal[False], str] | None:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why allow None as a return value? I think the signature is cleaner if it's just bool | tuple[Literal[False], str]

Copy link
Copy Markdown
Contributor Author

@maisim maisim Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal if to not have to return True "manually" if all is ok :

    @override
    def preconditions_met(self, state, host):
        from pyinfra.facts.server import KernelModules

        modules = host.get_fact(KernelModules) or {}
        if "zfs" not in modules:
            return False, "kernel module 'zfs' is not loaded"
        return True # <-- implies to always add return True at the end of the method

Explicit is better than impliciit, maybe we can do like that

Another way if to just return the reason, a string, and not False or a tulple `False, "resaon", because if we return anithing, it's because there is a problem

    @override
    def preconditions(self, state, host):
        from pyinfra.facts.server import KernelModules

        modules = host.get_fact(KernelModules) or {}
        if "zfs" not in modules:
            return  "kernel module 'zfs' is not loaded"

It might be less intuitive
What do you think ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is actually a good point. Suggestion: rename the method to check_preconditions(...) and make it return str | None. If it returns a string, some preconditions were not met and the return value gives more details. If nothing (None) is returned, all is well.

Comment thread src/pyinfra/facts/zfs.py
@maisim maisim force-pushed the feat/fact-preconditions-and-requires-command branch 3 times, most recently from 1d294fc to a26c050 Compare April 3, 2026 20:24
@DonDebonair
Copy link
Copy Markdown
Collaborator

Looks good to me. @Fizzadar ready to merge. Recommend not squashing the commits because they represent 2 different (but related) features.

@maisim maisim changed the title [RFC] feat(facts): add requires_command guard sentinel and preconditions() hook feat(facts): add requires_command guard sentinel and preconditions() hook Apr 6, 2026
@maisim maisim changed the title feat(facts): add requires_command guard sentinel and preconditions() hook feat: add requires_command guard sentinel and preconditions() hook Apr 6, 2026
@maisim maisim force-pushed the feat/fact-preconditions-and-requires-command branch from cfbd2a7 to f3cc916 Compare April 20, 2026 06:30
@maisim maisim changed the title feat: add requires_command guard sentinel and preconditions() hook feat: add requires_command guard sentinel and check_preconditions() hook Apr 20, 2026
Copy link
Copy Markdown
Member

@Fizzadar Fizzadar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this PR, this is vastly improved vs. the current behavior. My concern is with the breaking change to raise during execution. I think this is quite likely to break existing working deploys that rely on the defaulting behaviour, something like:

# Executed on both webserver.net + dbserver.net, default (due to missing command) on webserver is silently skipped
if host.get_fact(postgres.Databases):
   ...

At the same time, I don't want this stuck waiting for v4. What about logging, rather than raising, during execution phase (perhaps with config to enable raising?).

@maisim @DonDebonair keen for your thoughts!

Comment thread src/pyinfra/api/facts.py Outdated
@maisim maisim force-pushed the feat/fact-preconditions-and-requires-command branch from f3cc916 to 6b6172b Compare April 21, 2026 08:04
Comment thread src/pyinfra/api/facts.py
Comment on lines +219 to +238
except MissingCommandError as e:
# During the prepare phase the binary might not yet be installed (a prior
# operation will install it). Silently return the default so change
# detection can proceed normally.
if state.current_stage != StateStage.Execute:
logger.debug(
"Fact %s skipped on %s during prepare: %s",
cls.__name__,
host.print_prefix,
e,
)
return cls().default()
# TODO(v4): remove this compat shim and let the exception propagate.
logger.warning(
"Fact %s skipped on %s: command not found: %s (this will raise an exception in v4)",
cls.__name__,
host.print_prefix,
e,
)
return cls().default()
Copy link
Copy Markdown
Contributor Author

@maisim maisim Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the change here, It does not raise anymore, just log a warning.

@maisim maisim force-pushed the feat/fact-preconditions-and-requires-command branch from 6b6172b to 5b8df1f Compare April 22, 2026 12:00
Copy link
Copy Markdown
Member

@Fizzadar Fizzadar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome PR, huge QOL improvement! Thank you @maisim 🙏

@Fizzadar Fizzadar merged commit 5698bf1 into pyinfra-dev:3.x Apr 25, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants