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 is walked once, yielding :class:`ConfigField` records;
generators consume that iterator and produce a format-specific string.

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.

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,
    Dict,
    IO,
    Iterable,
    Iterator,
    List,
    Optional,
    Sequence,
    Tuple,
    Union,
    cast,
)

from .parser import get_argclass_parser
from .secret import SecretString
from .store import AbstractGroup, AbstractParser, TypedArgument
from .types import Actions
from .utils import 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: Optional[argparse.Namespace] = None, dest: Optional[str] = None, env_var: Optional[str] = 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 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. """ 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) return argument.default def derive_env_var( auto_prefix: Optional[str], dest: str, argument: TypedArgument, ) -> Optional[str]: """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 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. cli_path: Same shape as ``attr_path`` but respecting per-group ``prefix=`` overrides — useful when reconstructing CLI flag names. dest: argparse ``dest`` for the field (``"endpoint_credentials_username"``). Joins ``cli_path`` with underscores. 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``. """ attr_path: Tuple[str, ...] cli_path: Tuple[str, ...] dest: str argument: TypedArgument target: Any value: Any env_var: Optional[str] help: Optional[str] @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 iter_config_fields( parser: AbstractParser, *, namespace: Optional[argparse.Namespace] = None, mask_secrets: bool = False, ) -> Iterator[ConfigField]: """Walk ``parser`` and yield one :class:`ConfigField` per leaf. Subparsers are skipped (they're runtime branches, not config state). 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. 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) yield from iter_subtree_fields( parser, attr_path=(), cli_path=(), auto_prefix=auto_prefix, namespace=namespace, mask_secrets=mask_secrets, ) def iter_subtree_fields( target: Any, *, attr_path: Tuple[str, ...] = (), cli_path: Tuple[str, ...] = (), auto_prefix: Optional[str] = None, namespace: Optional[argparse.Namespace] = None, mask_secrets: bool = False, ) -> 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. ``mask_secrets`` mirrors :func:`iter_config_fields` — see its docstring for the semantics. """ node = cast(Any, target) cli_prefix = "_".join(cli_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=namespace, dest=dest, env_var=env_var, ) value = normalize_value(raw) if mask_secrets and argument.secret and value is not None: value = SecretString.PLACEHOLDER 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, ) for group_name, group in node.__argument_groups__.items(): seg = group_cli_segment(group, group_name) child_cli = cli_path + ((seg,) if seg else ()) yield from iter_subtree_fields( group, attr_path=attr_path + (group_name,), cli_path=child_cli, auto_prefix=auto_prefix, namespace=namespace, mask_secrets=mask_secrets, ) 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. """ #: File extension hint. Subclasses set this. extension: str = ""
[docs] def __init__(self, *, mask_secrets: bool = False) -> None: self.mask_secrets = mask_secrets
[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: Optional[argparse.Namespace] = None, ) -> str: """Walk ``parser`` and return the rendered config as a string.""" # 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, mask_secrets=self.mask_secrets, ), ) return self.render(fields)
[docs] def dump( self, parser: AbstractParser, dest: Union[str, Path, IO[str]], *, namespace: Optional[argparse.Namespace] = 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) 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 become dotted sections (``[endpoint.credentials]``). Help text is emitted as ``; <text>`` comments above each key. 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"
[docs] def render(self, fields: Sequence[ConfigField]) -> str: sections = group_fields_by_section(fields) lines: List[str] = [] root_fields = sections.pop((), []) if root_fields: lines.append("[DEFAULT]") self._emit_fields(root_fields, lines) lines.append("") for path, items in sections.items(): lines.append(f"[{'.'.join(path)}]") self._emit_fields(items, lines) lines.append("") return "\n".join(lines)
def _emit_fields( self, fields: List[ConfigField], lines: List[str], ) -> None: for field in fields: if field.value is None: # configparser has no native None; dropping the key # lets the reloaded parser fall back to its default. continue if field.help: lines.append(f"; {field.help}") lines.append(f"{field.key} = {self.render_scalar(field.value)}")
[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 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 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) lines: List[str] = [] root_fields = sections.pop((), []) for field in root_fields: if field.value is None: continue if field.help: lines.append(f"# {field.help}") lines.append(f"{field.key} = {self.render_value(field.value)}") if root_fields and sections: lines.append("") for path, items in sections.items(): lines.append(f"[{'.'.join(path)}]") for field in items: if field.value is None: continue if field.help: lines.append(f"# {field.help}") lines.append( f"{field.key} = {self.render_value(field.value)}", ) lines.append("") return "\n".join(lines)
[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: lines: List[str] = [] for field in fields: if field.env_var is None: continue if field.value is None: continue if field.help: lines.append(f"# {field.help}") lines.append(f"{field.env_var}={self.render_value(field.value)}") return "\n".join(lines) + ("\n" if lines 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. """
[docs] def __init__( self, option_strings: List[str], dest: str, generator: Union[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: Optional[str] = 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 self.generator.dump(argclass_parser, path, namespace=namespace) parser.exit(0)