Skip to content

cli

The deeperfly terminal interface, thin by design (ADR-0019).

Six commands (ADR-0084, ADR-0151): gui (gui) opens a project or a collection in the browser, train (train) trains a detector on projects, run (run) applies a run config to projects, models (models) lists, downloads and removes the released detectors, cache (cache) reports and clears what deeperfly keeps, each through subcommands (ADR-0154), and doctor (doctor) reports the installation. The commands follow the conventions deeperfly shares with octacam and spintrack (CONTRIBUTING.md): -c/--config, --set KEY=VALUE, -o/--out, --force, -v/--verbose. Suggestions and swapping two animals of a run are GUI actions. Typer is imported only here, when the command runs.

main

main() -> None

Console-script entry point (deeperfly).

Source code in src/deeperfly/cli/__init__.py
def main() -> None:
    """Console-script entry point (`deeperfly`)."""
    app()

deeperfly run PATH... -c config.toml: a run config applied to projects.

Every run serves a live view (ADR-0126) that stays idle until its printed URL is opened; --live has it watch from the first batch and opens it, --no-live serves none.

Swapping two animals of a run is the GUI's Runs panel (ADR-0084), over swap_animals.

run

run(
    paths: Annotated[
        list[Path],
        Argument(
            help="Projects: each a project folder, a directory holding a deeperfly/ project, or a directory of video files, which gets the default project deeperfly/ on its first run.",
            show_default=False,
        ),
    ],
    config: Annotated[
        Path,
        Option(
            --config,
            -c,
            help="The run config, a TOML file (see `docs/config-schema.md`).",
        ),
    ],
    sets: Sets = None,
    only: Annotated[
        list[str] | None,
        Option(--only, help="Run just these stages."),
    ] = None,
    skip: Annotated[
        list[str] | None,
        Option(--skip, help="Leave these stages out."),
    ] = None,
    out: Annotated[
        Path | None,
        Option(
            --out,
            -o,
            help="Output root: `<out>/<project>/<name>.h5` (default: `<project>/results/<name>.h5`).",
        ),
    ] = None,
    force: Annotated[
        bool,
        Option(
            --force,
            help="Recompute every frame asked for, let another config take over a run of this name, and replace a results file written for other video.",
        ),
    ] = False,
    dry_run: Annotated[
        bool,
        Option(
            --dry - run,
            help="Validate and print the plan; run nothing, create no project.",
        ),
    ] = False,
    device: Annotated[
        str | None,
        Option(help="Override `[compute] device`."),
    ] = None,
    name: Annotated[
        str | None,
        Option(
            --name,
            help="The run's name (default: the config's, else the time).",
        ),
    ] = None,
    frames: Annotated[
        str | None,
        Option(
            --frames,
            help="Frames to cover, comma-separated slices and indices, e.g. 0:1000:2,5000,6000::10 (default: the config's, else every frame).",
        ),
    ] = None,
    batch: Annotated[
        int | None,
        Option(
            --batch,
            min=1,
            metavar="<int>",
            help="Override `[compute] batch`: frames per pass.",
        ),
    ] = None,
    precision: Annotated[
        str | None,
        Option(
            --precision,
            help="Override `[compute] precision`: auto, float32, float16, bfloat16.",
        ),
    ] = None,
    compile: Annotated[
        bool | None,
        Option(
            --compile / --no - compile,
            help="Override `[compute] compile`: torch.compile the pose net.",
        ),
    ] = None,
    accumulate: Annotated[
        str | None,
        Option(
            --accumulate,
            help="Override `[compute] accumulate`: float32, or float16 with `--precision float16`.",
        ),
    ] = None,
    live: Annotated[
        bool,
        Option(
            --live,
            help="Watch from the first batch and open the live view now; without it the view is idle until its printed URL is opened.",
        ),
    ] = False,
    no_live: Annotated[
        bool,
        Option(--no - live, help="Serve no live view."),
    ] = False,
    port: Annotated[
        int | None,
        Option(
            --port,
            min=1,
            max=65535,
            metavar="<int>",
            help="Port of the live view (default: 8100, or the next free one).",
            show_default=False,
        ),
    ] = None,
    verbose: Verbose = False,
) -> None

Apply a run config to each project, stage by stage, over a frame set.

Each path is resolved to a project: a project folder as it is, else its deeperfly/, else, for a directory of video files, the default project deeperfly/ is created in it (one view per video file, fly38, one animal).

