Skip to content

topmark.cli.validators

topmark / cli / validators

CLI input validation and option policy helpers.

This module centralizes small, Click-layer checks and normalizations that are shared across multiple commands.

Conventions
  • apply_* helpers apply a policy and may update the shared typed CLI state (for example, to disable ANSI color for output formats that must remain plain). Some apply_* helpers also return the effective value they set or normalized.
  • validate_* helpers enforce a policy and raise TopmarkCliUsageError when the invocation is invalid or unsupported.
Notes
  • Messages should use ctx.command_path for consistency across command groups.
  • All validate_* helpers raise TopmarkCliUsageError on invalid usage.

validate_common_forbidden_path_command_options_in_extra_args

validate_common_forbidden_path_command_options_in_extra_args(
    ctx,
)

Reject common unsupported options left behind by permissive path parsing.

Path commands use ignore_unknown_options=True and allow_extra_args=True to support Black-style path handling. This means a mistyped or unsupported option can survive Click parsing in ctx.args. This validator catches known common spellings before input planning can treat them as file paths.

Parameters:

Name Type Description Default
ctx Context

Active Click context whose ctx.args may contain extra path-like tokens and unknown option spellings.

required

Raises:

Type Description
TopmarkCliUsageError

If a known common unsupported option is present in ctx.args.

Source code in src/topmark/cli/validators.py
def validate_common_forbidden_path_command_options_in_extra_args(
    ctx: click.Context,
) -> None:
    """Reject common unsupported options left behind by permissive path parsing.

    Path commands use ``ignore_unknown_options=True`` and ``allow_extra_args=True``
    to support Black-style path handling. This means a mistyped or unsupported
    option can survive Click parsing in ``ctx.args``. This validator catches
    known common spellings before input planning can treat them as file paths.

    Args:
        ctx: Active Click context whose ``ctx.args`` may contain extra path-like
            tokens and unknown option spellings.

    Raises:
        TopmarkCliUsageError: If a known common unsupported option is present in
            ``ctx.args``.
    """
    extra_args: list[str] = list(ctx.args)
    for opt, reason in _FORBIDDEN_PATH_COMMAND_OPTS_IN_EXTRA_ARGS.items():
        if any(_extra_arg_matches_option(arg, opt) for arg in extra_args):
            raise TopmarkCliUsageError(
                f"Option '{opt}' is not supported for '{ctx.command_path}'. {reason}"
            )

validate_forbidden_options_in_extra_args

validate_forbidden_options_in_extra_args(
    ctx, *, forbidden_opts
)

Reject known-but-forbidden options that remain in ctx.args.

This is used for commands that enable Click's permissive path-oriented parsing (ignore_unknown_options=True / allow_extra_args=True), where unsupported options would otherwise be silently accepted as extra arguments.

Source code in src/topmark/cli/validators.py
def validate_forbidden_options_in_extra_args(
    ctx: click.Context,
    *,
    forbidden_opts: Mapping[str, str],
) -> None:
    """Reject known-but-forbidden options that remain in `ctx.args`.

    This is used for commands that enable Click's permissive path-oriented parsing
    (`ignore_unknown_options=True` / `allow_extra_args=True`), where unsupported
    options would otherwise be silently accepted as extra arguments.
    """
    extra_args: list[str] = list(ctx.args)
    for opt, reason in forbidden_opts.items():
        if any(_extra_arg_matches_option(arg, opt) for arg in extra_args):
            raise TopmarkCliUsageError(
                f"Option '{opt}' is not supported for '{ctx.command_path}'. {reason}"
            )

validate_mutually_exclusive

validate_mutually_exclusive(ctx, *, flags, message=None)

Validate that at most one of the provided flags is enabled.

This is a small Click-oriented utility for enforcing mutual exclusion between boolean CLI options.

Parameters:

Name Type Description Default
ctx Context

Active Click context (used for ctx.command_path in messages).

required
flags dict[str, bool]

Mapping of user-facing option spellings (e.g. "--add-only") to their parsed boolean values.

required
message str | None

Optional override for the error message. When omitted, a standard message using the enabled option spellings is emitted.

None

Raises:

Type Description
TopmarkCliUsageError

