Discovery
Command discovery: turn a class, module, import path, directory, or installed distribution's entry points into runnable commands.
duho.discovery
Command discovery: turn a class, module, import path, or directory into Cmds.
This module answers "give me the runnable commands living over there" for four shapes of there:
- a :class:
~duho.Cmdsubclass -- already a command, used as-is; - a command module -- a
.pyfile whose top-levelmain/run/callis the entrypoint, adapted to the command contract by :class:ModuleCommand(a plain wrapper -- it does NOT subclasstypes.ModuleType); - an import path (dotted qualname) or a filesystem path -- resolved to a
module by :class:
CmdBuilder; - a package or directory -- walked by :func:
discover_commands, which collects both class commands and module commands from every submodule/file.
Two design points worth calling out:
- Resilience. :func:
discover_commandstreats a single unimportable or unsupported command as skippable, not fatal:ImportErrorandNotImplementedError(and subclasses) on one command are logged and skipped so the rest still load. A genuinely broken command file (e.g. aSyntaxError) is NOT swallowed -- it is a real bug the author wants surfaced. See :func:discover_commandsfor the exact caught set and rationale. - Injection hook. :func:
register_command_providerlets an external package teach :class:CmdBuilderhow to build a command from a directory shape core duho does not itself understand (e.g. a directory of numbered step files), WITHOUT core duho importing that package. If no provider matches, a directory or module is imported normally.
All union annotations are quoted so the module imports cleanly on Python 3.9.
__all__ = ['Command', 'ModuleCommand', 'CmdBuilder', 'register_command_provider', 'discover_commands', 'discover_entry_points', 'is_class_command', 'is_module_command']
module-attribute
CmdBuilder(qualname, source=None)
Build a :class:Command from an import path or a filesystem path.
CmdBuilder(qualname, source=None) resolves source to a command:
sourceaPath(or path-like) -- a filesystem source. If a registered provider (:func:register_command_provider) matches it, that provider builds the command; otherwise it is imported. A.pyfile is imported viaspec_from_file_locationunder a synthesized uniquesys.moduleskey (so a loose file never clobbers a real installed module of the same dotted name). A directory with__init__.pyis a package and imported by qualname; a directory without one is offered to providers first (the seam where a run-path-style runtime plugs in) and, if unclaimed, raisesImportError(core duho has no meaning for a bare dir of files -- that meaning is exactly what a provider supplies).sourceomitted/None --qualnameis treated as a dotted import path and imported viaimportlib.import_module(after checking providers for a namespace-package directory, mirroring the path branch).sourcealready a module orCommand-- used directly.
The resolved command is exposed as :attr:command. For a module source it
is a :class:ModuleCommand; for a provider it is whatever the provider
returns; for an already-Command source it is that object.
Source code in src/duho/discovery.py
429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 | |
command = self._from_path(_Path(source))
instance-attribute
qualname = str(qualname)
instance-attribute
Command
Bases: Protocol
The shape discover_commands/dispatch needs from a command.
A command is anything that can name itself as a subcommand and be run. Two concrete kinds fulfil it:
- a :class:
~duho.Cmdsubclass -- a class command; its_parsername_/class name names the subcommand,_parser_builds its parser, and an instance is run via__call__; - a :class:
ModuleCommand-- a module command wrapping a.pymodule, exposing the same surface.
This is a structural Protocol (not an ABC): the predicates
:func:is_class_command / :func:is_module_command classify concrete
objects, and callers branch on those rather than instantiating an ABC.
__call__()
Source code in src/duho/discovery.py
104 105 | |
ModuleCommand(module, *, name=None, entrypoint=None)
Adapt a command module to the :class:Command contract.
Wraps a module (a types.ModuleType, or anything exposing the same
attributes) whose top-level function is the command body. It is a plain
wrapper -- it does NOT subclass ModuleType -- so it stays a normal
object with no import-system entanglement.
It carries:
module-- the wrapped module;_parsername_-- the resolved subcommand name (module_parsername_/_cli_nameoverride, else the file stem with_->-);description/help-- frommodule.__doc__;- the entrypoint --
module.main(primary), falling back tomodule.run/module.call; - optional lifecycle hooks --
register(default no-op),init(default returnsNone-- no context),success/finally_(default no-ops); args_cls-- an optional module-levelArgsdeclaring this module's own CLI fields DECLARATIVELY (added to the subparser beforeregisterruns -- seeruntime._register_module_command), as an alternative to adding everything imperatively inregister. Accepts either an already-Args-subclassingArgs(used directly) or a plain class with annotated fields (mixed in withArgson the fly so its annotations still work as CLI fields, with no explicit import/subclass ofduho.Argsrequired).Noneif the module declares no usableArgs.
A ModuleCommand with no entrypoint raises NotImplementedError at
construction (a module offering no main/run/call is not a
command) -- discover_commands treats that as a skippable "not a command"
signal, so a helpers-only module simply contributes nothing.
Hook signatures / logger source. The lifecycle hooks (init/success/
finally_) and the entrypoint receive the parsed args instance and take
their logger from that instance's _logger_ (present on
LoggingArgs-based commands) rather than a separately threaded logger
argument. The resolved logger is available to hook authors as args._logger_
where the args class provides it, else this module's "duho" logger. The
concrete hook calls made by the driver are: ctx = init(args),
main(args) / entrypoint(args), success(ctx, args),
finally_(ctx, args); the defaults installed here accept *args, **kwargs
so a module may omit any hook (or define a narrower signature and simply not
receive extras it does not declare).
The register hook is the one exception, and is arity-tolerant: it may
be written either register(parser, args) (2-arg) or
register(parser, args, logger) (3-arg). The driver
(runtime._register_module_command) inspects the hook's signature and, for
a 3-arg hook, passes logger = getattr(args, "_logger_",
logging.getLogger("duho")); a 2-arg hook is called unchanged. A *args
hook is treated as 3-arg-capable, and a non-introspectable hook falls back to
the 2-arg call. Either way the hook adds its own arguments directly on the
subcommand's argparse parser.
Source code in src/duho/discovery.py
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | |
args_cls = args_cls if _inspect.isclass(args_cls) and args_cls not in (_Args, _Cmd) else None
instance-attribute
description
property
Full command help -- the wrapped module's docstring, stripped.
finally_ = getattr(module, 'finally_', None) or _noop
instance-attribute
help
property
One-line help -- the first line of :attr:description.
init = getattr(module, 'init', None) or _init_noop
instance-attribute
module = module
instance-attribute
register = getattr(module, 'register', None) or _noop
instance-attribute
success = getattr(module, 'success', None) or _noop
instance-attribute
__call__(args=None)
A ModuleCommand is directly callable; delegates to :meth:main.
Source code in src/duho/discovery.py
289 290 291 | |
__repr__()
Source code in src/duho/discovery.py
293 294 295 296 297 | |
main(args=None)
Run the command by invoking the wrapped module's entrypoint.
Called with the parsed args instance during dispatch. Kept
arg-optional so a ModuleCommand is trivially callable in tests /
direct use; the driver always passes the parsed args.
Source code in src/duho/discovery.py
278 279 280 281 282 283 284 285 286 287 | |
discover_commands(source)
Discover commands from a package name or a directory, resiliently.
source is dispatched by shape:
- a
Path/os.PathLike, or astrcontaining/or\or naming an existing directory -> filesystem: iteratesorted(dir.glob("*.py")), skip_-prefixed files, import each under a synthesized uniquesys.modulesname, and collect its commands; - any other
str-> dotted package:import_moduleit, require a__path__(it must be a package, not a plain module), walk its submodules withpkgutil.iter_modules, import each, and collect.
From each module it collects BOTH class commands (Cmd subclasses defined
in that module) and, if the module has a top-level main/run/call,
one :class:ModuleCommand. A module with neither contributes nothing.
Resilience. Per-command import/build is wrapped to catch only
ImportError and NotImplementedError (and their subclasses): these
mean "unsupported/optional-dep-missing" or "not actually a command", which
are skippable -- logged and skipped so the other commands still load. Any
other exception (notably SyntaxError -- a typo in a command file -- and
unexpected runtime errors) propagates: a genuinely broken command file is a
real bug the author wants surfaced, not silently swallowed.
The result is sorted by resolved subcommand name for deterministic
--help output (filesystem iteration order is OS-dependent).
Source code in src/duho/discovery.py
567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 | |
discover_entry_points(group)
Discover commands from installed-distribution entry points in group.
This is the plugin-discovery source behind duho.app(root,
entry_points="myapp.commands"): every entry point advertised in group
by an installed distribution is loaded (EntryPoint.load()) and coerced to
a :class:Command via :func:_coerce_entry_point_command -- a Cmd
subclass becomes a class command, a module becomes a module command.
Resilience mirrors :func:discover_commands: an entry point that fails to
load (a broken/renamed target, a missing optional dependency) or that does
not resolve to a command is logged at WARNING and skipped, so one bad
plugin never takes the app down -- the rest still load.
importlib.metadata is imported lazily (inside :func:_compat.iter_entry_points)
so a plain import duho never pays its cost -- only calling this triggers
the load (plan 02 P1). The result is sorted by resolved subcommand name for
deterministic --help output.
Source code in src/duho/discovery.py
692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 | |
is_class_command(obj)
True if obj is a class command: a Cmd subclass (not Cmd itself).
Source code in src/duho/discovery.py
108 109 110 | |
is_module_command(obj)
True if obj is a module command (a :class:ModuleCommand wrapper).
Source code in src/duho/discovery.py
113 114 115 | |
register_command_provider(predicate, builder)
Register an external provider that builds a Command from a directory.
This is the extension seam that keeps directory-shaped command runtimes
(e.g. an ordered "run-path" of numbered step files) OUT of core duho: an
external package registers (predicate, builder); when CmdBuilder
resolves a filesystem source, it consults registered providers before
importing the path normally, and the first matching provider's builder
produces the command. If no provider matches, the path is imported as a
plain module/package.
predicate(path: Path) -> bool-- True if this provider handlespath.builder(path: Path, qualname: str) -> Command-- build the command.
Providers are consulted most-recently-registered first, so a later registration can take precedence over an earlier one for the same shape.
Source code in src/duho/discovery.py
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 | |