A run is <project>/results/<name>.h5 with its config copied to <name>.toml beside it, and its videos under <project>/renders/<name>/. The name is the config's name, else the time of the invocation. A run of that name with the same config is continued: only the frames it does not cover are computed. Another config under the name is refused unless --force, which also recomputes every frame asked for and replaces a results file written for other video.

--set keys count as part of the config, and the copy beside the results has them written in; [compute] keys, like --device, do not. --name and --frames stand in for the config's name and frames, which may differ between invocations of one run. --dry-run checks every project and prints the coverage plan. A failed project is reported, the rest continue, and the command exits non-zero.

The live view's URL is printed first: opening it shows the detections as they are made, the stages and the log, and can stop the run as Ctrl-C would, which exits 130.

Source code in src/deeperfly/cli/run.py
def run(
    paths: Annotated[
        list[Path],
        typer.Argument(
            help=(
                "Projects: each a project folder, a directory holding a deeperfly/ "
                "project, or a directory of video files, which gets the default "
                "project deeperfly/ on its first run."
            ),
            show_default=False,
        ),
    ],
    config: Annotated[
        Path,
        typer.Option(
            "--config",
            "-c",
            help="The run config, a TOML file (see `docs/config-schema.md`).",
        ),
    ],
    sets: Sets = None,
    only: Annotated[
        list[str] | None, typer.Option("--only", help="Run just these stages.")
    ] = None,
    skip: Annotated[
        list[str] | None, typer.Option("--skip", help="Leave these stages out.")
    ] = None,
    out: Annotated[
        Path | None,
        typer.Option(
            "--out",
            "-o",
            help=(
                "Output root: `<out>/<project>/<name>.h5` (default: "
                "`<project>/results/<name>.h5`)."
            ),
        ),
    ] = None,
    force: Annotated[
        bool,
        typer.Option(
            "--force",
            help=(
                "Recompute every frame asked for, let another config take over a "
                "run of this name, and replace a results file written for other "
                "video."
            ),
        ),
    ] = False,
    dry_run: Annotated[
        bool,
        typer.Option(
            "--dry-run",
            help="Validate and print the plan; run nothing, create no project.",
        ),
    ] = False,
    device: Annotated[
        str | None, typer.Option(help="Override `[compute] device`.")
    ] = None,
    name: Annotated[
        str | None,
        typer.Option(
            "--name", help="The run's name (default: the config's, else the time)."
        ),
    ] = None,
    frames: Annotated[
        str | None,
        typer.Option(
            "--frames",
            help=(
                "Frames to cover, comma-separated slices and indices, e.g. "
                "0:1000:2,5000,6000::10 (default: the config's, else every frame)."
            ),
        ),
    ] = None,
    batch: Annotated[
        int | None,
        typer.Option(
            "--batch",
            min=1,
            metavar="<int>",
            help="Override `[compute] batch`: frames per pass.",
        ),
    ] = None,
    precision: Annotated[
        str | None,
        typer.Option(
            "--precision",
            help="Override `[compute] precision`: auto, float32, float16, bfloat16.",
        ),
    ] = None,
    compile: Annotated[
        bool | None,
        typer.Option(
            "--compile/--no-compile",
            help="Override `[compute] compile`: torch.compile the pose net.",
        ),
    ] = None,
    accumulate: Annotated[
        str | None,
        typer.Option(
            "--accumulate",
            help=(
                "Override `[compute] accumulate`: float32, or float16 with "
                "`--precision float16`."
            ),
        ),
    ] = None,
    live: Annotated[
        bool,
        typer.Option(
            "--live",
            help=(
                "Watch from the first batch and open the live view now; without it "
                "the view is idle until its printed URL is opened."
            ),
        ),
    ] = False,
    no_live: Annotated[
        bool, typer.Option("--no-live", help="Serve no live view.")
    ] = False,
    port: Annotated[
        int | None,
        typer.Option(
            "--port",
            min=1,
            max=65535,
            metavar="<int>",
            help="Port of the live view (default: 8100, or the next free one).",
            show_default=False,
        ),
    ] = None,
    verbose: Verbose = False,
) -> None:
    """Apply a run config to each project, stage by stage, over a frame set.

    Each path is resolved to a project: a project folder as it is, else its
    `deeperfly/`, else, for a directory of video files, the default project
    `deeperfly/` is created in it (one view per video file, fly38, one animal).

    A run is `<project>/results/<name>.h5` with its config copied to `<name>.toml`
    beside it, and its videos under `<project>/renders/<name>/`. The name is the
    config's `name`, else the time of the invocation. A run of that name with the
    same config is continued: only the frames it does not cover are computed. Another
    config under the name is refused unless `--force`, which also recomputes every
    frame asked for and replaces a results file written for other video.

    `--set` keys count as part of the config, and the copy beside the results has
    them written in; `[compute]` keys, like `--device`, do not. `--name` and
    `--frames` stand in for the config's `name` and `frames`, which may differ between
    invocations of one run. `--dry-run` checks every project and prints the coverage
    plan. A failed project is reported, the rest continue, and the command exits
    non-zero.

    The live view's URL is printed first: opening it shows the detections as they
    are made, the stages and the log, and can stop the run as Ctrl-C would, which
    exits 130.
    """
    from pydantic import ValidationError

    from ..config import ConfigError, RunConfig
    from ..config.run import RunCompute, parse_frames
    from ..document import parse, with_keys
    from ..pipeline import run_projects
    from ..pipeline.plan import describe, run_name, select_stages

    if live and no_live:
        raise typer.BadParameter(
            "--live opens the live view that --no-live turns off", param_hint="--live"
        )
    changes, compute_sets = split_sets(RunConfig, sets)
    cfg = load_run(config, changes)
    if compute_sets:
        try:
            set_compute = parse(
                RunCompute,
                with_keys(cfg.compute.model_dump(), compute_sets),
                "compute",
            )
        except ConfigError as exc:
            raise typer.BadParameter(str(exc), param_hint="--set") from exc
        cfg = cfg.model_copy(update={"compute": set_compute})
    compute = {
        key: value
        for key, value in (
            ("device", device),
            ("batch", batch),
            ("precision", precision),
            ("compile", compile),
            ("accumulate", accumulate),
        )
        if value is not None
    }
    if compute:
        try:
            merged = RunCompute.model_validate({**cfg.compute.model_dump(), **compute})
        except ValidationError as exc:
            detail = "; ".join(e["msg"] for e in exc.errors())
            hint = ", ".join(sorted({f"--{e['loc'][0]}" for e in exc.errors()}))
            raise typer.BadParameter(detail, param_hint=hint) from exc
        cfg = cfg.model_copy(update={"compute": merged})
    spec = None
    if frames is not None:
        spec = [part.strip() for part in frames.split(",") if part.strip()]
        try:
            parse_frames(spec)
        except ValueError as exc:
            raise typer.BadParameter(str(exc), param_hint="--frames") from exc
    try:
        run = run_name(cfg, name)
    except ConfigError as exc:
        raise typer.BadParameter(str(exc), param_hint="--name") from exc
    try:
        stages = select_stages(cfg, tuple(only or ()), tuple(skip or ()))
    except ConfigError as exc:
        raise typer.BadParameter(str(exc), param_hint="CONFIG") from exc
    try:
        if dry_run:
            report, bad = describe(
                cfg, stages, paths, out, name=run, frames=spec, force=force
            )
            for line in report:
                typer.echo(line)
            if bad:
                raise typer.Exit(code=1)
            return
    except (FileNotFoundError, ValueError) as exc:
        raise typer.BadParameter(str(exc), param_hint="PATHS") from exc
    watch, stop_live = (
        (None, None)
        if no_live
        else _live_view(cfg, config, spec, forced=live, port=port)
    )
    log = typer.echo
    if watch is not None:

        def log(line: str) -> None:
            typer.echo(line)
            watch.log(line)

    try:
        summary = run_projects(
            cfg,
            paths,
            only=tuple(only or ()),
            skip=tuple(skip or ()),
            out=out,
            force=force,
            name=name,
            frames=spec,
            log=log,
            observer=watch,
        )
    except (FileNotFoundError, ValueError) as exc:
        _end_live(watch, stop_live, "failed")
        raise typer.BadParameter(str(exc), param_hint="PATHS") from exc
    except KeyboardInterrupt:
        if watch is None or not watch.stop_requested:
            _end_live(watch, stop_live, "stopped", linger=False)
            raise
        _end_live(watch, stop_live, "stopped")
        typer.echo("stopped from the live view")
        raise typer.Exit(code=130) from None
    _end_live(watch, stop_live, "failed" if summary.failed else "done")
    if summary.failed:
        raise typer.Exit(code=1)

