Source code for argclass.emit

"""Config-file generators: render an argclass Parser to INI/JSON/TOML/.env.

Symmetric counterpart to ``argclass/defaults.py`` (which READS configs).
The parser tree — root arguments, groups and subparsers — is walked
once, yielding :class:`ConfigField` records; generators consume that
iterator and produce a format-specific string. A subparser becomes a
section named after its attribute, so one generated file describes
every subcommand and loads back through the same parser.

Subclass :class:`ConfigGenerator` and override :meth:`render` to add a
new format. The walking + Action wiring are shared — your subclass only
decides how to turn a stream of fields into text.

Usage::

    import argclass
    from argclass.emit import GenerateConfigAction, INIConfigGenerator

    class CLI(argclass.Parser):
        host: str = "localhost"
        generate_config = argclass.Argument(
            action=GenerateConfigAction,
            generator=INIConfigGenerator,
            metavar="FILE",
        )

The attribute name auto-derives the CLI flag, so end users run
``myapp --generate-config /etc/myapp.ini`` to write the file, or
``myapp --generate-config -`` to print to stdout.

Template output: for the comment-aware formats (INI/TOML/.env) the
generated file reads like a hand-written template. Any field still at
its declared default is written commented-out (so the defaults are
visible for reference) while values you actually overrode via CLI /
env / config stay active. Help text becomes comment lines above each
key, an argument with ``choices`` (``Literal``, ``EnumArgument``)
gets a ``choices: a, b, c`` comment line, and a blank line separates
entries so a value never runs into the next key's comment. Pass
``comment_defaults=False`` to the generator to emit every field
active instead (a full snapshot).

Security note: secret values are emitted as-is by default. Pass
``mask_secrets=True`` to the generator (or to its
``GenerateConfigAction`` wrapping via an instance) to replace
``Secret()`` field values with :attr:`SecretString.PLACEHOLDER`,
so a generated file can be shared as a template without leaking
credentials. Treat any unmasked generated file like a
credential-bearing file.
"""

import argparse
import json
import os
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path, PurePath
from typing import (
    Any,
    IO,
    cast,
)
from collections.abc import Iterable, Iterator, Sequence

from .parser import get_argclass_parser
from .secret import SecretString
from .store import AbstractGroup, AbstractParser, TypedArgument
from .types import Actions
from .utils import child_env_prefix, coerce_env_default


