Skip to content

Index

axm_config

axm-config.

Non-sensitive runtime config under ~/.axm (env>file>default)

ConfigError

Bases: RuntimeError

Raised when a required config value cannot be resolved.

Source code in packages/axm-config/src/axm_config/resolver.py
Python
class ConfigError(RuntimeError):
    """Raised when a required config value cannot be resolved."""

NamespaceStore

Read/write namespace sections of a single ~/.axm/config.toml.

Each namespace maps to a top-level (or nested, for a dotted namespace) TOML table within one config.toml. Reads of an absent or malformed file/section return {} rather than raising, so a consumer can rely on the store at import time without a pre-existing ~/.axm directory. Writes are atomic, preserve every other section, and leave the file 0600. A namespace node may be both a leaf (its own scalar/array keys) and a prefix (nested child namespaces such as [git.default] under [git]): those child sub-tables are re-attached on every write, so setting a key under the parent never erases them. Legacy ~/.axm/<ns>.toml files are folded in on first write.

Source code in packages/axm-config/src/axm_config/store.py
Python
class NamespaceStore:
    """Read/write namespace sections of a single ``~/.axm/config.toml``.

    Each namespace maps to a top-level (or nested, for a dotted namespace)
    TOML table within one ``config.toml``. Reads of an absent or malformed
    file/section return ``{}`` rather than raising, so a consumer can rely on
    the store at import time without a pre-existing ``~/.axm`` directory.
    Writes are atomic, preserve every other section, and leave the file
    ``0600``. A namespace node may be **both** a leaf (its own scalar/array
    keys) **and** a prefix (nested **child** namespaces such as ``[git.default]``
    under ``[git]``): those child sub-tables are re-attached on every write, so
    setting a key under the parent never erases them. Legacy
    ``~/.axm/<ns>.toml`` files are folded in on first write.
    """

    def _config_path(self) -> Path:
        """Resolve the on-disk path of ``config.toml`` under ``axm_home()``.

        Defence in depth on top of the boundary segment validation: the
        resolved path must sit *inside* the resolved ``~/.axm`` home, and
        ``resolve_safe`` refuses a home that itself resolves inside a git
        checkout (a misconfigured ``HOME`` pointing into a repo). A path that
        would escape the home raises :class:`ValueError`.
        """
        home = _safe_home()
        path = (home / CONFIG_FILENAME).resolve()
        if home not in path.parents:
            msg = f"refusing out-of-home store path {path}: escapes {home}"
            raise ValueError(msg)
        return path

    def _legacy_path(self, ns: str) -> Path:
        """Resolve the legacy per-namespace ``~/.axm/<ns>.toml`` path.

        Same in-home containment guard as :meth:`_config_path`. This is the
        previous storage layout; it is read-through and folded into
        ``config.toml`` on the next write.
        """
        home = _safe_home()
        path = (home / f"{ns}.toml").resolve()
        if home not in path.parents:
            msg = f"refusing out-of-home store path {path}: escapes {home}"
            raise ValueError(msg)
        return path

    def _load_config(self) -> dict[str, object]:
        """Return the full parsed ``config.toml`` mapping, or ``{}``.

        A missing file or a malformed TOML payload both degrade to ``{}`` so
        the call never raises for a consumer.
        """
        path = self._config_path()
        try:
            with path.open("rb") as fh:
                return tomllib.load(fh)
        except FileNotFoundError:
            return {}
        except (tomllib.TOMLDecodeError, OSError):
            return {}

    def _read_legacy(self, ns: str) -> dict[str, object]:
        """Return the contents of the legacy ``~/.axm/<ns>.toml``, or ``{}``."""
        path = self._legacy_path(ns)
        try:
            with path.open("rb") as fh:
                return tomllib.load(fh)
        except FileNotFoundError:
            return {}
        except (tomllib.TOMLDecodeError, OSError):
            return {}

    def read(self, ns: str) -> dict[str, object]:
        """Return the section for ``ns``, or ``{}`` if absent/corrupt.

        The ``[ns]`` section of ``config.toml`` is returned as a flat mapping
        of that namespace's *own* keys: a dotted namespace maps to a nested
        table, and any nested sub-table is a **child namespace**, not a key, so
        it is excluded from the result. If the section is absent but a legacy
        ``~/.axm/<ns>.toml`` exists, the legacy contents are returned so the
        value stays visible before the fold. A missing file or a malformed
        TOML payload both degrade to ``{}``. Raises
        :class:`~axm_config.resolver.UnsafeHomeError` (a :class:`ConfigError`)
        only when ``~/.axm`` cannot be used safely (HOME inside a git repo).
        """
        section = _section(self._load_config(), ns)
        if section:
            return section
        return self._read_legacy(ns)

    def write(self, ns: str, key: str, value: object) -> None:
        """Set ``key`` to ``value`` in ``ns``, preserving every other section.

        Read-modify-write of the *whole* ``config.toml``: the full mapping is
        loaded, the ``ns`` section folded with any legacy file and updated, and
        the result serialised to a same-directory temp file atomically moved
        into place via :func:`os.replace`; the file is chmod ``0600``. The
        legacy ``~/.axm/<ns>.toml`` (if any) is removed after the fold.
        """
        config = self._load_config()
        section = self._fold_legacy(config, ns)
        section[key] = value
        _set_section(config, ns, _with_child_tables(config, ns, section))
        self._commit_config(config)
        self._drop_legacy(ns)

    def delete(self, ns: str, key: str) -> None:
        """Remove ``key`` from ``ns``, rewriting atomically (no-op if absent).

        Read-modify-write mirroring :meth:`write` over the whole
        ``config.toml``: the key is popped from the (legacy-folded) section. If
        the section becomes empty it is dropped; if the file then holds no
        section it is unlinked. A missing key (after folding) is a silent
        no-op, but a pending legacy fold is still applied.
        """
        config = self._load_config()
        section = self._fold_legacy(config, ns)
        had_legacy = self._legacy_path(ns).exists()
        if key not in section and not had_legacy:
            return
        section.pop(key, None)
        if section:
            _set_section(config, ns, section)
        else:
            _drop_section(config, ns)
        if config:
            self._commit_config(config)
        else:
            self._config_path().unlink(missing_ok=True)
        self._drop_legacy(ns)

    def namespaces(self) -> list[str]:
        """Return every namespace path present in ``config.toml`` or legacy.

        A leaf table (a mapping whose values are all scalars/arrays, i.e. an
        actual namespace section) yields its dotted path. Legacy
        ``~/.axm/<ns>.toml`` files contribute their stem. Used by the doctor to
        enumerate "all known" namespaces when none is requested.
        """
        found = set(_leaf_paths(self._load_config()))
        home = _safe_home()
        for legacy in home.glob("*.toml"):
            if legacy.name != CONFIG_FILENAME:
                found.add(legacy.stem)
        return sorted(found)

    def _fold_legacy(self, config: dict[str, object], ns: str) -> dict[str, object]:
        """Return the ``ns`` section merged with any legacy file (legacy base)."""
        section = _section(config, ns)
        legacy = self._read_legacy(ns)
        merged: dict[str, object] = {**legacy, **section}
        return merged

    def _drop_legacy(self, ns: str) -> None:
        """Remove the legacy ``~/.axm/<ns>.toml`` after a successful fold."""
        self._legacy_path(ns).unlink(missing_ok=True)

    def _commit_config(self, config: dict[str, object]) -> None:
        """Serialise ``config`` to a same-dir temp file and atomically swap it.

        The mapping is written to a ``NamedTemporaryFile`` under ``~/.axm`` and
        moved onto ``config.toml`` via :func:`os.replace`; the resulting file
        is chmod ``0600``.
        """
        path = self._config_path()
        payload = tomli_w.dumps(config).encode("utf-8")
        with NamedTemporaryFile(mode="wb", dir=path.parent, delete=False) as tmp:
            tmp.write(payload)
            tmp_path = Path(tmp.name)
        self._commit(tmp_path, path)

    def _commit(self, tmp_path: Path, path: Path) -> None:
        """Atomically move ``tmp_path`` onto ``path``, never leaking the temp.

        :func:`os.replace` is the atomic swap; if it (or the follow-up
        ``chmod``) raises, the staged temp file would otherwise linger under
        ``~/.axm``. A ``try/finally`` unlinks it on the error path while a
        successful replace consumes it (the ``missing_ok`` unlink is then a
        no-op on the already-moved path).
        """
        try:
            os.replace(tmp_path, path)
            if os.name == "posix":
                os.chmod(path, NAMESPACE_FILE_MODE)
        finally:
            tmp_path.unlink(missing_ok=True)
