Architecture Overview
The dapcli is a headless CLI built on Click, designed to manage the Dell Automation Platform. The architecture centers on a single entry point that constructs a shared Invocation context from global flags, which is then passed to subcommands. This context resolves configuration, credentials, and output preferences in one place, ensuring consistent behavior across generic REST verbs and specialized plugin engines. The CLI supports extensibility through a kubectl-style mechanism that delegates unknown commands to external dap-* executables on the system PATH.
Entry Point and Command Registration
Section titled “Entry Point and Command Registration”The CLI entry point is the cli function in src/dap/cli.py, registered as the dap console script 1. It uses a Click group with a custom ExtensibleGroup class to support both built-in commands and external plugins 2. Commands are registered by adding sub-packages that expose their own commands, which are then added to the main group. The entry point handles global options such as output format, context selection, namespace, and debugging flags like --dry-run and --curl.
Context Building
Section titled “Context Building”The Invocation class in src/dap/rest/context.py is the central object for per-invocation state 3. It is built once from the root group’s global flags and stashed on Click’s ctx.obj. This object contains the resolved configuration, credential store, output preferences, and flags for debugging. Subcommands pull the necessary configuration and a ready-to-use RestClient from this context, ensuring that auth resolution and flag plumbing are handled in a single location. The Invocation.build method loads the configuration and initializes the credential store.
Generic REST Verbs
Section titled “Generic REST Verbs”The CLI provides generic REST verbs for interacting with the DAP portal and orchestrator 2. These are built using build_portal_group() and build_orchestrator_group() functions. The Invocation class maps CLI components like portal, neo, dpc, and ai to either the portal or orchestrator registry keys 3. The client_for method in Invocation builds an authenticated RestClient for a given component, handling server and token overrides if provided. If overrides are not provided, it resolves the binding from the configuration and uses the TokenProvider to obtain a bearer token.
Specialized Plugin Engines
Section titled “Specialized Plugin Engines”The CLI supports a kubectl-style extension mechanism for third-party plugins 4. The ExtensibleGroup class in src/dap/rest/extension.py falls back to executing dap-* plugins found on the system PATH for unknown commands. When an unknown command is encountered, the CLI searches for an executable with the dap- prefix. If found, it executes the plugin with the remaining arguments passed through verbatim. The extension command provides a way to list discovered extensions.
Error Handling
Section titled “Error Handling”The CLI uses a custom DapError exception type in src/dap/errors.py for user-facing errors 5. This exception is a subclass of click.ClickException and provides a clean, actionable message without showing a Python traceback. It is used for failures that the operator is responsible for resolving, such as missing configuration or bad credentials. Unexpected bugs are allowed to raise normally to show a traceback.
"""dapcli: a headless CLI for the Dell Automation Platform.
Public entry point is :func:`dap.cli.cli`, registered as the ``dap``
console script in ``pyproject.toml``. Sub-packages expose:
- :mod:`dap.config` -- cluster configuration loading + CLI
- :mod:`dap.auth` -- OIDC token mint + login fallback + CLI
- :mod:`dap.inventory` -- Redfish endpoint management + CLI
- :mod:`dap.bootstrap` -- one-shot admin client provisioning + CLI
- :mod:`dap.kube` -- SSH+incus runner proxy used by bootstrap
Hard rules for the codebase live in ``tests/test_repo_hygiene.py``
as machine-checkable assertions. A failing rule fails CI. The
README does not restate them.
"""
from __future__ import annotations
__version__ = "0.1.0"
"""Top-level Click group registration.
Each sub-package contributes its own commands via a ``commands`` module.
This file is intentionally small: any new feature should add a new
sub-package and have its commands registered here in one line.
"""
from __future__ import annotations
import sys
import click
from . import __version__
from .auth.commands import auth
from .auth.import_client import register as _register_import_client
from .blueprint.commands import blueprint
from .bootstrap import commands as _bootstrap_commands # noqa: F401 -- side-effects on `auth`
from .config.commands import config
from .deployment.commands import deployment
from .errors import DapError
from .inventory.commands import inventory
from .plugin.commands import plugin
from .rest.auth_cmds import register as _register_rest_auth
from .rest.commands import build_orchestrator_group, build_portal_group
from .rest.config_cmds import register as _register_rest_config
from .rest.context import Invocation
from .rest.extension import ExtensibleGroup, extension
from .rest.token_cmds import tokens
from .secrets.commands import secret
_register_import_client(auth)
_register_rest_config(config)
_register_rest_auth(auth)
@click.group(cls=ExtensibleGroup, help="Headless CLI for the Dell Automation Platform.")
@click.version_option(__version__)
@click.option("-o", "--output", default="table", help="table|json|yaml|wide|name|manifest")
@click.option("--no-headers", is_flag=True, help="Strip table headers.")
"""Per-invocation context shared across the generic command layer.
A single :class:`Invocation` is built once from the root group's global flags
and stashed on Click's ``ctx.obj``. Every generic verb pulls the resolved
config, output preferences, and a ready-to-use :class:`RestClient` from it, so
auth resolution and flag plumbing live in exactly one place.
"""
from __future__ import annotations
from dataclasses import dataclass
from .auth import TokenProvider
from .client import ClientOptions, RestClient
from .config import Config, load_config, resolve_binding
from .credentials import CredentialStore
# orchestrator outcome subcommands all resolve to the orchestrator binding
_OUTCOME_TO_COMPONENT = {"neo": "orchestrator", "dpc": "orchestrator", "ai": "orchestrator"}
@dataclass
class Invocation:
config: Config
store: CredentialStore
output: str = "table"
no_headers: bool = False
curl: bool = False
dry_run: str = "none"
include_token: bool = False
namespace: str | None = None
context_name: str | None = None
insecure: bool = False
server_override: str | None = None
token_override: str | None = None
@classmethod
def build(cls, **kw) -> Invocation:
return cls(config=load_config(), store=CredentialStore(), **kw)
"""kubectl-style extension mechanism.
An unknown subcommand ``dap foo ...`` is resolved to an executable named
``dap-foo`` on ``$PATH`` and exec'd with the remaining argv passed through
verbatim; the child's exit code and stdio are preserved. This is how
third-party plugins extend the CLI.
"""
from __future__ import annotations
import os
import shutil
import click
_PREFIX = "dap-"
def find_extension(name: str, which=shutil.which) -> str | None:
return which(_PREFIX + name)
def discover_extensions(path_env: str | None = None) -> list[tuple[str, str]]:
"""Return (command-name, executable-path) for every dap-* on PATH."""
seen: dict[str, str] = {}
for directory in (path_env or os.environ.get("PATH", "")).split(os.pathsep):
if not directory or not os.path.isdir(directory):
continue
for entry in sorted(os.listdir(directory)):
if entry.startswith(_PREFIX):
name = entry[len(_PREFIX) :]
full = os.path.join(directory, entry)
if name not in seen and os.access(full, os.X_OK):
seen[name] = full
return sorted(seen.items())
class ExtensibleGroup(click.Group):
"""A Click group that falls back to ``dap-*`` plugins for unknown commands."""
"""User-facing exception type for the dap CLI."""
from __future__ import annotations
import sys
import click
class DapError(click.ClickException):
"""A dap-cli error with a clean, actionable message.
Click catches this and prints ``Error: <message>`` then exits with
code 1, without showing a Python traceback. Use this for any
failure that the operator is responsible for resolving (missing
config, bad credentials, upstream HTTP errors) and let unexpected
bugs (programming errors) raise normally so they show a traceback.
"""
def __init__(self, message: str, *, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code
def show(self, file=None) -> None:
file = file or sys.stderr
click.echo(f"Error: {self.message}", file=file)