[docs] class NonConfigAction(argparse.Action): """Base class for argparse Actions that should NOT appear in config dumps produced by :class:`ConfigGenerator` subclasses. Use this as a base for any "fire and exit" style action — like ``--version``, ``--check-updates``, or ``--generate-config`` itself. ``ConfigGenerator`` checks ``__emit_config__`` on the action class; when it is ``False``, the argument is skipped. Stateful custom actions don't need to inherit anything — they're included by default. Only inherit from ``NonConfigAction`` (or set ``__emit_config__ = False`` manually) for actions whose presence in a dumped config makes no sense. """ __emit_config__ = False
def should_emit(argument: TypedArgument) -> bool: """True if this argument should appear in a generated config. Action classes opt out via ``__emit_config__ = False`` (e.g. by inheriting :class:`NonConfigAction`). argparse's built-in ``--help`` / ``--version`` actions are recognised by enum value since we cannot annotate argparse internals. """ action = argument.action if isinstance(action, type): return bool(getattr(action, "__emit_config__", True)) if action in (Actions.VERSION, Actions.HELP): return False return True def current_value( target: Any, name: str, argument: TypedArgument, *, namespace: argparse.Namespace | None = None, dest: str | None = None, env_var: str | None = None, owner: AbstractParser | None = None, section: str | None = None, ) -> Any: """Read the current value for ``name`` on a Parser/Group instance. Priority (highest first): 1. An argparse ``Namespace`` under ``dest`` — when provided, it represents the active parse and wins over stale instance state. Used by :class:`GenerateConfigAction` so a reused parser doesn't dump values from an earlier ``parse_args`` call. 2. The instance ``__dict__`` (set when an earlier ``parse_args`` completed, or when the field is a Group whose attributes argclass populated after parsing). 3. ``os.environ[env_var]`` — covers env vars when the dump runs before argclass has applied them to ``__dict__``. 4. The config files of ``owner`` (the parser that owns ``target``) under ``section`` — covers a parser that was not parsed yet and a subparser that was not selected, because argclass applies config values only to the parsed branch. 5. The argument's declared default. Env values arrive as strings; we apply ``argument.type`` when it is callable, so the dump reflects the same type argclass would bind at parse time. Config values are converted the same way. """ if namespace is not None and dest is not None and hasattr(namespace, dest): value = getattr(namespace, dest) if value is not None: return value if name in target.__dict__: return target.__dict__[name] if env_var: raw = os.environ.get(env_var) if raw is not None: return coerce_env_default(raw, argument) if owner is not None: value = getattr(owner, "_config_value")(name, argument, section=section) if value is not None: return value return argument.default def derive_env_var( auto_prefix: str | None, dest: str, argument: TypedArgument, ) -> str | None: """Compute the env-var name argclass would read for ``dest``. Mirrors :meth:`argclass.Parser.get_env_var`. Returns ``None`` when neither an explicit ``env_var`` on the argument nor an ``auto_env_var_prefix`` on the parser (own or inherited from the parent parser) supplies one. """ if argument.env_var is not None: return argument.env_var if auto_prefix is not None: return f"{auto_prefix}{dest}".upper() return None def group_cli_segment(group: AbstractGroup, attr_name: str) -> str: """Return the CLI/env path segment for a group attribute, honoring the group's ``prefix=`` override.""" prefix = getattr(group, "_prefix", None) return prefix if prefix is not None else attr_name def escape_inline_string(value: str) -> str: """Escape a string for embedding inside double-quoted literals. Used by both TOML (always quoted) and the ``.env`` emitter (quoted on demand) so multi-line / tab / quote-bearing values stay on a single line and survive a shell parser. """ return ( value.replace("\\", "\\\\") .replace('"', '\\"') .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") ) def normalize_value(value: Any) -> Any: """Convert non-config-native types to round-trippable forms. - ``Enum`` / ``IntEnum`` → ``.name`` (matches ``EnumArgument`` which accepts member names). - ``set`` / ``frozenset`` → ``list`` (sorted when comparable, so output stays stable). - ``Path`` → ``str``. Already-native types (``str``/``int``/``float``/``bool``/``list``/ ``tuple``/``None``) pass through. ``SecretString`` passes through too since it subclasses ``str``. """ if isinstance(value, Enum): return value.name if isinstance(value, (set, frozenset)): try: return sorted(value) except TypeError: return list(value) if isinstance(value, PurePath): return str(value) return value
[docs] @dataclass(frozen=True) class ConfigField: """A single leaf argument from the parser tree, ready to emit. A generator iterates these and writes them in the target format. Sections are derived from ``attr_path[:-1]``; the field key is ``attr_path[-1]``. Attributes ---------- attr_path: Tuple of attribute names from the parser root down to the leaf (``("endpoint", "credentials", "username")``). The last element is the field name; everything before it forms the section path used by INI / TOML. A subparser contributes its attribute name as one segment, exactly like a group. cli_path: Same shape as ``attr_path`` but respecting per-group ``prefix=`` overrides — useful when reconstructing CLI flag names. A subparser segment is the subcommand name. subparser_path: Attribute names of the subparsers that enclose the field, root first (``("serve", "worker")``). Empty for a field that belongs to the root parser. dest: argparse ``dest`` for the field (``"endpoint_credentials_username"``). Joins the part of ``cli_path`` below the owning subparser with underscores: a subparser is a separate ``ArgumentParser``, so its ``dest`` values do not carry the subcommand name. argument: The owning :class:`TypedArgument`. Carries declared type, help, env_var, etc. target: The Parser or Group instance that owns this attribute. Lets renderers reach back into the source if needed. value: The resolved value, already :func:`normalize_value`-d so it round-trips through every supported format. env_var: Env var name argclass would read (explicit ``env_var=`` or derived from ``auto_env_var_prefix=``). ``None`` when no env var is configured. help: Help text declared on the argument, or ``None``. choices: Accepted values, or ``None`` when the argument takes any value. Filled from ``Argument(choices=...)``, a ``Literal`` annotation, or the member names of an ``EnumArgument`` (lowercase when it was declared with ``lowercase=True``). Each item is :func:`normalize_value`-d. Comment-aware generators list them above the key. is_default: ``True`` when the resolved value is still the argument's declared default (nothing overrode it via CLI / env / config). Generators use this to emit the line commented-out, turning a dump into a human-friendly template of "here are the defaults, uncomment to change". ``False`` for arguments with no default and for any value that was explicitly overridden. """ attr_path: tuple[str, ...] cli_path: tuple[str, ...] dest: str argument: TypedArgument target: Any value: Any env_var: str | None help: str | None is_default: bool = False subparser_path: tuple[str, ...] = () choices: tuple[Any, ...] | None = None @property def section_path(self) -> tuple[str, ...]: """Path to the enclosing section, derived from ``attr_path``.""" return self.attr_path[:-1] @property def key(self) -> str: """Bare leaf attribute name.""" return self.attr_path[-1]
def namespace_targets( owner: AbstractParser, namespace: argparse.Namespace | None, ) -> frozenset[int] | None: """Return ``id()`` of every parser whose ``dest`` values ``namespace`` holds, or ``None`` when there is no namespace. argparse gives each subcommand a fresh namespace and copies it into the parent namespace only after the subcommand finished. So a namespace seen by an Action carries the values of the parser that owns the Action (``owner``) and of the selected subparsers below it; ``namespace.current_subparsers`` lists the selected chain deepest first. Ancestors of ``owner`` and unselected branches must not read from it: a same-named ``dest`` in another branch would leak. """ if namespace is None: return None ids = {id(owner)} for node in getattr(namespace, "current_subparsers", None) or (): if node is owner: break ids.add(id(node)) return frozenset(ids) def iter_config_fields( parser: AbstractParser, *, namespace: argparse.Namespace | None = None, namespace_owner: AbstractParser | None = None, mask_secrets: bool = False, include_subparsers: bool = True, ) -> Iterator[ConfigField]: """Walk ``parser`` and yield one :class:`ConfigField` per leaf. Order: the parser's own arguments, its groups (recursively), then its subparsers (recursively) in declaration order. A subparser becomes a section named after its attribute, the same rule the config readers apply. Pass ``include_subparsers=False`` to stop at the root parser and its groups. Non-emittable arguments (``--help``, ``--version``, any :class:`NonConfigAction` subclass) are filtered out by :func:`should_emit`. ``namespace``, when provided, lets fields pick up CLI args that argparse has already parsed — used by :class:`GenerateConfigAction` mid-parse. ``namespace_owner`` is the parser whose Action received the namespace (default: ``parser``); only that parser and the selected subparsers below it read from the namespace, see :func:`namespace_targets`. When ``mask_secrets`` is true, any field whose argument was declared via :func:`argclass.Secret` (or carries ``secret=True``) gets its value replaced with :attr:`SecretString.PLACEHOLDER` so the dump can be shared as a template without leaking credentials. """ auto_prefix = getattr(parser, "_auto_env_var_prefix", None) targets = namespace_targets(namespace_owner or parser, namespace) yield from iter_subtree_fields( parser, attr_path=(), cli_path=(), auto_prefix=auto_prefix, namespace=namespace, namespace_targets=targets, mask_secrets=mask_secrets, include_subparsers=include_subparsers, ) def subparser_env_prefix( subparser: Any, parent_prefix: str | None, name: str ) -> str | None: """Return the auto env-var prefix a subparser uses in the walk: its own ``auto_env_var_prefix`` when set, else the parent's prefix extended with the subparser attribute name.""" own = getattr(subparser, "_auto_env_var_prefix", None) if own is not None: return str(own) return child_env_prefix(parent_prefix, name) def iter_subtree_fields( target: Any, *, attr_path: tuple[str, ...] = (), cli_path: tuple[str, ...] = (), subparser_path: tuple[str, ...] = (), dest_path: tuple[str, ...] | None = None, owner: AbstractParser | None = None, auto_prefix: str | None = None, namespace: argparse.Namespace | None = None, namespace_targets: frozenset[int] | None = None, mask_secrets: bool = False, include_subparsers: bool = True, ) -> Iterator[ConfigField]: """Recursive walker used by :func:`iter_config_fields`. Power-users can call this directly to walk a sub-tree (for example to dump just one nested group). Pass the cumulative ``attr_path`` and ``cli_path`` you want the yielded fields to carry. ``dest_path`` is the part of ``cli_path`` below the owning subparser; it forms the argparse ``dest``. It defaults to ``cli_path`` and is reset when the walk enters a subparser. ``owner`` is the parser that owns ``target`` (``target`` itself for a parser node); it supplies config-file values for fields that no parse has bound yet. ``namespace_targets`` limits which parsers read from ``namespace`` (see :func:`namespace_targets`); ``None`` lets every node read it. ``mask_secrets`` and ``include_subparsers`` mirror :func:`iter_config_fields` — see its docstring for the semantics. """ node = cast(Any, target) if dest_path is None: dest_path = cli_path if owner is None and isinstance(node, AbstractParser): owner = node # Config section of this node relative to its owning parser. section = ".".join(attr_path[len(subparser_path) :]) or None # Groups share the namespace of their parser, so only a parser # node can lose it. The full namespace still travels down to the # subparsers, where each one is checked again. own_namespace = namespace if ( namespace_targets is not None and isinstance(node, AbstractParser) and id(node) not in namespace_targets ): own_namespace = None cli_prefix = "_".join(dest_path) for name, argument in node.__arguments__.items(): if not should_emit(argument): continue dest = f"{cli_prefix}_{name}" if cli_prefix else name env_var = derive_env_var(auto_prefix, dest, argument) raw = current_value( target, name, argument, namespace=own_namespace, dest=dest, env_var=env_var, owner=owner, section=section, ) value = normalize_value(raw) is_default = argument.has_default and value == normalize_value( argument.default, ) if mask_secrets and argument.secret and value is not None: value = SecretString.PLACEHOLDER is_default = False # placeholder isn't the default choices = ( tuple(normalize_value(c) for c in argument.choices) if argument.choices else None ) if ( choices and isinstance(raw, Enum) and value not in choices and str(value).lower() in choices ): # EnumArgument(lowercase=True) lists lowercase names; write # the value in the same spelling as the listed choices. value = str(value).lower() yield ConfigField( attr_path=attr_path + (name,), cli_path=cli_path + (name,), dest=dest, argument=argument, target=target, value=value, env_var=env_var, help=argument.help if argument.help else None, is_default=is_default, subparser_path=subparser_path, choices=choices or None, ) for group_name, group in node.__argument_groups__.items(): seg = group_cli_segment(group, group_name) child_seg = (seg,) if seg else () yield from iter_subtree_fields( group, attr_path=attr_path + (group_name,), cli_path=cli_path + child_seg, subparser_path=subparser_path, dest_path=dest_path + child_seg, owner=owner, auto_prefix=auto_prefix, namespace=own_namespace, namespace_targets=namespace_targets, mask_secrets=mask_secrets, include_subparsers=include_subparsers, ) if not include_subparsers: return for sub_name, subparser in getattr(node, "__subparsers__", {}).items(): # A subparser is a separate ArgumentParser: its dests start # over. Only a selected subparser reads the namespace; an # unselected branch falls back to instance state, env vars # and declared defaults. yield from iter_subtree_fields( subparser, attr_path=attr_path + (sub_name,), cli_path=cli_path + (sub_name,), subparser_path=subparser_path + (sub_name,), dest_path=(), owner=subparser, auto_prefix=subparser_env_prefix(subparser, auto_prefix, sub_name), namespace=namespace, namespace_targets=namespace_targets, mask_secrets=mask_secrets, include_subparsers=True, ) def fields_to_nested_dict( fields: Iterable[ConfigField], *, skip_none: bool = False, ) -> dict[str, Any]: """Build a nested dict from a stream of :class:`ConfigField`. Used by :class:`JSONConfigGenerator`. Each section path becomes a nested dict layer; the leaf attribute is set to ``field.value``. When ``skip_none`` is true, ``None`` values are omitted entirely so reloading falls back to the argument's own default. """ out: dict[str, Any] = {} for field in fields: if skip_none and field.value is None: continue target = out for segment in field.section_path: sub = target.get(segment) if not isinstance(sub, dict): sub = {} target[segment] = sub target = sub target[field.key] = field.value return out
[docs] class ConfigGenerator: """Walks an argclass Parser and renders its state to a config string. Subclasses override :meth:`render`. The base :meth:`dump_to_string` and :meth:`dump` methods take care of walking the parser and writing to disk / stdout / a file object. Parameters ---------- mask_secrets: When true, fields declared via :func:`argclass.Secret` (or otherwise carrying ``secret=True``) have their values replaced with :attr:`SecretString.PLACEHOLDER`. Use this to emit a credential-free template config; the resulting file is safe to commit / share. Default is ``False`` — the generator reproduces real credential values exactly so the file round-trips back into a working parser. comment_defaults: When true (the default), a field still sitting at its declared default is emitted commented-out — the generated file reads like a hand-written template where the defaults are shown for reference and only the values you actually changed are active. Overridden values (from CLI / env / config) and arguments without a default stay active. Set to ``False`` to emit every field active, reproducing the old behaviour where the dump is a full snapshot. Formats that cannot carry comments (JSON) ignore this flag. include_subparsers: When true (the default), every subparser is walked and its arguments land in a section named after the subparser attribute (``[serve]``, ``[serve.db]``), so one file covers the whole command tree and loads back through the same parser. Set to ``False`` to dump only the root parser and its groups. """ #: File extension hint. Subclasses set this. extension: str = "" #: Marker for help prose; INI overrides to ``;``. comment_marker: str = "#" #: Marker for a commented-out default; INI keeps ``#`` so a disabled #: value reads apart from its ``;`` help. default_comment_marker: str = "#"
[docs] def __init__( self, *, mask_secrets: bool = False, comment_defaults: bool = True, include_subparsers: bool = True, ) -> None: self.mask_secrets = mask_secrets self.comment_defaults = comment_defaults self.include_subparsers = include_subparsers
#: Label of the comment line that lists the accepted values. choices_label: str = "choices:" def _field_block(self, field: ConfigField, setting: str) -> str: """Render one field: help comment(s), then the accepted values when the argument has ``choices``, above the ``setting`` line (already rendered by the caller), with the setting itself commented out when ``comment_defaults`` is on and the field is still at its default. Help is split per line so a multi-line string can't break the surrounding format. """ marker = self.comment_marker lines: list[str] = [] if field.help: for help_line in str(field.help).splitlines(): lines.append(f"{marker} {help_line}" if help_line else marker) if field.choices: listed = ", ".join(str(choice) for choice in field.choices) lines.append(f"{marker} {self.choices_label} {listed}") if self.comment_defaults and field.is_default: lines.append(f"{self.default_comment_marker} {setting}") else: lines.append(setting) return "\n".join(lines)
[docs] def render(self, fields: Sequence[ConfigField]) -> str: """Render a sequence of :class:`ConfigField` records to text. Override this for a new format. ``fields`` is a materialised sequence — implementations may iterate it more than once (e.g. to split into header / sections) without burning through an exhausted iterator. Default implementation just raises — every format has to decide how to lay out fields. """ raise NotImplementedError
[docs] def dump_to_string( self, parser: AbstractParser, *, namespace: argparse.Namespace | None = None, namespace_owner: AbstractParser | None = None, ) -> str: """Walk ``parser`` and return the rendered config as a string. ``namespace`` and ``namespace_owner`` are passed to :func:`iter_config_fields`.""" # Materialise into a tuple so ``render`` (and any custom # subclass) can iterate the field stream multiple times. fields = tuple( iter_config_fields( parser, namespace=namespace, namespace_owner=namespace_owner, mask_secrets=self.mask_secrets, include_subparsers=self.include_subparsers, ), ) return self.render(fields)
[docs] def dump( self, parser: AbstractParser, dest: str | Path | IO[str], *, namespace: argparse.Namespace | None = None, namespace_owner: AbstractParser | None = None, ) -> None: """Write the rendered config to ``dest``. ``dest`` may be a filesystem path (as ``str`` or :class:`pathlib.Path`), a file-like object, or the string ``"-"`` for stdout. """ content = self.dump_to_string( parser, namespace=namespace, namespace_owner=namespace_owner, ) if dest == "-": sys.stdout.write(content) return if isinstance(dest, (str, Path)): with open(dest, "w") as fp: fp.write(content) return dest.write(content)
def group_fields_by_section( fields: Iterable[ConfigField], ) -> "dict[tuple[str, ...], list[ConfigField]]": """Group a stream of fields by ``section_path``. Preserves the original order both for sections and for fields within each section. """ sections: dict[tuple[str, ...], list[ConfigField]] = {} for field in fields: sections.setdefault(field.section_path, []).append(field) return sections
[docs] class INIConfigGenerator(ConfigGenerator): """Render a parser to INI. Top-level arguments go under ``[DEFAULT]`` (read by :class:`argclass.INIDefaultsParser`); nested groups and subparsers become dotted sections (``[endpoint.credentials]``, ``[serve.db]``). Help text is emitted as ``; <text>`` comments above each key, while a key left at its default is commented-out with ``#`` — the two markers keep help prose visually distinct from a disabled setting. configparser treats both ``;`` and ``#`` as comments, so either round-trips. Note: configparser's ``[DEFAULT]`` section would normally cascade into every other section, but :class:`argclass.INIDefaultsParser` strips that cascade on read so a top-level ``host`` cannot leak into a group's ``host`` attribute. """ extension = ".ini" comment_marker = ";" # help; a commented default uses base "#"
[docs] def render(self, fields: Sequence[ConfigField]) -> str: sections = group_fields_by_section(fields) blocks: list[str] = [] root_fields = sections.pop((), []) if root_fields: body = self._emit_fields(root_fields) blocks.append("[DEFAULT]" + (f"\n{body}" if body else "")) for path, items in sections.items(): body = self._emit_fields(items) header = f"[{'.'.join(path)}]" blocks.append(header + (f"\n{body}" if body else "")) return "\n\n".join(blocks) + ("\n" if blocks else "")
def _emit_fields(self, fields: list[ConfigField]) -> str: blocks: list[str] = [] for field in fields: if field.value is None: # configparser has no native None; drop the key so the # reloaded parser falls back to its default. continue setting = f"{field.key} = {self.render_scalar(field.value)}" blocks.append(self._field_block(field, setting)) return "\n\n".join(blocks)
[docs] @staticmethod def render_scalar(value: Any) -> str: if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (list, tuple)): return repr(list(value)) return str(value)
[docs] class JSONConfigGenerator(ConfigGenerator): """Render a parser to JSON. Comments are not supported by JSON, so help text is dropped. Nested groups and subparsers become nested objects. """ extension = ".json"
[docs] def render(self, fields: Sequence[ConfigField]) -> str: data = fields_to_nested_dict(fields) return json.dumps(self.coerce_value(data), indent=2) + "\n"
[docs] def coerce_value(self, value: Any) -> Any: """Convert non-JSON-native types to serialisable equivalents.""" if isinstance(value, dict): return {k: self.coerce_value(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [self.coerce_value(v) for v in value] if isinstance(value, (str, int, float, bool)) or value is None: return value return str(value)
[docs] class TOMLConfigGenerator(ConfigGenerator): """Render a parser to TOML. Help text is emitted as ``# <text>`` comments above each key. Nested groups and subparsers use dotted table headers (``[parent.child]``). Minimal hand-rolled emitter — covers ``str``/``int``/``float``/ ``bool``/``list``/``None``. Other types are coerced via ``str()``. """ extension = ".toml"
[docs] def render(self, fields: Sequence[ConfigField]) -> str: sections = group_fields_by_section(fields) blocks: list[str] = [] root_body = self._emit_fields(sections.pop((), [])) if root_body: blocks.append(root_body) for path, items in sections.items(): body = self._emit_fields(items) header = f"[{'.'.join(path)}]" blocks.append(header + (f"\n{body}" if body else "")) return "\n\n".join(blocks) + ("\n" if blocks else "")
def _emit_fields(self, fields: list[ConfigField]) -> str: blocks: list[str] = [] for field in fields: if field.value is None: continue setting = f"{field.key} = {self.render_value(field.value)}" blocks.append(self._field_block(field, setting)) return "\n\n".join(blocks)
[docs] def render_value(self, value: Any) -> str: if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): return repr(value) if isinstance(value, (list, tuple)): inner = ", ".join(self.render_value(v) for v in value) return f"[{inner}]" return self.render_string(value)
[docs] @staticmethod def render_string(value: Any) -> str: return f'"{escape_inline_string(str(value))}"'
[docs] class EnvConfigGenerator(ConfigGenerator): """Render a parser to a ``.env``-style listing. Emits one ``KEY=value`` line per argument that has a resolvable env var (explicit ``env_var=`` on the argument or computed from the parser's ``auto_env_var_prefix=``). Arguments without an env var are skipped. Help text appears as ``# <text>`` comments above each key. ``None`` values are dropped. Lists serialise to Python literal syntax so argclass can ``ast.literal_eval`` them on read. Strings are quoted only when they contain whitespace, ``=``, ``#``, or other characters that would confuse a typical ``.env`` parser. """ extension = ".env" QUOTE_CHARS = frozenset(' \t\n\r"\\#=')
[docs] def render(self, fields: Sequence[ConfigField]) -> str: blocks: list[str] = [] for field in fields: if field.env_var is None: continue if field.value is None: continue setting = f"{field.env_var}={self.render_value(field.value)}" blocks.append(self._field_block(field, setting)) return "\n\n".join(blocks) + ("\n" if blocks else "")
[docs] def render_value(self, value: Any) -> str: if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): return repr(value) if isinstance(value, (list, tuple)): return repr(list(value)) return self.quote_string(str(value))
[docs] def quote_string(self, value: str) -> str: if not value: return "" if any(c in self.QUOTE_CHARS for c in value): return f'"{escape_inline_string(value)}"' return value
[docs] class GenerateConfigAction(NonConfigAction): """Argparse Action that writes a parser's state as a config file. Declare an attribute on your Parser and let argclass derive the flag from its name:: class CLI(argclass.Parser): generate_config = argclass.Argument( action=GenerateConfigAction, generator=INIConfigGenerator, metavar="FILE", ) End users then run ``myapp --generate-config /etc/myapp.ini`` (or ``-`` for stdout). The action walks the parser, renders the config via the supplied ``generator=`` (class or instance), writes to the destination, and exits with status 0. The action may also be declared on a subparser. The dump then still starts at the root parser, so the file covers the whole command tree. argparse parses a subcommand into a fresh namespace, so root arguments given before the subcommand are not visible to the action; the root part of the dump shows instance state, env vars and declared defaults. Declare the flag on the root parser and pass it before the subcommand when root overrides matter. """
[docs] def __init__( self, option_strings: list[str], dest: str, generator: type | ConfigGenerator, **kwargs: Any, ): kwargs.setdefault("nargs", 1) kwargs.setdefault("metavar", "FILE") kwargs.setdefault("default", argparse.SUPPRESS) if isinstance(generator, type): self.generator: ConfigGenerator = generator() else: self.generator = generator super().__init__(option_strings, dest, **kwargs)
def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: Any, option_string: str | None = None, ) -> None: # Out-of-band back-reference avoids touching argparse_parser. argclass_parser = get_argclass_parser(parser) if argclass_parser is None: parser.error( "argclass parser back-reference missing — " "GenerateConfigAction requires the parser to be built " "through argclass.Parser.parse_args", ) path = values[0] if isinstance(values, list) else values root: AbstractParser = argclass_parser while root.__parent__ is not None: root = root.__parent__ self.generator.dump( root, path, namespace=namespace, namespace_owner=argclass_parser, ) parser.exit(0)