Skip to content

topmark.cli.io

topmark / cli / io

STDIN handling utilities for Click commands.

This module intentionally focuses only on normalizing STDIN usage for the CLI: - List mode: consume newline-delimited paths from STDIN. - Content mode: materialize STDIN bytes as a temporary file path.

Higher-level concerns like config building or file discovery are handled elsewhere.

StdinMode

Bases: str, Enum

How STDIN was interpreted by consume_stdin().

StdinResult

Bases: NamedTuple

Result of consuming STDIN for CLI commands that support it.

Attributes:

Name Type Description
mode StdinMode

How STDIN was interpreted: - "none": no STDIN was consumed (stdin is a TTY or empty) - "list": STDIN contained a list of paths (one per line) - "content": STDIN contained file content (written to a temp file)

paths list[Path]

In "list" mode, the parsed paths. In "content", a single temp path.

temp_path Path | None

The temp file created for "content" mode; otherwise None.

errors list[str]

Non-fatal errors/warnings (strings) collected during processing.

InputPlan dataclass

InputPlan(
    *,
    stdin_mode,
    stdin_filename,
    temp_path,
    paths,
    include_patterns,
    exclude_patterns,
    files_from,
    include_from,
    exclude_from,
)

Normalized inputs for building a MutableConfig and file list.

Attributes:

Name Type Description
stdin_mode bool

True when reading a single file's content from STDIN via "-".

stdin_filename str | None

The filename to use when in STDIN mode.

temp_path Path | None

Temporary file path used in content-on-STDIN mode; None otherwise.

paths list[str]

Positional PATH arguments after normalization (and/or from --files-from -).

include_patterns list[str]

Include glob patterns after merging CLI and include-from.

exclude_patterns list[str]

Exclude glob patterns after merging CLI and exclude-from.

files_from list[str]

File paths to read additional candidate paths from (no '-' sentinels).

include_from list[str]

File paths to read include patterns from (no '-' sentinels).

exclude_from list[str]

File paths to read exclude patterns from (no '-' sentinels).

consume_stdin

consume_stdin(
    *, expect="auto", stdin_filename=None, encoding="utf-8"
)

Consume STDIN if present and return a normalized result.

If stdin_filename is provided (or expect="content"), STDIN is treated as the contents of a single file which is written to a temporary path. The returned paths will contain exactly that temp path and mode == "content".

Otherwise (default), STDIN is treated as a list of paths, one per line. Empty lines and lines starting with '#' are ignored. mode == "list".

If STDIN is a TTY or empty, returns mode == "none".

Parameters:

Name Type Description Default
expect Literal['auto', 'list', 'content']

Force interpretation of STDIN ("list" or "content"), or "auto".

'auto'
stdin_filename str | None

Target filename to use when interpreting STDIN as file content. If omitted in content mode, a default name is used.

None
encoding str

Text encoding for reading and writing.

'utf-8'

Returns:

Type Description
StdinResult

A struct describing what (if anything) was consumed.

Source code in src/topmark/cli/io.py
def consume_stdin(
    *,
    expect: Literal["auto", "list", "content"] = "auto",
    stdin_filename: str | None = None,
    encoding: str = "utf-8",
) -> StdinResult:
    """Consume STDIN if present and return a normalized result.

    If `stdin_filename` is provided (or `expect="content"`), STDIN is treated
    as the contents of a single file which is written to a temporary path.
    The returned `paths` will contain exactly that temp path and `mode == "content"`.

    Otherwise (default), STDIN is treated as a list of paths, one per line.
    Empty lines and lines starting with '#' are ignored. `mode == "list"`.

    If STDIN is a TTY or empty, returns `mode == "none"`.

    Args:
        expect: Force interpretation of STDIN ("list" or "content"), or "auto".
        stdin_filename: Target filename to use when interpreting STDIN as file content. If omitted
            in content mode, a default name is used.
        encoding: Text encoding for reading and writing.

    Returns:
        A struct describing what (if anything) was consumed.
    """
    # If no data is piped, do nothing.
    if not sys.stdin or sys.stdin.isatty():
        return StdinResult(mode=StdinMode.NONE, paths=[], temp_path=None, errors=[])

    data: str = sys.stdin.read()
    if data == "":
        return StdinResult(mode=StdinMode.NONE, paths=[], temp_path=None, errors=[])

    # Decide interpretation
    force_content: bool = expect == "content" or stdin_filename is not None
    if force_content:
        # Treat as a single file's content written to a temp file.
        # We use NamedTemporaryFile(delete=False) to hand back a stable path.
        # We do not delete it here; caller cleans up if needed.
        try:
            # Create temp file with a sensible suffix/name if provided
            suffix: str = ""
            if stdin_filename and "." in stdin_filename:
                suffix = "." + stdin_filename.rsplit(".", 1)[-1]

            with tempfile.NamedTemporaryFile(
                prefix="topmark-stdin-",
                suffix=suffix,
                delete=False,
            ) as tmp:
                temp_path = Path(tmp.name)
                tmp.write(data.encode(encoding))

            return StdinResult(
                mode=StdinMode.CONTENT,
                paths=[temp_path],
                temp_path=temp_path,
                errors=[],
            )

        except OSError as exc:
            return StdinResult(
                mode=StdinMode.NONE,
                paths=[],
                temp_path=None,
                errors=[f"#ERROR: failed to create temp file for STDIN content: {exc}"],
            )

    # Default/forced list mode: parse as list of paths (one per line)
    lines: list[str] = [ln.strip() for ln in data.splitlines()]
    paths: list[Path] = [Path(ln) for ln in lines if ln and not ln.startswith("#")]
    return StdinResult(mode=StdinMode.LIST, paths=paths, temp_path=None, errors=[])