deeperfly train: a detector trained from a TOML config, over projects.

train

train(
    config: Annotated[
        Path,
        Option(
            --config,
            -c,
            help="The training config, a TOML file (see `docs/config-schema.md`).",
        ),
    ],
    paths: Annotated[
        list[Path] | None,
        Argument(
            help="Projects to train on, each a project or a directory of them; they replace the config's `[data] projects`.",
            show_default=False,
        ),
    ] = None,
    sets: Sets = None,
    out: Annotated[
        Path | None,
        Option(
            --out,
            -o,
            help="Output directory (default: `[train] out`, else `<config dir>/<stem>/`).",
        ),
    ] = None,
    steps: Annotated[
        int | None,
        Option(
            help="Override the config's train steps, for a quick run."
        ),
    ] = None,
    device: Annotated[
        str | None,
        Option(
            help="Override the config's compute device."
        ),
    ] = None,
    workers: Annotated[
        int | None,
        Option(
            help="Override the config's compute workers."
        ),
    ] = None,
    verbose: Verbose = False,
) -> None

Train a 2D detector on projects' labels and score it on held-out projects.

The output directory gets the trained detector, its report, and the config as trained: --set keys and the projects given here written in. [compute] keys, like --device, are left out of it.

Source code in src/deeperfly/cli/train.py
def train(
    config: Annotated[
        Path,
        typer.Option(
            "--config",
            "-c",
            help="The training config, a TOML file (see `docs/config-schema.md`).",
        ),
    ],
    paths: Annotated[
        list[Path] | None,
        typer.Argument(
            help="Projects to train on, each a project or a directory of them; "
            "they replace the config's `[data] projects`.",
            show_default=False,
        ),
    ] = None,
    sets: Sets = None,
    out: Annotated[
        Path | None,
        typer.Option(
            "--out",
            "-o",
            help="Output directory (default: `[train] out`, else "
            "`<config dir>/<stem>/`).",
        ),
    ] = None,
    steps: Annotated[
        int | None,
        typer.Option(help="Override the config's train steps, for a quick run."),
    ] = None,
    device: Annotated[
        str | None, typer.Option(help="Override the config's compute device.")
    ] = None,
    workers: Annotated[
        int | None, typer.Option(help="Override the config's compute workers.")
    ] = None,
    verbose: Verbose = False,
) -> None:
    """Train a 2D detector on projects' labels and score it on held-out projects.

    The output directory gets the trained detector, its report, and the config as
    trained: `--set` keys and the projects given here written in. `[compute]` keys,
    like `--device`, are left out of it.
    """
    from ..config import ConfigError, TrainConfig
    from ..config.train import TrainCompute
    from ..document import parse, with_keys
    from ..pose2d.training.run import out_dir as train_out_dir
    from ..pose2d.training.run import train as run_train
    from ..pose2d.training.run import training_projects

    changes, compute_sets = split_sets(TrainConfig, sets)
    try:
        cfg = TrainConfig.load(config, changes)
    except (FileNotFoundError, ConfigError) as exc:
        hint = "--config/--set" if changes else "--config"
        raise typer.BadParameter(str(exc), param_hint=hint) from exc
    if compute_sets:
        try:
            set_compute = parse(
                TrainCompute,
                with_keys(cfg.compute.model_dump(), compute_sets),
                "compute",
            )
        except ConfigError as exc:
            raise typer.BadParameter(str(exc), param_hint="--set") from exc
        cfg = cfg.model_copy(update={"compute": set_compute})
    projects = None
    if cfg.data.kind == "project":
        # Resolved here so a bad pattern or path is a usage error. They go to the
        # training only when they replace the document's, which its output records.
        try:
            named = training_projects(cfg, paths)
        except ConfigError as exc:
            hint = "PATHS" if paths else "--config"
            raise typer.BadParameter(str(exc), param_hint=hint) from exc
        projects = named if paths else None
        _warn_unsaved(named)
    elif paths:
        raise typer.BadParameter(
            f"[data] kind = {cfg.data.kind!r} reads a corpus, not projects",
            param_hint="PATHS",
        )
    if steps is not None:
        cfg = cfg.model_copy(
            update={"train": cfg.train.model_copy(update={"steps": steps})}
        )
    if device is not None or workers is not None:
        cfg = cfg.model_copy(
            update={
                "compute": cfg.compute.model_copy(
                    update={
                        "device": device if device is not None else cfg.compute.device,
                        "workers": (
                            workers if workers is not None else cfg.compute.workers
                        ),
                    }
                )
            }
        )
    target = train_out_dir(cfg, out)
    report = run_train(cfg, target, projects=projects, log=typer.echo)
    hold = report.get("holdout_report")
    if hold:
        o = hold["overall"]
        typer.echo(
            f"held-out mean {o['mean']:.2f} px, p90 {o['p90']:.2f} px "
            f"over {o['n']} cells"
        )
    typer.echo(f"wrote {target}")