If more than one flag is enabled.

Source code in src/topmark/cli/validators.py
def validate_mutually_exclusive(
    ctx: click.Context,
    *,
    flags: dict[str, bool],
    message: str | None = None,
) -> None:
    """Validate that at most one of the provided flags is enabled.

    This is a small Click-oriented utility for enforcing mutual exclusion
    between boolean CLI options.

    Args:
        ctx: Active Click context (used for `ctx.command_path` in messages).
        flags: Mapping of user-facing option spellings (e.g. `"--add-only"`)
            to their parsed boolean values.
        message: Optional override for the error message. When omitted, a
            standard message using the enabled option spellings is emitted.

    Raises:
        TopmarkCliUsageError: If more than one flag is enabled.
    """
    enabled: list[str] = [opt for opt, is_on in flags.items() if is_on]
    if len(enabled) <= 1:
        return

    cmd: str = ctx.command_path
    if message is None:
        # Keep message stable and easy to read.
        joined: str = " and ".join(enabled) if len(enabled) == 2 else ", ".join(enabled)
        message = f"{cmd}: {joined} are mutually exclusive."

    raise TopmarkCliUsageError(message)

validate_machine_format_forbids_flags

validate_machine_format_forbids_flags(
    ctx, *, fmt, flags, reason
)

Validate that certain flags are forbidden when a machine-readable output format is used.

This is a Click-layer policy helper to enforce that specific CLI options are not compatible with machine-readable output formats (e.g. JSON, NDJSON).

Parameters:

Name Type Description Default
ctx Context

Active Click context (used for ctx.command_path in messages).

required
fmt OutputFormat

Effective output format selected for the command.

required
flags Mapping[str, bool]

Mapping of user-facing option spellings to their parsed boolean values.

required
reason str

Explanation string appended to the error message. Should include a leading verb phrase such as "is not supported" or "are not supported".

required

Raises:

Type Description
TopmarkCliUsageError

If any of the specified flags are enabled with a machine-readable format.

Source code in src/topmark/cli/validators.py
def validate_machine_format_forbids_flags(
    ctx: click.Context,
    *,
    fmt: OutputFormat,
    flags: Mapping[str, bool],
    reason: str,
) -> None:
    """Validate that certain flags are forbidden when a machine-readable output format is used.

    This is a Click-layer policy helper to enforce that specific CLI options are not
    compatible with machine-readable output formats (e.g. JSON, NDJSON).

    Args:
        ctx: Active Click context (used for `ctx.command_path` in messages).
        fmt: Effective output format selected for the command.
        flags: Mapping of user-facing option spellings to their parsed boolean values.
        reason: Explanation string appended to the error message. Should include a
            leading verb phrase such as "is not supported" or "are not supported".

    Raises:
        TopmarkCliUsageError: If any of the specified flags are enabled with a
            machine-readable format.
    """
    if not is_machine_format(fmt):
        return

    enabled: list[str] = [opt for opt, is_on in flags.items() if is_on]
    if not enabled:
        return

    cmd: str = ctx.command_path
    if len(enabled) == 1:
        opts: str = enabled[0]
    elif len(enabled) == 2:
        opts = " and ".join(enabled)
    else:
        opts = ", ".join(enabled)

    raise TopmarkCliUsageError(f"{cmd}: {CliOpt.OUTPUT_FORMAT}={fmt.value}: {opts} {reason}")

warn_and_clear

warn_and_clear(ctx, *, message, obj_key, cleared_value)

Warn the user and clear one supported field on typed CLI state.

This helper is for non-fatal output-policy adjustments where TopMark warns that an explicitly requested option is ignored and then updates the corresponding TopmarkCliState field. It is intentionally not a generic state setter; supported fields are defined by _set_cleared_cli_state_value().

Parameters:

Name Type Description Default
ctx Context

Active Click context carrying the shared typed CLI state.

required
message str

Warning message to emit before mutating state.

required
obj_key str

Supported ArgKey.* string identifying the field to clear.

required
cleared_value _T

Replacement value to assign to the typed state field.

required

Returns:

Type Description
_T

The same cleared_value passed by the caller, for use in normalization

_T

expressions.

