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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class MyArgs(ArgConfig):
Longer description shown in --help.
"""

name = "mytool"
tool_name = "mytool"

# Declarative option: no method needed when there's nothing to parse.
title = confargs.option(name="title", default="report", help="Report title.")
Expand Down Expand Up @@ -76,7 +76,7 @@ Highest wins: **CLI > environment variables > nearest TOML > user-directory TOML
### TOML files

Config is read from a table named after your tool. By default that is
`[tool.<name>]` (e.g. `[tool.mytool]`); override it with
`[tool.<tool_name>]` (e.g. `[tool.mytool]`); override it with
`default_config_section = "tool.custom"`. The file names searched are set with
`config_names` (default `["pyproject.toml"]`). Both `dashed-keys` and
`snake_case_keys` are accepted.
Expand Down Expand Up @@ -164,12 +164,12 @@ def log2(self, value: str = "log.html") -> str: ...
```

The generated name comes from the class `env_var_template` (default
`"{name}_{option}"`), formatted with the tool `name` and the `option` attribute
`"{name}_{option}"`), formatted with the tool name and the `option` attribute
name and upper-cased — e.g. `MYTOOL_LOG`. Override it per class:

```python
class Args(ArgConfig):
name = "mytool"
tool_name = "mytool"
env_var_template = "MYTOOL_CFG_{option}" # -> MYTOOL_CFG_LOG
```

Expand All @@ -181,7 +181,7 @@ Some tools accept a whole *command line* from an environment variable —

```python
class Args(ArgConfig):
name = "mytool"
tool_name = "mytool"
options_env_var = "MYTOOL_OPTIONS"
```

Expand Down Expand Up @@ -290,7 +290,7 @@ from confargs import ArgConfig, argument


class Runner(ArgConfig):
name = "runner"
tool_name = "runner"

# A required single positional.
suite = argument(name="suite", help="Suite file to run.")
Expand Down
2 changes: 1 addition & 1 deletion examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
class Greeter(ArgConfig):
"""greeter - a tiny self-contained CLI built with confargs."""

name = "greeter"
tool_name = "greeter"

# Declarative option: when there is nothing to parse or validate, an option
# can be declared as a plain attribute — no method needed. The value passes
Expand Down
17 changes: 9 additions & 8 deletions src/confargs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ class ArgConfig:
:func:`confargs.option`. Class attributes configure discovery and naming:

Attributes:
name: The tool name. Used for the default TOML section
(``[tool.<name>]``) and, for options declared with ``env=True``, as
the ``{name}`` part of the environment variable name.
tool_name: The tool name. Used for the default TOML section
(``[tool.<tool_name>]``) and, for options declared with
``env=True``, as the ``{name}`` part of the environment variable
name.
config_names: File names to look for when discovering TOML config,
in priority order.
default_config_section: Dotted path of the TOML table to read
(e.g. ``"tool.mytool"``). When unset, ``tool.<name>`` is used.
(e.g. ``"tool.mytool"``). When unset, ``tool.<tool_name>`` is used.
env_var_template: Template used to build the environment variable name
for options declared with ``env=True``. Formatted with ``name``
(the tool name) and ``option`` (the attribute name), then
Expand All @@ -35,7 +36,7 @@ class ArgConfig:
instead of being ignored.
"""

name: str | None = None
tool_name: str | None = None
config_names: list[str] = ["pyproject.toml"] # noqa: RUF012 - documented, per-subclass override
default_config_section: str | None = None
env_var_template: str = "{name}_{option}"
Expand Down Expand Up @@ -77,10 +78,10 @@ def config_section(self) -> tuple[str, ...]:
"""The TOML table path to read configuration from."""
if self.default_config_section:
return tuple(self.default_config_section.split("."))
base = self.name or type(self).__name__.lower()
base = self.resolved_tool_name
return ("tool", base)

@property
def tool_name(self) -> str:
def resolved_tool_name(self) -> str:
"""A non-optional tool name, falling back to the class name."""
return self.name or type(self).__name__.lower()
return self.tool_name or type(self).__name__.lower()
2 changes: 1 addition & 1 deletion src/confargs/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class MyArgs(ArgConfig):
merged into one configuration object.
"""

name = "mytool"
tool_name = "mytool"

# Declarative option (no method): a simple value that needs no custom
# parsing can be declared as a plain attribute.
Expand Down
4 changes: 2 additions & 2 deletions src/confargs/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def process(self) -> Namespace:
nearest, user = self._load_toml(cli_result.values)
env_values = collect_env_values(
self.options,
self.instance.tool_name,
self.instance.resolved_tool_name,
template=self.instance.env_var_template,
environ=self.environ,
)
Expand Down Expand Up @@ -296,7 +296,7 @@ def _load_toml(self, cli_values: Mapping[str, Any]) -> tuple[dict[str, Any], dic
project_files = find_project_config_files(self.cwd, config_names, ignore_git=ignore_git)
nearest_path, nearest = first_section_with_path(project_files, section)

user_files = find_user_config_files(self.instance.tool_name, config_names)
user_files = find_user_config_files(self.instance.resolved_tool_name, config_names)
user_path, user = first_section_with_path(user_files, section)
return (
self._apply_profiles(nearest, nearest_path, requested),
Expand Down
4 changes: 2 additions & 2 deletions tests/robot_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class RobotArgs(ArgConfig):
of the real command-line interface, rebuilt with confargs.
"""