deeperfly gui: the browser workspace over a project or a collection.

Setup, calibration, labeling, suggestions, runs and swapping two animals of a run are all done there (ADR-0084).

gui

gui(
    paths: Annotated[
        list[Path] | None,
        Argument(
            help="Projects to open: each a project folder, a directory holding a deeperfly/ project, or a directory of projects. None opens the launcher; one directory of videos opens the setup form over it.",
            show_default=False,
        ),
    ] = None,
    host: Annotated[
        str, Option(help="Interface to bind.")
    ] = "127.0.0.1",
    port: Annotated[
        int | None,
        Option(
            help="Port to serve on. Default: 8000, or the next free port.",
            show_default=False,
        ),
    ] = None,
    no_browser: Annotated[
        bool,
        Option(
            --no - browser, help="Do not open a browser."
        ),
    ] = False,
    stop: Annotated[
        bool,
        Option(
            --stop,
            help="Stop your running GUI server, and exit.",
        ),
    ] = False,
    verbose: Verbose = False,
) -> None

Launch the browser GUI: set up projects, calibrate, label.

One project opens straight in its workspace; several, or a directory whose children are projects, open together as a collection. A directory of videos with no project yet opens the setup form over it. The paths are checked here first. A server already running opens them in a new tab instead.

Source code in src/deeperfly/cli/gui.py
def gui(
    paths: Annotated[
        list[Path] | None,
        typer.Argument(
            help="Projects to open: each a project folder, a directory holding a "
            "deeperfly/ project, or a directory of projects. None opens the launcher; "
            "one directory of videos opens the setup form over it.",
            show_default=False,
        ),
    ] = None,
    host: Annotated[str, typer.Option(help="Interface to bind.")] = "127.0.0.1",
    port: Annotated[
        int | None,
        typer.Option(
            help="Port to serve on. Default: 8000, or the next free port.",
            show_default=False,
        ),
    ] = None,
    no_browser: Annotated[
        bool, typer.Option("--no-browser", help="Do not open a browser.")
    ] = False,
    stop: Annotated[
        bool,
        typer.Option("--stop", help="Stop your running GUI server, and exit."),
    ] = False,
    verbose: Verbose = False,
) -> None:
    """Launch the browser GUI: set up projects, calibrate, label.

    One project opens straight in its workspace; several, or a directory whose
    children are projects, open together as a collection. A directory of videos with
    no project yet opens the setup form over it. The paths are checked here first. A
    server already running opens them in a new tab instead.
    """
    if stop:
        from ..gui import instance

        stopped = instance.stop()
        for server in stopped:
            typer.echo(
                f"stopped the gui server on {server.base_url} (pid {server.pid})"
            )
        if not stopped:
            typer.echo("no deeperfly gui server is running")
        return

    from ..gui import serve  # lazy: keeps the rest of the CLI free of the web stack
    from ..gui.server import start_page

    given = list(paths or [])
    try:
        start_page(given)
    except (FileNotFoundError, ValueError) as exc:
        raise typer.BadParameter(str(exc), param_hint="PATHS") from exc
    try:
        serve(host, port, open_browser=not no_browser, paths=given)
    except (OSError, RuntimeError) as exc:
        typer.echo(f"error: {exc}", err=True)
        raise typer.Exit(1) from exc