delete(ns, key)

Remove key from ns, rewriting atomically (no-op if absent).

Read-modify-write mirroring :meth:write over the whole config.toml: the key is popped from the (legacy-folded) section. If the section becomes empty it is dropped; if the file then holds no section it is unlinked. A missing key (after folding) is a silent no-op, but a pending legacy fold is still applied.

Source code in packages/axm-config/src/axm_config/store.py
Python
def delete(self, ns: str, key: str) -> None:
    """Remove ``key`` from ``ns``, rewriting atomically (no-op if absent).

    Read-modify-write mirroring :meth:`write` over the whole
    ``config.toml``: the key is popped from the (legacy-folded) section. If
    the section becomes empty it is dropped; if the file then holds no
    section it is unlinked. A missing key (after folding) is a silent
    no-op, but a pending legacy fold is still applied.
    """
    config = self._load_config()
    section = self._fold_legacy(config, ns)
    had_legacy = self._legacy_path(ns).exists()
    if key not in section and not had_legacy:
        return
    section.pop(key, None)
    if section:
        _set_section(config, ns, section)
    else:
        _drop_section(config, ns)
    if config:
        self._commit_config(config)
    else:
        self._config_path().unlink(missing_ok=True)
    self._drop_legacy(ns)
namespaces()

Return every namespace path present in config.toml or legacy.

