Source code for argdigest.core.normalization

"""Declared normalization: argument-name aliases as data.

Renaming an argument before it is judged is what lets a library accept the names its
users actually type — `residue_index` for `group_index` — without every downstream layer
having to know about it. ArgDigest has always supported this through the `standardizer`
hook, one callable per library, which in practice grows into a chain of
`if caller == ...` branches.

That is the shape the function contract removed for admission rules, and this module
removes it for renaming: aliases are declared as data, discovered like digesters and
contracts, and composed by ArgDigest.

Rules are deliberately **static**. An earlier design generated target names from a
template such as `{element}_{name}`, which is shorter to write and admits names that do
not exist: on the reference consumer it would have produced six attributes nobody
defines. A table declares only what is real.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from fnmatch import fnmatchcase
from typing import Any, Iterable, Mapping

#: Applied to every caller.
APPLIES_TO_ALL = "*"


[docs] @dataclass(frozen=True) class AliasTable: """A set of argument-name aliases, optionally scoped to callers and to a context. `applies_to` is an exact caller, an `fnmatch` pattern, or `"*"` for every caller. `when` guards the table on the value of another argument of the same call, which is what covers a name whose meaning depends on context: `name` means `atom_name` when `element="atom"`. It is an equality test against already-bound arguments, not an expression language. """ aliases: Mapping[str, str] applies_to: str = APPLIES_TO_ALL when: Mapping[str, Any] | None = None description: str | None = None def __post_init__(self) -> None: if not self.aliases: raise ValueError("An AliasTable needs at least one alias.") for source, target in self.aliases.items(): if source == target: raise ValueError( f"Alias {source!r} maps to itself, which cannot be what was meant." ) @property def specificity(self) -> tuple[int, int]: """Rank for resolution: an exact caller beats a longer pattern beats `*`.""" if self.applies_to == APPLIES_TO_ALL: return (0, 0) if any(character in self.applies_to for character in "*?["): return (1, len(self.applies_to)) return (2, len(self.applies_to)) def matches_caller(self, caller: str) -> bool: if self.applies_to == APPLIES_TO_ALL: return True return fnmatchcase(caller, self.applies_to) def matches_context(self, bound: Mapping[str, Any]) -> bool: if not self.when: return True return all(bound.get(name) == value for name, value in self.when.items())
class NormalizationRegistry: """Resolves which alias tables apply to a caller, most specific first.""" def __init__(self, tables: Iterable[AliasTable] = ()) -> None: self._tables: list[AliasTable] = [] # Which tables match a caller depends only on the caller, so it is cached; the # `when` guard still has to be evaluated per call, because it reads values. self._by_caller: dict[str, tuple[AliasTable, ...]] = {} for table in tables: self.add(table) def add(self, table: AliasTable) -> None: self._tables.append(table) self._tables.sort(key=lambda item: item.specificity, reverse=True) self._by_caller.clear() def for_caller(self, caller: str) -> tuple[AliasTable, ...]: try: return self._by_caller[caller] except KeyError: pass matching = tuple(table for table in self._tables if table.matches_caller(caller)) self._by_caller[caller] = matching return matching def tables(self) -> tuple[AliasTable, ...]: return tuple(self._tables) def __bool__(self) -> bool: return bool(self._tables) def apply_normalization(registry: NormalizationRegistry, caller: str, bound: dict[str, Any]) -> dict[str, Any]: """Rename the arguments of one call according to the declared tables. Tables are applied most specific first, so a caller-scoped alias wins over a global one for the same name. A name already renamed is not reconsidered: renaming is a single pass, never a chain. Insertion order is preserved, because a caller reading a traceback or a repr should see arguments in the order they were written. """ tables = registry.for_caller(caller) if not tables: return bound renames: dict[str, str] = {} for table in tables: if not table.matches_context(bound): continue for source, target in table.aliases.items(): if source in bound and source not in renames: renames[source] = target if not renames: return bound return {renames.get(name, name): value for name, value in bound.items()}
[docs] def describe_normalization(registry: NormalizationRegistry, caller: str | None = None) -> list[dict[str, Any]]: """Render the declared aliases as plain data, optionally for one caller. Declaring rules as data rather than as a callable is what makes this possible: the alternative names a function accepts can be listed in its documentation instead of living undocumented inside a branch of a standardizer. """ tables = registry.tables() if caller is None else registry.for_caller(caller) return [ { "applies_to": table.applies_to, "when": dict(table.when) if table.when else None, "aliases": dict(table.aliases), "description": table.description, } for table in tables ]