deeperfly models: the released detectors, and the copies of them in the cache.

Three subcommands (ADR-0154). list prints the catalog this deeperfly ships with (released), rig by rig, and whether each model is downloaded. A run config names a model where it would give a path (ADR-0151) and downloads it on first use (ADR-0153); download fetches the models named ahead of that, or all of them with --all, and remove deletes the models named from the cache. All of the cache at once is deeperfly cache clear's to delete.

download

download(
    names: Annotated[
        list[str] | None,
        Argument(help=NAMES, show_default=False),
    ] = None,
    everything: Annotated[
        bool,
        Option(
            --all, help="Download every released model."
        ),
    ] = False,
    verbose: Verbose = False,
) -> None

Download the released models named, or all of them with --all.

The first run that names a model downloads it, so this is for fetching ahead of time, as for a machine that will run without a network. A model already in the cache is checked against its SHA-256 and not fetched again.

Source code in src/deeperfly/cli/models.py
def download(
    names: Annotated[
        list[str] | None, typer.Argument(help=NAMES, show_default=False)
    ] = None,
    everything: Annotated[
        bool,
        typer.Option("--all", help="Download every released model."),
    ] = False,
    verbose: Verbose = False,
) -> None:
    """Download the released models named, or all of them with `--all`.

    The first run that names a model downloads it, so this is for fetching ahead of
    time, as for a machine that will run without a network. A model already in the
    cache is checked against its SHA-256 and not fetched again.
    """
    from ..pose2d.released import RELEASED
    from ..pose2d.released import download as fetch

    if names and everything:
        raise typer.BadParameter("--all takes no model names", param_hint="NAMES")
    if not names and not everything:
        raise typer.BadParameter("name the models, or pass --all", param_hint="NAMES")
    fetch(_released(names) if names else RELEASED, log=typer.echo)