A leaf table (a mapping whose values are all scalars/arrays, i.e. an actual namespace section) yields its dotted path. Legacy ~/.axm/<ns>.toml files contribute their stem. Used by the doctor to enumerate "all known" namespaces when none is requested.

Source code in packages/axm-config/src/axm_config/store.py
Python
def namespaces(self) -> list[str]:
    """Return every namespace path present in ``config.toml`` or legacy.

    A leaf table (a mapping whose values are all scalars/arrays, i.e. an
    actual namespace section) yields its dotted path. Legacy
    ``~/.axm/<ns>.toml`` files contribute their stem. Used by the doctor to
    enumerate "all known" namespaces when none is requested.
    """
    found = set(_leaf_paths(self._load_config()))
    home = _safe_home()
    for legacy in home.glob("*.toml"):
        if legacy.name != CONFIG_FILENAME:
            found.add(legacy.stem)
    return sorted(found)
read(ns)

Return the section for ns, or {} if absent/corrupt.

The [ns] section of config.toml is returned as a flat mapping of that namespace's own keys: a dotted namespace maps to a nested table, and any nested sub-table is a child namespace, not a key, so it is excluded from the result. If the section is absent but a legacy ~/.axm/<ns>.toml exists, the legacy contents are returned so the value stays visible before the fold. A missing file or a malformed TOML payload both degrade to {}. Raises :class:~axm_config.resolver.UnsafeHomeError (a :class:ConfigError) only when ~/.axm cannot be used safely (HOME inside a git repo).

Source code in packages/axm-config/src/axm_config/store.py
Python
def read(self, ns: str) -> dict[str, object]:
    """Return the section for ``ns``, or ``{}`` if absent/corrupt.

    The ``[ns]`` section of ``config.toml`` is returned as a flat mapping
    of that namespace's *own* keys: a dotted namespace maps to a nested
    table, and any nested sub-table is a **child namespace**, not a key, so
    it is excluded from the result. If the section is absent but a legacy
    ``~/.axm/<ns>.toml`` exists, the legacy contents are returned so the
    value stays visible before the fold. A missing file or a malformed
    TOML payload both degrade to ``{}``. Raises
    :class:`~axm_config.resolver.UnsafeHomeError` (a :class:`ConfigError`)
    only when ``~/.axm`` cannot be used safely (HOME inside a git repo).
    """
    section = _section(self._load_config(), ns)
    if section:
        return section
    return self._read_legacy(ns)
write(ns, key, value)

Set key to value in ns, preserving every other section.

Read-modify-write of the whole config.toml: the full mapping is loaded, the ns section folded with any legacy file and updated, and the result serialised to a same-directory temp file atomically moved into place via :func:os.replace; the file is chmod 0600. The legacy ~/.axm/<ns>.toml (if any) is removed after the fold.

Source code in packages/axm-config/src/axm_config/store.py
Python
def write(self, ns: str, key: str, value: object) -> None:
    """Set ``key`` to ``value`` in ``ns``, preserving every other section.

    Read-modify-write of the *whole* ``config.toml``: the full mapping is
    loaded, the ``ns`` section folded with any legacy file and updated, and
    the result serialised to a same-directory temp file atomically moved
    into place via :func:`os.replace`; the file is chmod ``0600``. The
    legacy ``~/.axm/<ns>.toml`` (if any) is removed after the fold.
    """
    config = self._load_config()
    section = self._fold_legacy(config, ns)
    section[key] = value
    _set_section(config, ns, _with_child_tables(config, ns, section))
    self._commit_config(config)
    self._drop_legacy(ns)

UnsafeHomeError

Bases: ConfigError

Raised when ~/.axm cannot be used safely (e.g. a HOME in a git repo).

