From 39ea423df5f180b9701c03edb7d5a4309c757885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Hirsz?= Date: Mon, 31 Aug 2026 18:01:24 +0200 Subject: [PATCH] feat: add argument-file BOM handling and expandvars pragma Read argument files as utf-8-sig so a leading UTF-8 BOM is ignored, and support an optional first-line `# expandvars: ` pragma that expands `\`/`\`/`\` references from the environment (with `\$\$` as a literal `\$`) before splitting the file into tokens. Failed expansion raises CliUsageError with a Robot Framework-style message. Also let the joined negation form (`--nostatusrc`) resolve through unambiguous-prefix abbreviation when `cli_allow_abbrev` and `cli_ignore_hyphens` are enabled, matching the separator form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 +++ src/confargs/argfile.py | 117 ++++++++++++++++++++++++++++++++++--- src/confargs/cli.py | 7 ++- tests/test_abbrev_names.py | 15 +++++ tests/test_eager.py | 61 +++++++++++++++++++ 5 files changed, 200 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6d8c67d..bb3d533 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,15 @@ class Args(ArgConfig): `ConfigurationProcessor(Args, argv=[...])` accepts an explicit argument list; when omitted it falls back to `sys.argv[1:]`. +Argument files are read as `utf-8-sig`, so a leading UTF-8 BOM is ignored. Each +line is stripped; blank lines and `#` comments are skipped; an option line is +split into a name and value on the first space or `=`. When the first line is a +truthy `# expandvars: ` pragma, the whole file is expanded first — +`$NAME`, `${NAME}` and `${NAME=default}` pull from the environment (pass a +custom `environ=` mapping to `read_argument_file`/`split_argument_file` to +override), `$$` is a literal `$`, and an unset variable without a default (or a +malformed reference) raises `CliUsageError`. + ## Positional arguments Options are addressed by name; **arguments** are positional — filled from the diff --git a/src/confargs/argfile.py b/src/confargs/argfile.py index c076343..37cde69 100644 --- a/src/confargs/argfile.py +++ b/src/confargs/argfile.py @@ -4,23 +4,49 @@ eager option (see :func:`confargs.option`) can expand a file into extra CLI tokens: +* a leading UTF-8 BOM is ignored (files are read as ``utf-8-sig``), +* an optional ``# expandvars: `` pragma on the first line enables + environment-variable expansion of the whole file (see below), * each line is stripped of surrounding whitespace, * blank lines and ``#`` comment lines are ignored, * a line starting with ``-`` is an option; it is split into a name and value on the first space or ``=`` (whichever comes first), and * any other non-empty line is passed through as a positional token. +**Variable expansion.** When the first line is a truthy ``# expandvars:`` pragma +(e.g. ``# expandvars: true``), the file contents are expanded *before* being +split into lines, using these rules (matching Robot Framework): + +* ``$NAME`` and ``${NAME}`` are replaced with the environment variable ``NAME``, +* ``${NAME=default}`` uses ``default`` when ``NAME`` is unset, +* ``$$`` is an escaped literal ``$``, +* a reference to an unset variable without a default raises an error, and +* a malformed reference (e.g. ``$1abc``) raises an error. + Only the parsing is provided here; the decision to inject the resulting tokens is made by the eager option's method, which returns them to the processor. """ from __future__ import annotations +import os +import re +from collections.abc import Mapping +from string import Template from typing import TYPE_CHECKING +from confargs.exceptions import CliUsageError + if TYPE_CHECKING: + from collections.abc import Iterator from pathlib import Path +# Strings considered "false" for the ``# expandvars:`` pragma (case-insensitive), +# matching Robot Framework's ``is_truthy`` semantics. +_FALSE_VALUES = frozenset({"FALSE", "NO", "OFF", "0", "NONE", ""}) + +_EXPANDVARS_PRAGMA = re.compile(r"#\s*expandvars:\s*(.*)\s*\n", flags=re.IGNORECASE) + def _option_separator(line: str) -> str | None: """Return the separator (space or ``=``) between an option and its value.""" @@ -43,15 +69,54 @@ def _split_option(line: str) -> list[str]: return [name, value] -def split_argument_file(text: str) -> list[str]: - """Tokenize the *contents* of an argument file into a list of argv tokens. +class _TemplateWithDefaults(Template): + """``string.Template`` that also accepts ``${NAME=default}`` references.""" - Args: - text: The full text of an argument file. + braceidpattern = r"(?a:[_a-z][_a-z0-9]*(=[^}]*)?)" - Returns: - The tokens to splice into ``argv``. + +class _EnvWithDefaults(Mapping[str, str]): + """Mapping wrapper resolving ``NAME=default`` keys against ``environ``.""" + + def __init__(self, environ: Mapping[str, str]) -> None: + self._environ = environ + + def __getitem__(self, key: str) -> str: + if "=" in key: + name, default = key.split("=", 1) + return self._environ.get(name, default) + return self._environ[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._environ) + + def __len__(self) -> int: + return len(self._environ) + + +def _expand_variables(text: str, environ: Mapping[str, str]) -> str: + """Expand ``$NAME`` / ``${NAME}`` / ``${NAME=default}`` references in ``text``. + + Raises: + ValueError: If a referenced variable is unset (and has no default) or a + reference is malformed. """ + try: + return _TemplateWithDefaults(text).substitute(_EnvWithDefaults(environ)) + except KeyError as err: + raise ValueError(f"Variable '{err.args[0]}' does not exist.") from err + + +def _expandvars_enabled(text: str) -> bool: + match = _EXPANDVARS_PRAGMA.match(text) + if match is None: + return False + return match.group(1).strip().upper() not in _FALSE_VALUES + + +def _tokenize(text: str, environ: Mapping[str, str]) -> list[str]: + if _expandvars_enabled(text): + text = _expand_variables(text, environ) tokens: list[str] = [] for raw_line in text.splitlines(): line = raw_line.strip() @@ -62,16 +127,52 @@ def split_argument_file(text: str) -> list[str]: return tokens -def read_argument_file(path: str | Path, *, encoding: str = "utf-8") -> list[str]: +def split_argument_file(text: str, *, environ: Mapping[str, str] | None = None) -> list[str]: + """Tokenize the *contents* of an argument file into a list of argv tokens. + + Args: + text: The full text of an argument file. + environ: Environment mapping used when the ``# expandvars:`` pragma is + enabled. Defaults to :data:`os.environ`. + + Returns: + The tokens to splice into ``argv``. + + Raises: + CliUsageError: If variable expansion fails (unset or malformed variable). + """ + try: + return _tokenize(text, os.environ if environ is None else environ) + except ValueError as err: + raise CliUsageError(f"Processing argument file failed: {err}") from err + + +def read_argument_file( + path: str | Path, + *, + encoding: str = "utf-8-sig", + environ: Mapping[str, str] | None = None, +) -> list[str]: """Read an argument file from disk and tokenize it. + A leading UTF-8 BOM is ignored (the default ``utf-8-sig`` encoding strips it). + Args: path: Path to the argument file. encoding: Text encoding used to read the file. + environ: Environment mapping used when the ``# expandvars:`` pragma is + enabled. Defaults to :data:`os.environ`. Returns: The tokens to splice into ``argv``. + + Raises: + CliUsageError: If variable expansion fails (unset or malformed variable). """ from pathlib import Path as _Path - return split_argument_file(_Path(path).read_text(encoding=encoding)) + text = _Path(path).read_text(encoding=encoding) + try: + return _tokenize(text, os.environ if environ is None else environ) + except ValueError as err: + raise CliUsageError(f"Processing argument file '{path}' failed: {err}") from err diff --git a/src/confargs/cli.py b/src/confargs/cli.py index 59a0750..00eaec4 100644 --- a/src/confargs/cli.py +++ b/src/confargs/cli.py @@ -68,7 +68,12 @@ def _negated_flag_attr(name: str, table: NameTable, flags: set[str]) -> str | No if table.ignore_hyphens: normalized = table.normalize_bare(name[2:]) if normalized.startswith("no"): - attr = table.long_normalized_to_attr.get(normalized[2:]) + base = normalized[2:] + attr = table.long_normalized_to_attr.get(base) + if attr is None and table.allow_abbrev: + candidates = table._abbrev_attrs(base) + if len(candidates) == 1: + attr = next(iter(candidates)) if attr is not None and attr in flags: return attr return None diff --git a/tests/test_abbrev_names.py b/tests/test_abbrev_names.py index a1dff3f..dd5666c 100644 --- a/tests/test_abbrev_names.py +++ b/tests/test_abbrev_names.py @@ -60,6 +60,21 @@ def test_prefix_flag_and_negation() -> None: assert run_cli(Tool, ["--no-dry"]).values == {"dryrun": False} +def test_joined_negation_abbreviates_with_ignore_hyphens() -> None: + class Runner(ArgConfig): + cli_allow_abbrev = True + cli_case_insensitive = True + cli_ignore_hyphens = True + statusrc: bool = option(name="statusrc", default=True) + + # ``--nostatusrc`` is the joined negation; abbreviated + case-insensitive + # forms must reach the same flag (RF uses ``--NoStatus`` / ``--NoStatusRC``). + assert run_cli(Runner, ["--nostatusrc"]).values == {"statusrc": False} + assert run_cli(Runner, ["--nostatus"]).values == {"statusrc": False} + assert run_cli(Runner, ["--NoStatus"]).values == {"statusrc": False} + assert run_cli(Runner, ["--no-status"]).values == {"statusrc": False} + + def test_ambiguous_prefix_raises() -> None: # ``--re`` is a prefix of both ``--removekeywords`` and ``--reportbackground``. with pytest.raises(CliUsageError) as exc: diff --git a/tests/test_eager.py b/tests/test_eager.py index 8789952..978af75 100644 --- a/tests/test_eager.py +++ b/tests/test_eager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import Path import pytest @@ -142,3 +143,63 @@ def broken(self, value: str | None = None) -> str | None: with pytest.raises(OptionDefinitionError, match="bare string"): ConfigurationProcessor(BadConfig, argv=["--broken", "x"]).process() + + +def test_read_argument_file_strips_utf8_bom(tmp_path: Path) -> None: + af = tmp_path / "bom.txt" + af.write_text("\ufeff--name-opt fromfile\n--verbose\n", encoding="utf-8") + assert read_argument_file(af) == ["--name-opt", "fromfile", "--verbose"] + + +def test_expandvars_pragma_expands_from_environ() -> None: + text = "# expandvars: true\n--name-opt ${WHO}\n" + assert split_argument_file(text, environ={"WHO": "world"}) == ["--name-opt", "world"] + + +def test_expandvars_pragma_dollar_name_form() -> None: + text = "# expandvars: yes\n--name-opt $WHO\n" + assert split_argument_file(text, environ={"WHO": "world"}) == ["--name-opt", "world"] + + +def test_expandvars_pragma_default_when_unset() -> None: + text = "# expandvars: true\n--name-opt ${WHO=fallback}\n" + assert split_argument_file(text, environ={}) == ["--name-opt", "fallback"] + + +def test_expandvars_pragma_expands_whole_line() -> None: + text = "# expandvars: true\n${LINE=--name-opt whole}\n" + assert split_argument_file(text, environ={}) == ["--name-opt", "whole"] + + +def test_expandvars_pragma_escaped_dollar() -> None: + text = "# expandvars: true\n--name-opt $$WHO\n" + assert split_argument_file(text, environ={"WHO": "world"}) == ["--name-opt", "$WHO"] + + +def test_expandvars_pragma_disabled_leaves_text() -> None: + text = "# expandvars: false\n--name-opt ${WHO}\n" + assert split_argument_file(text, environ={"WHO": "world"}) == ["--name-opt", "${WHO}"] + + +def test_expandvars_no_pragma_leaves_text() -> None: + text = "--name-opt ${WHO}\n" + assert split_argument_file(text, environ={"WHO": "world"}) == ["--name-opt", "${WHO}"] + + +def test_expandvars_unset_variable_errors() -> None: + text = "# expandvars: true\n--name-opt ${MISSING}\n" + with pytest.raises(CliUsageError, match=r"Variable 'MISSING' does not exist\."): + split_argument_file(text, environ={}) + + +def test_expandvars_malformed_reference_errors() -> None: + text = "# expandvars: true\n--name-opt $1bad\n" + with pytest.raises(CliUsageError, match="Processing argument file failed"): + split_argument_file(text, environ={}) + + +def test_read_argument_file_expandvars_error_includes_path(tmp_path: Path) -> None: + af = tmp_path / "args.txt" + af.write_text("# expandvars: true\n--name-opt ${MISSING}\n", encoding="utf-8") + with pytest.raises(CliUsageError, match=re.escape(f"Processing argument file '{af}' failed")): + read_argument_file(af, environ={})