list_models

list_models(
    as_json: Annotated[
        bool,
        Option(
            --json,
            help="Print the list as JSON, for a script.",
        ),
    ] = False,
    verbose: Verbose = False,
) -> None

List the released detectors and locators, and whether each is downloaded.

A model is named in a run config where a path would go: [pose2d] weights = ["NAME"], and locator = "NAME" for a rig that needs one.

Source code in src/deeperfly/cli/models.py
def list_models(
    as_json: Annotated[
        bool,
        typer.Option("--json", help="Print the list as JSON, for a script."),
    ] = False,
    verbose: Verbose = False,
) -> None:
    """List the released detectors and locators, and whether each is downloaded.

    A model is named in a run config where a path would go: `[pose2d] weights =
    ["NAME"]`, and `locator = "NAME"` for a rig that needs one.
    """
    from ..cache import cache_dir
    from ..pose2d.released import RELEASED

    chosen = list(RELEASED.values())
    if as_json:
        rows = [
            {
                "name": m.name,
                "rig": m.rig,
                "role": m.role,
                "size": m.size,
                "sha256": m.sha256,
                "downloaded": m.downloaded,
                "path": str(m.path),
            }
            for m in chosen
        ]
        typer.echo(json.dumps(rows, indent=2))
        return
    width = max(len(m.name) for m in chosen)
    rig = None
    for m in chosen:
        if m.rig != rig:
            rig = m.rig
            typer.echo(rig)
        state = (
            typer.style("downloaded", fg="green") if m.downloaded else "not downloaded"
        )
        typer.echo(f"  {m.name:<{width}}  {m.role:<8}  {megabytes(m.size):>7}  {state}")
    typer.echo("")
    typer.echo(f"Downloads are kept in {cache_dir() / 'models'}.")

remove

remove(
    names: Annotated[
        list[str], Argument(help=NAMES, show_default=False)
    ],
    verbose: Verbose = False,
) -> None

Remove the downloaded models named from the cache.

Removing costs only time: the next run that names a model downloads it again. A models directory that is a link to a shared copy is left alone. deeperfly cache clear deletes every model at once, with the rest of the cache.

Source code in src/deeperfly/cli/models.py
def remove(
    names: Annotated[list[str], typer.Argument(help=NAMES, show_default=False)],
    verbose: Verbose = False,
) -> None:
    """Remove the downloaded models named from the cache.

    Removing costs only time: the next run that names a model downloads it again.
    A `models` directory that is a link to a shared copy is left alone.
    `deeperfly cache clear` deletes every model at once, with the rest of the cache.
    """
    from ..pose2d import released

    freed = released.remove(_released(names))
    for name in names:
        if name in freed:
            typer.echo(f"removed {name} ({megabytes(freed[name])})")
        else:
            typer.echo(f"{name}: not downloaded")