Raises:

Type Description
KeyError

If obj_key is not supported for clearing.

TypeError

If cleared_value has the wrong type for the target field.

Source code in src/topmark/cli/validators.py
def warn_and_clear(
    ctx: click.Context,
    *,
    message: str,
    obj_key: str,
    cleared_value: _T,
) -> _T:
    """Warn the user and clear one supported field on typed CLI state.

    This helper is for non-fatal output-policy adjustments where TopMark warns
    that an explicitly requested option is ignored and then updates the
    corresponding `TopmarkCliState` field. It is intentionally not a generic
    state setter; supported fields are defined by `_set_cleared_cli_state_value()`.

    Args:
        ctx: Active Click context carrying the shared typed CLI state.
        message: Warning message to emit before mutating state.
        obj_key: Supported `ArgKey.*` string identifying the field to clear.
        cleared_value: Replacement value to assign to the typed state field.

    Returns:
        The same `cleared_value` passed by the caller, for use in normalization
        expressions.

    Raises:
        KeyError: If `obj_key` is not supported for clearing.
        TypeError: If `cleared_value` has the wrong type for the target field.
    """  # noqa: DOC502 - documents propagated exceptions from _set_cleared_cli_state_value()
    state: TopmarkCliState = get_cli_state(ctx)
    state.console.warn(message)
    _set_cleared_cli_state_value(
        state,
        obj_key=obj_key,
        cleared_value=cleared_value,
    )
    return cleared_value

warn_if_report_scope_ignored

warn_if_report_scope_ignored(
    ctx, *, output_format, summary_mode, report_scope
)

Warn when an explicitly provided --report value will have no effect.

--report only affects human per-file output. It is ignored when:

  • a machine-readable output format is selected, or
  • summary mode is enabled.

To avoid noisy warnings for the default case, this helper only emits a warning when the user explicitly provided --report on the command line.

Warnings are emitted through the CLI console helper so machine-readable payloads on stdout remain unchanged.

Parameters:

Name Type Description Default
ctx Context

Active Click context.

required
output_format OutputFormat

Effective output format.

required
summary_mode bool

Whether summary-only mode is enabled.

required
report_scope ReportScope

Effective report scope.

required
Source code in src/topmark/cli/validators.py
def warn_if_report_scope_ignored(
    ctx: click.Context,
    *,
    output_format: OutputFormat,
    summary_mode: bool,
    report_scope: ReportScope,
) -> None:
    """Warn when an explicitly provided `--report` value will have no effect.

    `--report` only affects human per-file output. It is ignored when:

    - a machine-readable output format is selected, or
    - summary mode is enabled.

    To avoid noisy warnings for the default case, this helper only emits a
    warning when the user explicitly provided `--report` on the command line.

    Warnings are emitted through the CLI console helper so machine-readable payloads on stdout
    remain unchanged.

    Args:
        ctx: Active Click context.
        output_format: Effective output format.
        summary_mode: Whether summary-only mode is enabled.
        report_scope: Effective report scope.
    """
    param_source: ParameterSource | None = ctx.get_parameter_source(ArgKey.REPORT_SCOPE)
    if param_source is not click.core.ParameterSource.COMMANDLINE:
        return

    msgs: list[str] = []

    if is_machine_format(output_format):
        msgs.append(
            f"Note: {ctx.command_path}: {CliOpt.REPORT}={report_scope.value} is ignored when "
            f"{CliOpt.OUTPUT_FORMAT}={output_format.value}."
        )
    if summary_mode:
        msgs.append(
            f"Note: {ctx.command_path}: {CliOpt.REPORT}={report_scope.value} is ignored when "
            f"{CliOpt.RESULTS_SUMMARY_MODE} is enabled."
        )
    if not msgs:
        return

    state: TopmarkCliState = get_cli_state(ctx)
    for msg in msgs:
        state.console.warn(msg)

apply_color_policy_for_output_format

apply_color_policy_for_output_format(ctx, *, fmt)

Enforce the CLI color policy for the selected output format.

Colorized (ANSI) output is only supported for OutputFormat.TEXT. For all other formats (e.g. markdown, json, ndjson), ANSI color codes must be disabled to avoid corrupting structured or copy/paste-friendly output.