merge_cli_paths_with_stdin

merge_cli_paths_with_stdin(cli_paths, stdin_result)

Merge CLI-provided paths with STDIN, with predictable semantics.

Rules
  • mode == "none": just return CLI paths.
  • mode == "list": return CLI paths + list from STDIN (in that order).
  • mode == "content": ignore CLI paths and return the single temp file path.

This keeps command bodies small and avoids subtle drift across subcommands.

Parameters:

Name Type Description Default
cli_paths Iterable[str]

list of CLI-provided paths

required
stdin_result StdinResult

result of consuming STDIN

required

Returns:

Type Description
list[Path]

List of paths (merged with STDIN input)

Note

Do not use this to feed data for --files-from -, --include-from -, or --exclude-from -; those options expect the STDIN lines to be routed into their respective option lists instead of positional PATHS.

Source code in src/topmark/cli/io.py
def merge_cli_paths_with_stdin(
    cli_paths: Iterable[str],
    stdin_result: StdinResult,
) -> list[Path]:
    """Merge CLI-provided paths with STDIN, with predictable semantics.

    Rules:
      - mode == "none": just return CLI paths.
      - mode == "list": return CLI paths + list from STDIN (in that order).
      - mode == "content": ignore CLI paths and return the single temp file path.

    This keeps command bodies small and avoids subtle drift across subcommands.

    Args:
        cli_paths: list of CLI-provided paths
        stdin_result: result of consuming STDIN

    Returns:
        List of paths (merged with STDIN input)

    Note:
        Do not use this to feed data for --files-from -, --include-from -, or --exclude-from -;
        those options expect the STDIN lines to be routed into their respective option lists
        instead of positional PATHS.
    """
    cli: list[Path] = [Path(p) for p in cli_paths]

    if stdin_result.mode == "none":
        return cli

    if stdin_result.mode == "list":
        return [*cli, *stdin_result.paths]

    # "content" mode
    return list(stdin_result.paths)

plan_cli_inputs

plan_cli_inputs(
    *,
    ctx,
    files_from,
    include_from,
    exclude_from,
    include_patterns,
    exclude_patterns,
    stdin_filename,
    allow_empty_paths=False,
)

Normalize CLI args and STDIN into a plan, with strict guards.

Parameters:

Name Type Description Default
ctx Context

Click context.

required
files_from Iterable[str]

Iterable of files to read candidate paths from (may include '-').

required
include_from Iterable[str]

Iterable of files to read include patterns from (may include '-').

required
exclude_from Iterable[str]

Iterable of files to read exclude patterns from (may include '-').

required
include_patterns Iterable[str]

Iterable of include glob patterns.

required
exclude_patterns Iterable[str]

Iterable of exclude glob patterns.

required
stdin_filename str | None

Optional assumed filename when reading content from STDIN.

required
allow_empty_paths bool

If True, do not raise an error if no paths are provided. (Used for commands like dump-config that are file-agnostic.)

False

Raises:

Type Description
TopmarkCliUsageError

