2424from pyinfra .api .output import format_text
2525from pyinfra .api import StringCommand
2626from pyinfra .api .arguments import all_global_arguments , pop_global_arguments
27- from pyinfra .api .exceptions import FactProcessError
27+ from pyinfra .api .exceptions import FactPreconditionError , FactProcessError , MissingCommandError
2828from pyinfra .api .util import (
2929 get_kwargs_str ,
3030 log_error_or_warning ,
3636from pyinfra .progress import progress_spinner
3737
3838from .arguments import CONNECTOR_ARGUMENT_KEYS
39+ from .state import StateStage
3940
4041if TYPE_CHECKING :
4142 from pyinfra .api import Host , State
4243
44+ # Sentinel output line emitted when skip_unless_command binary is absent on the remote host.
45+ _MISSING_COMMAND_MARKER = "##PYINFRA_NOCMD##"
46+
4347SUDO_REGEX = r"^sudo: unknown user"
4448SU_REGEXES = (
4549 r"^su: user .+ does not exist" ,
@@ -60,6 +64,22 @@ class FactBase(Generic[T]):
6064 command : Callable [..., str | StringCommand ]
6165
6266 def requires_command (self , * args , ** kwargs ) -> str | None :
67+ """Return the binary name that must exist on the remote host for this fact to run.
68+ If the binary is absent the fact returns its ``default()`` value silently.
69+ """
70+ return None
71+
72+ def check_preconditions (self , state : "State" , host : "Host" ) -> str | None :
73+ """Check that this fact's prerequisites are satisfied before running.
74+
75+ Override this method to call ``host.get_fact(...)`` and return:
76+
77+ - ``None`` (or no return) — all prerequisites satisfied, proceed normally
78+ - ``"reason message"`` — prerequisite not satisfied with explanation
79+
80+ The framework handles raising ``FactPreconditionError`` and phase-awareness
81+ automatically; fact authors never need to import exception classes.
82+ """
6383 return None
6484
6585 @override
@@ -186,15 +206,51 @@ def get_fact(
186206 apply_failed_hosts = apply_failed_hosts ,
187207 )
188208
189- return _get_fact (
190- state ,
191- host ,
192- cls ,
193- args ,
194- kwargs ,
195- ensure_hosts ,
196- apply_failed_hosts ,
197- )
209+ try :
210+ return _get_fact (
211+ state ,
212+ host ,
213+ cls ,
214+ args ,
215+ kwargs ,
216+ ensure_hosts ,
217+ apply_failed_hosts ,
218+ )
219+ except MissingCommandError as e :
220+ # During the prepare phase the binary might not yet be installed (a prior
221+ # operation will install it). Silently return the default so change
222+ # detection can proceed normally.
223+ if state .current_stage != StateStage .Execute :
224+ logger .debug (
225+ "Fact %s skipped on %s during prepare: %s" ,
226+ cls .__name__ ,
227+ host .print_prefix ,
228+ e ,
229+ )
230+ return cls ().default ()
231+ # During the execute phase the binary should already be present. If it
232+ # isn't, the deploy is incorrectly ordered (missing an install step?).
233+ # TODO(v4): remove this compat shim and let the exception propagate.
234+ logger .warning (
235+ "Fact %s skipped on %s: command not found: %s (this will raise an exception in v4)" ,
236+ cls .__name__ ,
237+ host .print_prefix ,
238+ e ,
239+ )
240+ return cls ().default ()
241+ except FactPreconditionError as e :
242+ # Same phase-aware logic: a precondition not satisfied during prepare
243+ # is normal (e.g. kernel module not yet loaded); during execute it is an
244+ # ordering error in the deploy.
245+ if state .current_stage != StateStage .Execute :
246+ logger .debug (
247+ "Fact %s skipped on %s during prepare: %s" ,
248+ cls .__name__ ,
249+ host .print_prefix ,
250+ e ,
251+ )
252+ return cls ().default ()
253+ raise
198254
199255
200256def _get_fact (
@@ -229,20 +285,30 @@ def _get_fact(
229285 if fact .shell_executable :
230286 global_kwargs ["_shell_executable" ] = fact .shell_executable
231287
288+ # Check preconditions before running this fact's command.
289+ if reason := fact .check_preconditions (state , host ):
290+ raise FactPreconditionError (cls , reason )
291+
232292 command = _make_command (fact .command , fact_kwargs )
233293 requires_command = _make_command (fact .requires_command , fact_kwargs )
234294 if requires_command :
235295 command = StringCommand (
236- # Command doesn't exist, return 0 *or* run & return fact command
237- "!" ,
296+ # If binary exists → run the fact command; otherwise emit the sentinel so
297+ # pyinfra can distinguish "binary absent" from "no output".
298+ "if" ,
238299 "command" ,
239300 "-v" ,
240301 requires_command ,
241302 ">/dev/null" ,
242- "||" ,
303+ "2>&1;" ,
304+ "then" ,
243305 "(" ,
244306 command ,
245- ")" ,
307+ ");" ,
308+ "else" ,
309+ "echo" ,
310+ f"'{ _MISSING_COMMAND_MARKER } ';" ,
311+ "fi" ,
246312 )
247313
248314 status = False
@@ -268,6 +334,17 @@ def _get_fact(
268334
269335 stdout_lines , stderr_lines = output .stdout_lines , output .stderr_lines
270336
337+ # Detect the "binary absent" sentinel from the if/then/else shell guard.
338+ if status and stdout_lines == [_MISSING_COMMAND_MARKER ]:
339+ cmd_str = str (requires_command ) if requires_command else ""
340+ logger .debug (
341+ "Skipping fact %s on %s: command not found: %s" ,
342+ name ,
343+ host .print_prefix ,
344+ cmd_str ,
345+ )
346+ raise MissingCommandError (cmd_str )
347+
271348 data = fact .default ()
272349
273350 if status :
0 commit comments