deeperfly cache: where deeperfly keeps what it can fetch or make again.

Two subcommands (ADR-0154): info reports the cache directory (cache_dir) and what each part of it holds, and clear deletes all of it (ADR-0151). A single model is deeperfly models remove's to delete.

clear

clear(verbose: Verbose = False) -> None

Delete everything in the cache: every model and every kernel.

Clearing costs only time: a model is downloaded again, a kernel compiled again. A models directory that is a link to a shared copy loses the link only. deeperfly models remove deletes the models named and leaves the rest.

Source code in src/deeperfly/cli/cache.py
def clear(verbose: Verbose = False) -> None:
    """Delete everything in the cache: every model and every kernel.

    Clearing costs only time: a model is downloaded again, a kernel compiled again.
    A `models` directory that is a link to a shared copy loses the link only.
    `deeperfly models remove` deletes the models named and leaves the rest.
    """
    from .. import cache as store

    root = store.cache_dir()
    freed = store.clear()
    if freed:
        done = ", ".join(f"{p} ({megabytes(n)})" for p, n in freed.items())
        typer.echo(f"cleared {done} from {root}")
    else:
        typer.echo(f"nothing to clear in {root}")

info

info(
    as_json: Annotated[
        bool,
        Option(
            --json,
            help="Print the report as JSON, for a script.",
        ),
    ] = False,
    verbose: Verbose = False,
) -> None

Show the cache directory and what it holds.

The cache holds the released models deeperfly downloaded and the kernels the GUI compiled. $DEEPERFLY_CACHE_DIR moves the directory.

Source code in src/deeperfly/cli/cache.py
def info(
    as_json: Annotated[
        bool,
        typer.Option("--json", help="Print the report as JSON, for a script."),
    ] = False,
    verbose: Verbose = False,
) -> None:
    """Show the cache directory and what it holds.

    The cache holds the released models deeperfly downloaded and the kernels the
    GUI compiled. `$DEEPERFLY_CACHE_DIR` moves the directory.
    """
    from .. import cache as store

    root = store.cache_dir()
    held = store.usage()
    if as_json:
        typer.echo(json.dumps({"directory": str(root), "parts": held}, indent=2))
        return
    typer.echo(str(root))
    for part, size in held.items():
        typer.echo(f"  {part:<8}{megabytes(size):>8}  {store.PARTS[part]}")

deeperfly doctor: report the state of the installation, seam by seam.

Does the numeric core work (JAX in float64, on the CPU, as intended)? Can video be decoded, and against which linked libav? Which optional extras are installed (detect, ik, ball), and which accelerator does torch see? Where is the cache, and which released models does it hold? A missing extra is reported with where to read how to add it, not as a fault. Every probe reports rather than raises, so doctor runs to the end on the install that needs diagnosing. --json prints the same findings for a script, and --check makes warnings fail too.

Section dataclass

One probe's verdict: a status word, how bad it is, and the lines under it.

Source code in src/deeperfly/cli/doctor.py
@dataclass
class Section:
    """One probe's verdict: a status word, how bad it is, and the lines under it."""

    name: str
    status: str
    level: Level
    lines: list[tuple[str, str]] = field(default_factory=list)

    def row(self, label: str, detail: str = "") -> None:
        """Add a `label   detail` line; a line with no detail is plain text."""
        self.lines.append((label, detail))

row

row(label: str, detail: str = '') -> None

Add a label detail line; a line with no detail is plain text.

Source code in src/deeperfly/cli/doctor.py
def row(self, label: str, detail: str = "") -> None:
    """Add a `label   detail` line; a line with no detail is plain text."""
    self.lines.append((label, detail))

doctor

doctor(
    as_json: Annotated[
        bool,
        Option(
            --json,
            help="Print the report as JSON, for a script.",
        ),
    ] = False,
    check: Annotated[
        bool,
        Option(
            --check,
            help="Exit non-zero on warnings too, not only errors.",
        ),
    ] = False,
    verbose: Verbose = False,
) -> None

Report the state of the deeperfly installation and its optional pieces.

Exits 1 when a piece is broken (with --check, also when one needs a look), so it can gate a script.