If - mixing content '-' with any ...-from '-' option. - using '-' as a PATH without --stdin-filename. - no input is provided (unless allow_empty_paths is True).

Returns:

Type Description
InputPlan

The normalized input plan for config and file discovery.

Source code in src/topmark/cli/io.py
def plan_cli_inputs(
    *,
    ctx: click.Context,
    files_from: Iterable[str],
    include_from: Iterable[str],
    exclude_from: Iterable[str],
    include_patterns: Iterable[str],
    exclude_patterns: Iterable[str],
    stdin_filename: str | None,
    allow_empty_paths: bool = False,
) -> InputPlan:
    """Normalize CLI args and STDIN into a plan, with strict guards.

    Args:
        ctx: Click context.
        files_from: Iterable of files to read candidate paths from (may include '-').
        include_from: Iterable of files to read include patterns from (may include '-').
        exclude_from: Iterable of files to read exclude patterns from (may include '-').
        include_patterns: Iterable of include glob patterns.
        exclude_patterns: Iterable of exclude glob patterns.
        stdin_filename: Optional assumed filename when reading content from STDIN.
        allow_empty_paths: If True, do not raise an error if no paths are provided.
            (Used for commands like dump-config that are file-agnostic.)

    Raises:
        TopmarkCliUsageError: If
            - mixing content '-' with any ...-from '-' option.
            - using '-' as a PATH without --stdin-filename.
            - no input is provided (unless allow_empty_paths is True).

    Returns:
        The normalized input plan for config and file discovery.
    """
    raw_args: list[str] = list(ctx.args)

    # detect content mode
    stdin_mode: bool = raw_args == ["-"]

    # Route list-on-STDIN to one of the ...-from options.
    from_stdin: FromOptionStdinText = extract_stdin_for_from_options(
        files_from,
        include_from,
        exclude_from,
    )
    files_from_text: str | None = from_stdin.files_from
    include_from_text: str | None = from_stdin.include_from
    exclude_from_text: str | None = from_stdin.exclude_from

    # forbid mixing content mode with ...-from - usage
    if stdin_mode and any(
        t is not None for t in (files_from_text, include_from_text, exclude_from_text)
    ):
        raise TopmarkCliUsageError(
            "Cannot combine '-' (content on STDIN) with "
            f"{CliOpt.FILES_FROM} - / {CliOpt.INCLUDE_FROM} - / {CliOpt.EXCLUDE_FROM} -."
        )

    paths: list[str] = []
    inc: list[str] = list(include_patterns)
    exc: list[str] = list(exclude_patterns)
    temp_path: Path | None = None

    if stdin_mode:
        if not stdin_filename:
            raise TopmarkCliUsageError(
                f"{CliOpt.STDIN_FILENAME} is required when using '-' to read from STDIN."
            )
        res: StdinResult = consume_stdin(expect="content", stdin_filename=stdin_filename)
        if res.mode != "content" or not res.paths:
            raise TopmarkCliUsageError("No data received on STDIN while '-' was specified.")
        temp_path = res.paths[0]
        paths = [str(temp_path)]
    else:
        # ...-from routing (list mode)
        if files_from_text is not None:
            paths.extend(split_nonempty_lines(files_from_text))

        if raw_args:
            if "-" in raw_args:
                raise TopmarkCliUsageError(
                    "'-' is only valid as the sole PATH to read content from STDIN. "
                    f"Use {CliOpt.FILES_FROM} - to read a list of paths from STDIN."
                )
            paths.extend(raw_args)

        if include_from_text is not None:
            inc += split_nonempty_lines(include_from_text)
        if exclude_from_text is not None:
            exc += split_nonempty_lines(exclude_from_text)

    # Remove STDIN sentinels from --*-from option values after mode-specific
    # routing has consumed them.
    from_values: FromOptionValues = strip_dash_sentinels(
        files_from,
        include_from,
        exclude_from,
    )
    files_from = from_values.files_from
    include_from = from_values.include_from
    exclude_from = from_values.exclude_from

    if not paths and not allow_empty_paths:
        raise TopmarkCliUsageError(f"No arguments provided. Try 'topmark {ctx.command.name} FILE'")

    return InputPlan(
        stdin_mode=stdin_mode,
        stdin_filename=stdin_filename,
        temp_path=temp_path,
        paths=paths,
        include_patterns=inc,
        exclude_patterns=exc,
        files_from=list(files_from),
        include_from=list(include_from),
        exclude_from=list(exclude_from),
    )