Behavior
  • If fmt is not OutputFormat.TEXT, force state.color_enabled to False.
  • If the user explicitly requested --color=always and the format does not support color, emit a warning explaining that the option is being ignored.

This helper updates the shared typed CLI state and is intended to be called by subcommands after the effective output format has been resolved.

Requires on TopmarkCliState: - console: The active console instance. - color_mode: The color mode specified with --color or --no-color. - color_enabled: Whether color output is effectively enabled.

Parameters:

Name Type Description Default
ctx Context

Active Click context carrying the shared typed CLI state.

required
fmt OutputFormat

Effective output format selected for the command.

required
Source code in src/topmark/cli/validators.py
def apply_color_policy_for_output_format(
    ctx: click.Context,
    *,
    fmt: OutputFormat,
) -> None:
    """Enforce the CLI color policy for the selected output format.

    Colorized (ANSI) output is only supported for `OutputFormat.TEXT`. For all other formats
    (e.g. `markdown`, `json`, `ndjson`), ANSI color codes must be disabled to avoid corrupting
    structured or copy/paste-friendly output.

    Behavior:
        - If `fmt` is not `OutputFormat.TEXT`, force `state.color_enabled` to `False`.
        - If the user explicitly requested `--color=always` and the format
          does not support color, emit a warning explaining that the option
          is being ignored.

    This helper updates the shared typed CLI state and is intended to be called by subcommands
    after the effective output format has been resolved.

    Requires on `TopmarkCliState`:
        - `console`: The active console instance.
        - `color_mode`: The color mode specified with `--color` or `--no-color`.
        - `color_enabled`: Whether color output is effectively enabled.

    Args:
        ctx: Active Click context carrying the shared typed CLI state.
        fmt: Effective output format selected for the command.
    """
    if fmt != OutputFormat.TEXT:
        state: TopmarkCliState = get_cli_state(ctx)

        # ANSI color is only supported for OutputFormat.TEXT (human) output.
        color_mode: ColorMode = state.color_mode

        # Warn only when the user explicitly forced color.
        if color_mode == ColorMode.ALWAYS:
            warn_and_clear(
                ctx,
                message=(
                    f"Note: {ctx.command_path}: {CliOpt.COLOR_MODE}={color_mode.value} is ignored "
                    f"when {CliOpt.OUTPUT_FORMAT}={fmt.value}."
                ),
                obj_key=ArgKey.COLOR_ENABLED,
                cleared_value=False,
            )

        # Ensure downstream emitters do not use ANSI styling.
        state.color_enabled = False

apply_ignore_positional_paths_policy

apply_ignore_positional_paths_policy(
    ctx, *, warn_stdin_dash=True
)

Ignore positional PATHS for file-agnostic commands.

Some CLI commands do not operate on input files (for example, configuration inspection commands like topmark config check). When such commands are implemented with allow_extra_args=True / ignore_unknown_options=True, Click places unexpected positional arguments into ctx.args.

This helper applies a consistent policy:

  • If any positional arguments were provided, emit a warning that they are ignored.
  • If "-" was provided (STDIN sentinel) and warn_stdin_dash is enabled, emit a dedicated warning that STDIN is ignored.
  • Clear ctx.args so downstream logic can assume the command is file-agnostic.

Messages use Click's computed ctx.command_path (for example, "topmark config check"), which already includes the full group/subcommand path.

Parameters:

Name Type Description Default
ctx Context

Active Click context.

required
warn_stdin_dash bool

If True, emit an extra warning when "-" is present.

True

Returns:

Type Description
None

None. This helper mutates ctx.args in-place.