Source code in src/deeperfly/cli/doctor.py
def doctor(
    as_json: Annotated[
        bool,
        typer.Option("--json", help="Print the report as JSON, for a script."),
    ] = False,
    check: Annotated[
        bool,
        typer.Option("--check", help="Exit non-zero on warnings too, not only errors."),
    ] = False,
    verbose: Verbose = False,
) -> None:
    """Report the state of the deeperfly installation and its optional pieces.

    Exits 1 when a piece is broken (with `--check`, also when one needs a look), so
    it can gate a script.
    """
    sections = _report()
    errors = sum(s.level == "error" for s in sections)
    warnings = sum(s.level == "warning" for s in sections)
    if as_json:
        typer.echo(
            json.dumps(
                {
                    "deeperfly": __version__,
                    "python": platform.python_version(),
                    "executable": sys.executable,
                    "platform": platform.platform(),
                    "sections": [asdict(s) for s in sections],
                    "errors": errors,
                    "warnings": warnings,
                },
                indent=2,
            )
        )
    else:
        _print(sections)
    if errors or (check and warnings):
        raise typer.Exit(1)

What the deeperfly commands share: options, error handling, loading configs.

Every command takes -v/--verbose and goes through command, which turns an unexpected error into one line (a traceback with -v) and Ctrl-C into exit code 130. A config failure the operator can fix becomes a Typer parameter error naming the argument.

command

command(fn: Callable[P, R]) -> Callable[P, R]

Wrap a command: --verbose logging, and errors as one line.

A usage error stays Typer's (exit 2). Any other error prints error: ... and exits 1, with its traceback when the command ran with --verbose; Ctrl-C exits 130.

Source code in src/deeperfly/cli/common.py
def command[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    """Wrap a command: `--verbose` logging, and errors as one line.

    A usage error stays Typer's (exit 2). Any other error prints `error: ...` and
    exits 1, with its traceback when the command ran with `--verbose`; Ctrl-C exits
    130.
    """

    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        verbose = bool(kwargs.get("verbose"))
        if verbose:
            logging.basicConfig(format="%(levelname)s %(name)s: %(message)s")
            logging.getLogger("deeperfly").setLevel(logging.DEBUG)
        try:
            return fn(*args, **kwargs)
        except TyperException, typer.Exit, typer.Abort:
            raise
        except KeyboardInterrupt:
            if verbose:
                traceback.print_exc()
            typer.echo("interrupted", err=True)
            raise typer.Exit(130) from None
        except Exception as exc:
            if verbose:
                raise
            typer.echo(f"error: {exc}", err=True)
            raise typer.Exit(1) from None

    return wrapper

load_run

load_run(
    config: Path, changes: dict[str, Any] | None = None
)

Load a run config with --set changes, a config error as a CLI error.

Source code in src/deeperfly/cli/common.py
def load_run(config: Path, changes: dict[str, Any] | None = None):
    """Load a run config with `--set` changes, a config error as a CLI error."""
    from ..config import ConfigError, RunConfig

    try:
        return RunConfig.load(config, changes)
    except (FileNotFoundError, ConfigError) as exc:
        hint = "--config/--set" if changes else "--config"
        raise typer.BadParameter(str(exc), param_hint=hint) from exc

megabytes

megabytes(size: int) -> str

A size in bytes as megabytes, 384 MB.

Source code in src/deeperfly/cli/common.py
def megabytes(size: int) -> str:
    """A size in bytes as megabytes, `384 MB`."""
    return f"{size / 1e6:,.0f} MB"

split_sets

split_sets(
    cls: type, sets: list[str] | None
) -> tuple[dict, dict]

The --set items as typed values: the document's keys, and [compute]'s.

[compute] keys are kept apart: like --device, they say how to compute, not what, so they are applied over the loaded config and left out of what the run records.

Source code in src/deeperfly/cli/common.py
def split_sets(cls: type, sets: list[str] | None) -> tuple[dict, dict]:
    """The `--set` items as typed values: the document's keys, and `[compute]`'s.

    `[compute]` keys are kept apart: like `--device`, they say how to compute, not
    what, so they are applied over the loaded config and left out of what the run
    records.
    """
    from ..config import ConfigError
    from ..document import overrides

    try:
        changes = overrides(cls, sets or ())
    except ConfigError as exc:
        raise typer.BadParameter(str(exc), param_hint="--set") from exc
    compute = {
        key.removeprefix("compute."): value
        for key, value in changes.items()
        if key.startswith("compute.")
    }
    document = {k: v for k, v in changes.items() if not k.startswith("compute.")}
    return document, compute