A :class:ConfigError subclass so every consumer surface that already catches :class:ConfigError (the CLI, :func:load) degrades cleanly instead of leaking the raw ValueError from :func:axm_config.home.resolve_safe. The security refusal itself is intentional; only its type is narrowed here so callers can handle it.

Source code in packages/axm-config/src/axm_config/resolver.py
Python
class UnsafeHomeError(ConfigError):
    """Raised when ``~/.axm`` cannot be used safely (e.g. a HOME in a git repo).

    A :class:`ConfigError` subclass so every consumer surface that already
    catches :class:`ConfigError` (the CLI, :func:`load`) degrades cleanly
    instead of leaking the raw ``ValueError`` from
    :func:`axm_config.home.resolve_safe`. The security refusal itself is
    intentional; only its *type* is narrowed here so callers can handle it.
    """

axm_home()

Return the resolved ~/.axm directory, creating it 0700 if absent.

Idempotent: a pre-existing directory with looser permissions is tightened back to 0700. Permission calls degrade gracefully on non-POSIX systems.

Source code in packages/axm-config/src/axm_config/home.py
Python
def axm_home() -> Path:
    """Return the resolved ``~/.axm`` directory, creating it ``0700`` if absent.

    Idempotent: a pre-existing directory with looser permissions is tightened
    back to ``0700``. Permission calls degrade gracefully on non-POSIX systems.
    """
    home = (Path.home() / ".axm").resolve()
    home.mkdir(mode=AXM_DIR_MODE, parents=True, exist_ok=True)
    if os.name == "posix":
        os.chmod(home, AXM_DIR_MODE)
    return home

delete(namespace, key)

Remove key from the [namespace] section of config.toml (no-op if absent).

namespace and key are validated first. Deleting an absent key (or a namespace with no file) is a silent no-op — it never raises. After removal the key resolves through the lower layers again (env, then default).

Source code in packages/axm-config/src/axm_config/resolver.py
Python
def delete(namespace: str, key: str) -> None:
    """Remove ``key`` from the ``[namespace]`` section of config.toml (no-op if absent).

    ``namespace`` and ``key`` are validated first. Deleting an absent key (or a
    namespace with no file) is a silent no-op — it never raises. After removal
    the key resolves through the lower layers again (env, then ``default``).
    """
    validate_segment(namespace, kind="namespace")
    validate_segment(key, kind="key")
    _store.delete(namespace, key)

get(namespace, key, *, default=None)

Return the resolved value for key in namespace.

Precedence is env > file > default. An env value is returned as the raw str from the environment; file values keep their TOML-parsed type.

Source code in packages/axm-config/src/axm_config/resolver.py
Python
def get(namespace: str, key: str, *, default: object = None) -> object:
    """Return the resolved value for ``key`` in ``namespace``.

    Precedence is ``env > file > default``. An env value is returned as the
    raw ``str`` from the environment; file values keep their TOML-parsed type.
    """
    return resolve(namespace, key, default)

load(namespace, model)

Build model from namespace, resolving each field by name.

Every field of model is resolved via :func:get (the field name is the config key). Unresolved fields are omitted so pydantic applies the field default; a required field that stays unresolved raises :class:ConfigError instead of a raw ValidationError.

Source code in packages/axm-config/src/axm_config/resolver.py
Python
def load[M: BaseModel](namespace: str, model: type[M]) -> M:
    """Build ``model`` from ``namespace``, resolving each field by name.

    Every field of ``model`` is resolved via :func:`get` (the field name is
    the config key). Unresolved fields are omitted so pydantic applies the
    field default; a required field that stays unresolved raises
    :class:`ConfigError` instead of a raw ``ValidationError``.
    """
    values: dict[str, object] = {}
    for field in model.model_fields:
        resolved = resolve(namespace, field, _MISSING)
        if resolved is not _MISSING:
            values[field] = resolved
    try:
        return model.model_validate(values)
    except Exception as exc:
        msg = f"cannot build {model.__name__} for namespace {namespace!r}: {exc}"
        raise ConfigError(msg) from exc

resolve_safe(target)

Resolve target and refuse any path sitting inside a git repo.

Walks the resolved path and its ancestors looking for a .git marker (a source checkout). Raises :class:ValueError rather than returning an in-repo path; returns the resolved path otherwise.

Source code in packages/axm-config/src/axm_config/home.py
Python
def resolve_safe(target: Path | str) -> Path:
    """Resolve ``target`` and refuse any path sitting inside a git repo.

    Walks the resolved path and its ancestors looking for a ``.git`` marker
    (a source checkout). Raises :class:`ValueError` rather than returning an
    in-repo path; returns the resolved path otherwise.
    """
    resolved = Path(target).resolve()
    for ancestor in (resolved, *resolved.parents):
        if (ancestor / ".git").exists():
            msg = (
                f"refusing in-repo path {resolved}: "
                f"resolves inside the git checkout at {ancestor}"
            )
            raise ValueError(msg)
    return resolved