Source code in src/topmark/cli/validators.py
def apply_ignore_positional_paths_policy(
    ctx: click.Context,
    *,
    warn_stdin_dash: bool = True,
) -> None:
    """Ignore positional PATHS for file-agnostic commands.

    Some CLI commands do not operate on input files (for example, configuration inspection
    commands like ``topmark config check``). When such commands are implemented with
    ``allow_extra_args=True`` / ``ignore_unknown_options=True``, Click places unexpected
    positional arguments into ``ctx.args``.

    This helper applies a consistent policy:

    - If any positional arguments were provided, emit a warning that they are ignored.
    - If ``"-"`` was provided (STDIN sentinel) and ``warn_stdin_dash`` is enabled, emit a
      dedicated warning that STDIN is ignored.
    - Clear ``ctx.args`` so downstream logic can assume the command is file-agnostic.

    Messages use Click's computed `ctx.command_path` (for example, "topmark config check"), which
    already includes the full group/subcommand path.

    Args:
        ctx: Active Click context.
        warn_stdin_dash: If True, emit an extra warning when ``"-"`` is present.

    Returns:
        None. This helper mutates ``ctx.args`` in-place.
    """
    state: TopmarkCliState = get_cli_state(ctx)
    console: ConsoleProtocol = state.console
    cmd: str = ctx.command_path

    original_args: list[str] = list(ctx.args)
    if not original_args:
        return

    if warn_stdin_dash and "-" in original_args:
        console.warn(
            f"Note: {cmd} is file-agnostic; '-' (content from STDIN) is ignored.",
        )

    console.warn(
        f"Note: {cmd} is file-agnostic; positional paths are ignored.",
    )

    ctx.args = []

validate_output_verbosity_policy

validate_output_verbosity_policy(
    ctx, *, verbosity, quiet, fmt
)

Validate TEXT-only verbosity and quiet policy for the current invocation.

Parameters:

Name Type Description Default
ctx Context

Active Click context carrying the shared typed CLI state.

required
verbosity int

Raw verbosity count from --verbose/-v.

required
quiet bool

Whether --quiet was specified.

required
fmt OutputFormat

Effective output format for this invocation.

required
Behavior
  • For non-TEXT formats, --verbose and --quiet are cleared silently.
  • For TEXT output, --verbose and --quiet are mutually exclusive.
Source code in src/topmark/cli/validators.py
def validate_output_verbosity_policy(
    ctx: click.Context,
    *,
    verbosity: int,
    quiet: bool,
    fmt: OutputFormat,
) -> None:
    """Validate TEXT-only verbosity and quiet policy for the current invocation.

    Args:
        ctx: Active Click context carrying the shared typed CLI state.
        verbosity: Raw verbosity count from `--verbose/-v`.
        quiet: Whether `--quiet` was specified.
        fmt: Effective output format for this invocation.

    Behavior:
        - For non-TEXT formats, `--verbose` and `--quiet` are cleared silently.
        - For TEXT output, `--verbose` and `--quiet` are mutually exclusive.
    """
    state: TopmarkCliState = get_cli_state(ctx)

    if fmt != OutputFormat.TEXT:
        # Verbosity and quiet are TEXT-only console-output controls. For
        # Markdown and machine-readable formats, clear them silently so document and
        # structured output remain stable and renderable.
        if verbosity > 0 or quiet:
            ignored: list[str] = []
            if verbosity > 0:
                ignored.append(CliOpt.VERBOSE)
                state.verbosity = 0
            if quiet:
                ignored.append(CliOpt.QUIET)
                state.quiet = False

            logger.debug("Ignoring TEXT-only CLI options: %s", ", ".join(ignored))
        return

    validate_mutually_exclusive(
        ctx,
        flags={
            CliOpt.VERBOSE: verbosity > 0,
            CliOpt.QUIET: quiet,
        },
    )

validate_diff_policy_for_output_format

validate_diff_policy_for_output_format(ctx, *, diff, fmt)

Validate that unified diffs are only supported with human-readable output formats.

Unified diffs are a human-facing rendering feature and are not supported for machine-readable output (json/ndjson). If --diff is requested with a machine-readable format, raise a TopmarkCliUsageError.

Parameters:

Name Type Description Default
ctx Context

Active Click context carrying the shared typed CLI state.

required
diff bool

Whether the user requested unified diffs.

required
fmt OutputFormat

Effective output format for this invocation.

