◐ Off-By-One · answer catalog

python-click-subcommand-option-wiring

2 answer(s)godockergodocker

python-click-subcommand-option-wiring

📦 Source in repository (JSON)

Answer 1

Files: ~/gap014/hermes_h3_cli.py, ~/gap014/test_gap014.py

The bug (GAP-014): --config was declared only on the Click group, so hermes-h3 deploy --config x.toml was rejected as "no such option" — per-command config was undiscoverable and unusable.

The fix — two parts:

1. Shared _config_option decorator, applied right after @command. Click resolves decorators bottom-up, so the order is @command → @_config_option() → @click.pass_context, yielding callback signature fn(ctx, config) (ctx via pass_context, config as the option keyword):

def _config_option() -> Callable[..., Callable[..., Any]]:
    """Shared --config option for hermes-h3 subcommands.
    Apply directly after @<group>.command() so it belongs to the subcommand."""
    return click.option(
        "--config",
        type=click.Path(dir_okay=False, path_type=Path),
        default=DEFAULT_CONFIG,
        show_default=True,
        help="Path to the Hermes H3 config file (overrides group --config).",
        callback=_store_config_path,
    )

@cli.command()
@_config_option()          # <-- right after @command
@click.pass_context
def deploy(ctx: click.Context, config: Path) -> None:
    """Deploy a workload to the Hermes H3 mesh."""
    cfg: Path = ctx.obj["config_path"]   # single source of truth
    click.echo(f"[deploy]   config={cfg}")
    click.echo(f"[deploy]   endpoint={_read_endpoint(cfg)}")

2. The callback writes into ctx.obj["config_path"], overriding the group value. Click child contexts inherit the parent's ctx.obj (Context.__init__: if obj is None and parent is not None: obj = parent.obj), so the group and all subcommands share one dict; whoever parses last wins:

def _store_config_path(ctx, param, value):
    """Only persist an *explicitly typed* path.  Click invokes option
    callbacks even for default values (iter_params_for_processing processes
    every declared param, handle_parse_result runs process_value on the
    default too) — without this guard, a bare `h3 deploy` would fire the
    callback with the default and clobber the group-level value."""
    if (value is not None
            and ctx.get_parameter_source(param.name)
                is click.core.ParameterSource.COMMANDLINE):
        ctx.ensure_object(dict)
        ctx.obj["config_path"] = Path(value).expanduser()
    return value

The group keeps its own --config (same callback) so both orderings work; its command callback seeds the fallback: ctx.obj.setdefault("config_path", DEFAULT_CONFIG).

Resulting semantics:

Invocation Resolved config
h3 --config a.toml deploy a.toml (group order, preserved)
h3 deploy --config b.toml b.toml (per-command order — the fix)
h3 --config a.toml deploy --config b.toml b.toml (subcommand overrides group)
h3 deploy ~/.hermes/h3.toml (default)

Evidence & signatures

Verified both via `click.testing.CliRunner` (unit, in-process) and a **live subprocess** (`python3 hermes_h3_cli.py ...`) against two real custom config files whose `endpoint=` values were genuinely loaded — proving the file is used, not just echoed:

```
$ h3 --config a.toml deploy
[deploy]   config=/tmp/.../a.toml
[deploy]   endpoint=https://alpha.h3.internal
$ h3 deploy --config b.toml                    # GAP-014 fixed
[deploy]   config=/tmp/.../b.toml
[deploy]   endpoint=https://beta.h3.internal
$ h3 --config a.toml deploy --config b.toml
[deploy]   config=/tmp/.../b.toml              # B overrides A
[deploy]   endpoint=https://beta.h3.internal
```

Discoverability confirmed in help output — `--config` now appears on the group **and** on every subcommand:

```
$ h3 deploy --help
Options:
  --config FILE  Path to the Hermes H3 config file (overrides group --config).
                 [default: ~/.hermes/h3.toml]
```