set_(namespace, key, value)

Persist key = value in the [namespace] section of config.toml.

namespace and key are validated against the safe-segment pattern first (path-traversal guard). A value of None is routed to :func:delete — TOML cannot encode None, so deleting the key is the well-defined contract rather than a raw TypeError. Otherwise delegates to :meth:NamespaceStore.write (atomic, 0600, other keys preserved).

Source code in packages/axm-config/src/axm_config/resolver.py
Python
def set_(namespace: str, key: str, value: object) -> None:
    """Persist ``key`` = ``value`` in the ``[namespace]`` section of config.toml.

    ``namespace`` and ``key`` are validated against the safe-segment pattern
    first (path-traversal guard). A ``value`` of ``None`` is routed to
    :func:`delete` — TOML cannot encode ``None``, so deleting the key is the
    well-defined contract rather than a raw ``TypeError``. Otherwise delegates
    to :meth:`NamespaceStore.write` (atomic, ``0600``, other keys preserved).
    """
    validate_segment(namespace, kind="namespace")
    validate_segment(key, kind="key")
    if value is None:
        _store.delete(namespace, key)
        return
    _store.write(namespace, key, value)

validate_segment(value, *, kind='segment')

Return value if it is a safe config segment, else raise ConfigError.

A segment is a namespace or a key: the single entry-point guard against path traversal and env-name ambiguity. It must be a non-empty str matching its kind's pattern — no path separators, no .. traversal, no NUL byte — so it can never widen the on-disk ~/.axm/<ns>.toml path. Both patterns are lowercase-only (no upper-case): the env-name surface upper-cases the segments, so accepting both "Demo" and "demo" would let two distinct namespaces fold to the same AXM_DEMO_* prefix — forbidding upper-case makes that collision unrepresentable. The patterns differ by kind: a "namespace" (:data:_NAMESPACE_RE) is lowercase-alphanumeric segments joined by dots — no _ and no - — whereas a "key" (:data:_KEY_RE) is lowercase-alphanumeric segments joined by single _ (no ./-, no leading/trailing _, no doubled __) so the derived env name stays POSIX-valid and the ns/key boundary is unambiguous: only the namespace's dot-fold yields __, the key can never forge one, and the lone single _ separates the folded namespace from the key. Any other kind falls back to the namespace pattern. Shared with every public boundary (and reused by the env-name surface) so validation is declared exactly once.

Source code in packages/axm-config/src/axm_config/resolver.py
Python
def validate_segment(value: str, *, kind: str = "segment") -> str:
    """Return ``value`` if it is a safe config segment, else raise ConfigError.

    A *segment* is a ``namespace`` or a ``key``: the single entry-point guard
    against path traversal and env-name ambiguity. It must be a non-empty
    ``str`` matching its kind's pattern — no path separators, no ``..``
    traversal, no NUL byte — so it can never widen the on-disk
    ``~/.axm/<ns>.toml`` path. Both patterns are **lowercase-only** (no
    upper-case): the env-name surface upper-cases the segments, so accepting
    both ``"Demo"`` and ``"demo"`` would let two distinct namespaces fold to
    the *same* ``AXM_DEMO_*`` prefix — forbidding upper-case makes that
    collision unrepresentable. The patterns differ by ``kind``: a
    ``"namespace"`` (:data:`_NAMESPACE_RE`) is lowercase-alphanumeric segments
    joined by dots — no ``_`` and no ``-`` — whereas a ``"key"``
    (:data:`_KEY_RE`) is lowercase-alphanumeric segments joined by **single**
    ``_`` (no ``.``/``-``, no leading/trailing ``_``, no doubled ``__``) so the
    derived env name stays POSIX-valid and the ns/key boundary is
    unambiguous: only the namespace's dot-fold yields ``__``, the key can
    never forge one, and the lone single ``_`` separates the folded namespace
    from the key. Any other ``kind`` falls back to the namespace pattern.
    Shared with every public boundary (and reused by the env-name surface) so
    validation is declared exactly once.
    """
    pattern = _SEGMENT_PATTERNS.get(kind, _NAMESPACE_RE)
    if not isinstance(value, str) or not pattern.match(value):
        msg = f"invalid {kind} {value!r}: must match {pattern.pattern}"
        raise ConfigError(msg)
    return value