required
Source code in src/topmark/cli/validators.py
def validate_diff_policy_for_output_format(
    ctx: click.Context,
    *,
    diff: bool,
    fmt: OutputFormat,
) -> None:
    """Validate that unified diffs are only supported with human-readable output formats.

    Unified diffs are a human-facing rendering feature and are not supported for machine-readable
    output (`json`/`ndjson`). If `--diff` is requested with a machine-readable format, raise a
    `TopmarkCliUsageError`.

    Args:
        ctx: Active Click context carrying the shared typed CLI state.
        diff: Whether the user requested unified diffs.
        fmt: Effective output format for this invocation.
    """
    validate_machine_format_forbids_flags(
        ctx,
        fmt=fmt,
        flags={CliOpt.RENDER_DIFF: diff},
        reason="is not supported with machine-readable output formats.",
    )

validate_human_only_config_flags_for_machine_format

validate_human_only_config_flags_for_machine_format(
    ctx, *, config_root, for_pyproject, fmt
)

Validate that human-only config template flags are not used with machine-readable formats.

Some options only affect human-facing template rendering (e.g. injecting root = true or emitting a pyproject-scoped [tool.topmark] header). Machine-readable output formats (JSON/NDJSON) should remain schema-driven and are not compatible with such template-only toggles.

Parameters:

Name Type Description Default
ctx Context

Active Click context.

required
config_root bool

Whether the user requested --root.

required
for_pyproject bool

Whether the user requested --pyproject.

required
fmt OutputFormat

Effective output format for this invocation.

required
Source code in src/topmark/cli/validators.py
def validate_human_only_config_flags_for_machine_format(
    ctx: click.Context,
    *,
    config_root: bool,
    for_pyproject: bool,
    fmt: OutputFormat,
) -> None:
    """Validate that human-only config template flags are not used with machine-readable formats.

    Some options only affect human-facing template rendering (e.g. injecting `root = true`
    or emitting a pyproject-scoped `[tool.topmark]` header). Machine-readable output formats
    (JSON/NDJSON) should remain schema-driven and are not compatible with such template-only
    toggles.

    Args:
        ctx: Active Click context.
        config_root: Whether the user requested `--root`.
        for_pyproject: Whether the user requested `--pyproject`.
        fmt: Effective output format for this invocation.
    """
    validate_machine_format_forbids_flags(
        ctx,
        fmt=fmt,
        flags={
            CliOpt.CONFIG_ROOT: config_root,
            CliOpt.CONFIG_FOR_PYPROJECT: for_pyproject,
        },
        reason="are not supported with machine-readable output formats.",
    )

validate_stdin_dash_requires_piped_input

validate_stdin_dash_requires_piped_input(
    ctx, *, files_from, include_from, exclude_from
)

Fail fast if a --*-from - option is used without piped STDIN.

Parameters:

Name Type Description Default
ctx Context

Active Click context.

required
files_from list[str] | None

Parsed --files-from values (may be None).

required
include_from list[str] | None

Parsed --include-from values (may be None).

required
exclude_from list[str] | None

Parsed --exclude-from values (may be None).

required

Raises:

Type Description
TopmarkCliUsageError

if any of the --*-from options contain '-' but STDIN is a TTY.

Source code in src/topmark/cli/validators.py
def validate_stdin_dash_requires_piped_input(
    ctx: click.Context,
    *,
    files_from: list[str] | None,
    include_from: list[str] | None,
    exclude_from: list[str] | None,
) -> None:
    """Fail fast if a `--*-from -` option is used without piped STDIN.

    Args:
        ctx: Active Click context.
        files_from: Parsed `--files-from` values (may be None).
        include_from: Parsed `--include-from` values (may be None).
        exclude_from: Parsed `--exclude-from` values (may be None).

    Raises:
        TopmarkCliUsageError: if any of the `--*-from` options contain '-' but STDIN is a TTY.
    """
    uses_dash: bool = (
        ("-" in files_from if files_from else False)
        or ("-" in include_from if include_from else False)
        or ("-" in exclude_from if exclude_from else False)
    )
    if not uses_dash:
        return

    import sys

    if sys.stdin.isatty():
        cmd: str = ctx.command_path
        raise TopmarkCliUsageError(
            f"{cmd}: '-' requests patterns/paths from STDIN, but no STDIN is piped. "
            "Pipe input (e.g. `printf 'pat\\n' | ... --exclude-from -`) or use a file path."
        )