name = "robot"
tool_name = "robot"
config_names = ["pyproject.toml", "robot.toml"] # noqa: RUF012 - per-subclass override

# --- Positional arguments ----------------------------------------------
Expand Down Expand Up @@ -80,7 +80,7 @@ def argumentfile(self, value: str | None = None) -> list[str] | None:
# Pure pass-through options are plain attributes. Optional scalars annotate
# ``str | None``; repeatable options use ``default=list`` for a clean
# ``list[str]`` with an empty-list default.
name_: str | None = option(
name: str | None = option(
name="name",
short="N",
default=None,
Expand Down
16 changes: 8 additions & 8 deletions tests/test_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def _process(cls: type[ArgConfig], argv: list[str], **kwargs: object) -> confarg
class Basic(ArgConfig):
"""A tool with positional arguments."""

name = "basic"
tool_name = "basic"

src = argument(name="src", help="Source path.")
count = argument(name="count", type=int, nargs="?", default=0, help="Optional count.")
Expand Down Expand Up @@ -48,7 +48,7 @@ def test_required_argument_missing_raises() -> None:

def test_options_and_arguments_together() -> None:
class Mixed(ArgConfig):
name = "mixed"
tool_name = "mixed"
verbose = option(name="verbose", default=False, help="Verbose.")
path = argument(name="path", help="A path.")

Expand All @@ -66,7 +66,7 @@ def test_arguments_after_double_dash() -> None:

def test_unexpected_positionals_raise() -> None:
class OneArg(ArgConfig):
name = "onearg"
tool_name = "onearg"
only = argument(name="only")

with pytest.raises(confargs.CliUsageError, match="unexpected argument"):
Expand All @@ -75,7 +75,7 @@ class OneArg(ArgConfig):

def test_plus_nargs_requires_at_least_one() -> None:
class Plus(ArgConfig):
name = "plus"
tool_name = "plus"
files = argument(name="files", nargs="+", type=str)

assert _process(Plus, ["a", "b"]).files == ["a", "b"]
Expand Down Expand Up @@ -104,15 +104,15 @@ def test_cli_positionals_override_config(tmp_path) -> None:

def test_declarative_argument_coercion() -> None:
class Nums(ArgConfig):
name = "nums"
tool_name = "nums"
numbers = argument(name="numbers", nargs="*", type=list[int])

assert _process(Nums, ["1", "2", "3"]).numbers == [1, 2, 3]


def test_method_argument_can_reject_value() -> None:
class Guard(ArgConfig):
name = "guard"
tool_name = "guard"

@argument(name="port")
def port(self, value: int) -> int:
Expand All @@ -127,7 +127,7 @@ def port(self, value: int) -> int:

def test_variadic_must_be_last() -> None:
class Bad(ArgConfig):
name = "bad"
tool_name = "bad"
many = argument(name="many", nargs="*")
tail = argument(name="tail")

Expand All @@ -142,7 +142,7 @@ def test_invalid_nargs_rejected() -> None:

def test_option_and_argument_with_same_config_name_coexist() -> None:
class Mix(ArgConfig):
name = "mix"
tool_name = "mix"
thing = option(name="thing", default="")
thing_arg = argument(name="thing") # same config name, different attribute

Expand Down
8 changes: 4 additions & 4 deletions tests/test_attr_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def _run(cls: type[ArgConfig], argv: list[str], **kw: object) -> confargs.Namesp
class Annotated(ArgConfig):
"""A tool whose declarative options carry attribute annotations."""

name = "annotated"
tool_name = "annotated"

title: str = option(name="title", default="report")
retries: int = option(name="retries", default=3)
Expand Down Expand Up @@ -52,15 +52,15 @@ def test_optional_annotation_allows_none() -> None:

def test_argument_annotation_used() -> None:
class Nums(ArgConfig):
name = "nums"
tool_name = "nums"
count: int = argument(name="count")

assert _run(Nums, ["7"]).count == 7


def test_explicit_type_overrides_annotation() -> None:
class Mixed(ArgConfig):
name = "mixed"
tool_name = "mixed"
# Annotation says str, but explicit type= wins.
value: str = option(name="value", type=int, default=0)

Expand All @@ -69,7 +69,7 @@ class Mixed(ArgConfig):

def test_annotation_absent_falls_back_to_default_inference() -> None:
class NoAnno(ArgConfig):
name = "noanno"
tool_name = "noanno"
count = option(name="count", default=3)

assert _run(NoAnno, ["--count", "9"]).count == 9
4 changes: 2 additions & 2 deletions tests/test_choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def _run(cls: type[ArgConfig], argv: list[str], **kw: object) -> confargs.Namesp
class Choices(ArgConfig):
"""A tool with Literal-constrained options."""

name = "choices"
tool_name = "choices"

console: Literal["verbose", "dotted", "quiet", "none"] = option(name="console", default="verbose")
level: Literal[1, 2, 3] = option(name="level", type=Literal[1, 2, 3], default=1)
Expand Down Expand Up @@ -67,7 +67,7 @@ def test_literal_on_method_parameter() -> None:

def test_argument_literal_validated() -> None:
class Cmd(ArgConfig):
name = "cmd"
tool_name = "cmd"
action: Literal["run", "list"] = argument(name="action")

assert _run(Cmd, ["run"]).action == "run"
Expand Down
8 changes: 4 additions & 4 deletions tests/test_default_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def _run(cls: type[ArgConfig], argv: list[str], **kw: object) -> confargs.Namesp
class Factories(ArgConfig):
"""A tool using factory defaults."""

name = "factories"
tool_name = "factories"

tags: list[str] = option(name="tags", default=list)
meta: dict = option(name="meta", type=dict, default=dict)
Expand Down Expand Up @@ -45,15 +45,15 @@ def test_factory_produces_fresh_value_each_time() -> None:

def test_lambda_factory() -> None:
class Custom(ArgConfig):
name = "custom"
tool_name = "custom"
entries: list[str] = option(name="entries", default=lambda: ["seed"])

assert _run(Custom, []).entries == ["seed"]


def test_non_callable_default_unchanged() -> None:
class Plain(ArgConfig):
name = "plain"
tool_name = "plain"
title: str = option(name="title", default="report")
count: int = option(name="count", default=3)

Expand All @@ -64,7 +64,7 @@ class Plain(ArgConfig):

def test_method_option_with_factory_default() -> None:
class Method(ArgConfig):
name = "method"
tool_name = "method"

@option(name="names")
def names(self, value: list[str] = list) -> list[str]: # type: ignore[assignment]
Expand Down
4 changes: 2 additions & 2 deletions tests/test_eager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@


class ArgFileConfig(ArgConfig):
name = "afdemo"
tool_name = "afdemo"

@option(name="argumentfile", short="A", config=False, is_eager=True)
def argumentfile(self, value: str | None = None) -> list[str] | None:
Expand Down Expand Up @@ -134,7 +134,7 @@ def test_eager_after_double_dash_is_not_expanded(tmp_path: Path) -> None:

def test_eager_option_returning_bare_string_is_rejected() -> None:
class BadConfig(ArgConfig):
name = "bad"
tool_name = "bad"

@option(is_eager=True)
def broken(self, value: str | None = None) -> str | None:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_env_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class Tool(ArgConfig):
"""A tool that reads extra options from TOOL_OPTIONS."""

name = "tool"
tool_name = "tool"
options_env_var = "TOOL_OPTIONS"

log = option(name="log", default="log.html", help="Log file.")
Expand Down Expand Up @@ -58,7 +58,7 @@ def test_env_var_absent_is_ignored() -> None:

def test_feature_disabled_by_default() -> None:
class Plain(ArgConfig):
name = "plain"
tool_name = "plain"
log = option(name="log", default="log.html")

ns = confargs.ConfigurationProcessor(Plain, argv=[], environ={"PLAIN_OPTIONS": "--log hacked.html"}).process()
Expand All @@ -70,7 +70,7 @@ def test_env_args_participate_in_eager_expansion(tmp_path) -> None:
argfile.write_text("--log from_file.html\n", encoding="utf-8")

class Eager(ArgConfig):
name = "eager"
tool_name = "eager"
options_env_var = "EAGER_OPTIONS"
log = option(name="log", default="log.html")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_env_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


class Tool(ArgConfig):
name = "mytool"
tool_name = "mytool"

@option(env="EXPLICIT_LOG")
def log(self, value: str = "log.html") -> str:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Tool(ArgConfig):
A longer description that spans the summary.
"""

name = "mytool"
tool_name = "mytool"

@option
def log(self, value: str | None = "log.html") -> str | None:
Expand Down
Loading
Loading