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__ = ['Command', 'ModuleCommand', 'CmdBuilder', 'register_command_provider', 'unregister_command_provider', 'import_from_path', '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 in a privateduho._cmdbuilder.namespace (so a loose file NEVER clobbers a real installed module of the same dotted name, even one not imported yet -- e.g.CmdBuilder("json", ...)never becomessys.modules["json"]). A directory with__init__.pyis a package and imported by qualname, but ONLY after verifying thatqualnameactually resolves (viasys.path) to this same directory -- otherwise a same-named package elsewhere onsys.pathwould be silently wrapped instead, or a real ImportError with no message raised. A directory without__init__.pyis 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/_builder.py
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | |
command
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/_command.py
56 57 | |
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 (a module-level_parsername_override, else the file stem with_->-);description/help-- frommodule.__doc__;- the entrypoint --
module.main(primary), falling back tomodule.run/module.call. Only a callable genuinely DEFINED in the module counts (see :func:_own_callable) -- an imported function of the same name (from subprocess import run) is never mistaken for the entrypoint; - 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._mcp_-- a module-level_mcp_ = Falseopts this command (and, when it is a namespace, its whole subtree) out of the MCP tool surface --duho.mcp's per-command exclusion. DefaultTrue(read viagetattr(module, "_mcp_", True), so a module that never mentions it is unaffected).
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.
Only a callable genuinely DEFINED in the module is bound to a hook name
(see :func:_own_callable) -- from colorama import init never becomes
the init hook.
Hook signatures / logger source. The lifecycle hooks (init/success/
finally_) and the entrypoint receive the parsed args instance and read
their logger from that instance's _logger_ (present on
LoggingArgs-based commands) rather than a separately threaded logger
argument. The driver (runtime.run_command) ensures args._logger_ is
present before calling them: when the parsed instance has none, it sets one
via :meth:_logger_for (the args instance's own _logger_ if present,
else this module's "duho" logger) so a hook written against the
documented convention never hits AttributeError on a plain root. 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/_command.py
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 259 260 261 262 263 264 265 266 267 | |
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.
entrypoint
property
The resolved callable (main/run/call or the one passed in).
finally_ = _own_callable(module, 'finally_') or _noop
instance-attribute
help
property
One-line help -- the first line of :attr:description.
init = _own_callable(module, 'init') or _init_noop
instance-attribute
module = module
instance-attribute
register = _own_callable(module, 'register') or _noop
instance-attribute
success = _own_callable(module, 'success') or _noop
instance-attribute
__call__(args=None)
A ModuleCommand is directly callable; delegates to :meth:main.
Source code in src/duho/discovery/_command.py
303 304 305 | |
__repr__()
Source code in src/duho/discovery/_command.py
307 308 309 310 311 | |
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/_command.py
292 293 294 295 296 297 298 299 300 301 | |
discover_commands(source, *, on_error=None, providers=False, sort=True)
Discover commands from a package name or a directory, resiliently.
source may also be a list or tuple of sources: each is discovered on its
own, a command of the same name in a later source replaces the earlier one,
and the merged result is sorted by name as below. An empty sequence gives
[]. Each member goes through the same checks as a single source.
sort=False keeps discovery order instead of sorting: the sources as
given; within a directory or package, files by name; within a file,
definition order. A later command of the same name takes the place of the
earlier one.
on_error(source, exc), when given, replaces the resilience rule below:
it is called for any exception (SystemExit included) raised while
importing one file or one package module, building its commands, or building
a provider command, with source the file Path, the dotted module
name, or the provider directory. Returning skips that one; raising aborts
discovery. None keeps the rule below.
providers=True (filesystem sources only) first offers the source
directory itself, then each of its child directories not starting with _
or ., to the registered command providers
(:func:register_command_provider); a matching one builds a command named
from the directory (_ as -). A source directory a provider claims
yields that one command and its files are not scanned. Off by default.
A single source is dispatched by shape:
- a
Path/os.PathLike, or astrcontaining/or\-> filesystem: iteratesorted(dir.glob("*.py")), skip_-prefixed files, import each under a synthesized uniquesys.modulesname, and collect its commands; - any other
str-> FIRST taken as a 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. Only when the name does not resolve to an importable module/package does it fall back to a same-named directory relative to the current working directory (filesystem mode, as above). This ordering is deliberate: a bare name is usually an intended package, and trying it first prevents an unrelated same-named directory in the CWD from silently shadowing it and having its.pyfiles imported/executed instead (see :func:_looks_like_path).
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.
An empty source is rejected outright (ValueError): a bare ""
or an os.PathLike whose __fspath__() is "" would otherwise
silently discover the current working directory as a side effect of a
blank/uninitialised value (a security-relevant fix -- this is also the
path :func:duho.runtime.app's source= argument goes through, so
app(source="") is covered the same way). Pass "." explicitly (a
string, or any Path/os.PathLike -- Path("") included, since it
already normalises to Path(".") before reaching here) to deliberately
scan the current directory.
A bare drive letter is also rejected outright (ValueError): a
"C:"-shaped source (see :func:_is_bare_drive_source) is Windows'
own spelling for "the current directory on that drive" -- another,
platform-specific way to smuggle the CWD in past the empty-source check
above (a security-relevant fix, likewise covering app(source="C:")).
Spell an actual path ("C:\cmds", or "." for the CWD) instead.
The result is sorted by resolved subcommand name for deterministic
--help output (filesystem iteration order is OS-dependent).
Source code in src/duho/discovery/_scan.py
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 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 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 | |
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. The result is sorted by resolved subcommand name for
deterministic --help output.
Source code in src/duho/discovery/_entrypoints.py
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 | |
import_from_path(base_name, path)
Import a .py file at path under a sys.modules key derived
from base_name, guaranteed not to clobber an existing module of that
name (:func:_unique_module_name) and reused across repeat imports of
the SAME file (see :func:_import_from_path).
Public counterpart of the _unique_module_name + _import_from_path
pair this module already used internally for its own filesystem-based
discovery (:class:CmdBuilder, :func:discover_commands) -- exposed
so an external command-provider package (duho.runpath is the
first, and so far only, consumer) can import a .py file the exact
same way without reaching into either private helper directly.
Source code in src/duho/discovery/_importing.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | |
is_class_command(obj)
True if obj is a class command: a Cmd subclass (not Cmd itself).
Source code in src/duho/discovery/_command.py
60 61 62 | |
is_module_command(obj)
True if obj is a module command (a :class:ModuleCommand wrapper).
Source code in src/duho/discovery/_command.py
65 66 67 | |
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/_providers.py
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | |
unregister_command_provider(predicate, builder)
Remove a provider registered with
:func:register_command_provider -- the exact (predicate, builder)
pair (matched the same way list.remove would).
A no-op if that exact pair is not currently registered, so a caller does not need to track whether it already unregistered. Use it for test isolation or a plugin reloading itself.
Source code in src/duho/discovery/_providers.py
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | |