Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <bool>` 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
Expand Down
117 changes: 109 additions & 8 deletions src/confargs/argfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <bool>`` 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."""
Expand All @@ -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()
Expand All @@ -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
7 changes: 6 additions & 1 deletion src/confargs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/test_abbrev_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions tests/test_eager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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={})
Loading