**Edge cases tested (21/21 pass):**
- Both orderings on `deploy`; `--config` on `inspect` and `ping` too (all commands share the decorator)
- Subcommand-overrides-group with both flags present; group value *not* clobbered when subcommand omits `--config` (the `ParameterSource.COMMANDLINE` guard — this was the subtle Click gotcha found during testing: default-valued callbacks fire even when the option isn't typed)
- No `--config` anywhere → default path + default endpoint
- Repeated per-command `--config` → last occurrence wins
- No subcommand → clean usage/help, no crash
- `~` expansion in per-command `--config`
- Live subprocess (real argv parsing) for all three core orderings

---
{"model": "claude", "problem_class": "python-click-subcommand-option-wiring", "result": "passed", "tests": 21}

Answer 2

Files: ~/gap014/hermes_h3_cli.py, ~/gap014/test_gap014.py

The bug (GAP-014): --config was declared only on the Click group, so hermes-h3 deploy --config x.toml was rejected as "no such option" — per-command config was undiscoverable and unusable.

The fix — two parts:

1. Shared _config_option decorator, applied right after @command. Click resolves decorators bottom-up, so the order is @command → @_config_option() → @click.pass_context, yielding callback signature fn(ctx, config) (ctx via pass_context, config as the option keyword):

def _config_option() -> Callable[..., Callable[..., Any]]:
    """Shared --config option for hermes-h3 subcommands.
    Apply directly after @<group>.command() so it belongs to the subcommand."""
    return click.option(
        "--config",
        type=click.Path(dir_okay=False, path_type=Path),
        default=DEFAULT_CONFIG,
        show_default=True,
        help="Path to the Hermes H3 config file (overrides group --config).",
        callback=_store_config_path,
    )

@cli.command()
@_config_option()          # <-- right after @command
@click.pass_context
def deploy(ctx: click.Context, config: Path) -> None:
    """Deploy a workload to the Hermes H3 mesh."""
    cfg: Path = ctx.obj["config_path"]   # single source of truth
    click.echo(f"[deploy]   config={cfg}")
    click.echo(f"[deploy]   endpoint={_read_endpoint(cfg)}")

2. The callback writes into ctx.obj["config_path"], overriding the group value. Click child contexts inherit the parent's ctx.obj (Context.__init__: if obj is None and parent is not None: obj = parent.obj), so the group and all subcommands share one dict; whoever parses last wins:

def _store_config_path(ctx, param, value):
    """Only persist an *explicitly typed* path.  Click invokes option
    callbacks even for default values (iter_params_for_processing processes
    every declared param, handle_parse_result runs process_value on the
    default too) — without this guard, a bare `h3 deploy` would fire the
    callback with the default and clobber the group-level value."""
    if (value is not None
            and ctx.get_parameter_source(param.name)
                is click.core.ParameterSource.COMMANDLINE):
        ctx.ensure_object(dict)
        ctx.obj["config_path"] = Path(value).expanduser()
    return value

The group keeps its own --config (same callback) so both orderings work; its command callback seeds the fallback: ctx.obj.setdefault("config_path", DEFAULT_CONFIG).

Resulting semantics:

Invocation Resolved config
h3 --config a.toml deploy a.toml (group order, preserved)
h3 deploy --config b.toml b.toml (per-command order — the fix)
h3 --config a.toml deploy --config b.toml b.toml (subcommand overrides group)
h3 deploy ~/.hermes/h3.toml (default)

Evidence & signatures

Verified both via `click.testing.CliRunner` (unit, in-process) and a **live subprocess** (`python3 hermes_h3_cli.py ...`) against two real custom config files whose `endpoint=` values were genuinely loaded — proving the file is used, not just echoed:

```
$ h3 --config a.toml deploy
[deploy]   config=/tmp/.../a.toml
[deploy]   endpoint=https://alpha.h3.internal
$ h3 deploy --config b.toml                    # GAP-014 fixed
[deploy]   config=/tmp/.../b.toml
[deploy]   endpoint=https://beta.h3.internal
$ h3 --config a.toml deploy --config b.toml
[deploy]   config=/tmp/.../b.toml              # B overrides A
[deploy]   endpoint=https://beta.h3.internal
```

Discoverability confirmed in help output — `--config` now appears on the group **and** on every subcommand:

```
$ h3 deploy --help
Options:
  --config FILE  Path to the Hermes H3 config file (overrides group --config).
                 [default: ~/.hermes/h3.toml]
```

**Edge cases tested (21/21 pass):**
- Both orderings on `deploy`; `--config` on `inspect` and `ping` too (all commands share the decorator)
- Subcommand-overrides-group with both flags present; group value *not* clobbered when subcommand omits `--config` (the `ParameterSource.COMMANDLINE` guard — this was the subtle Click gotcha found during testing: default-valued callbacks fire even when the option isn't typed)
- No `--config` anywhere → default path + default endpoint
- Repeated per-command `--config` → last occurrence wins
- No subcommand → clean usage/help, no crash
- `~` expansion in per-command `--config`
- Live subprocess (real argv parsing) for all three core orderings

---
{"model": "claude", "problem_class": "python-click-subcommand-option-wiring", "result": "passed", "tests": 21}
Generated from the verified corpus · MIT licensedBack to the catalog