Normalization#
Normalization renames arguments before anything judges them, so a library can accept the names its users actually type.
If your users write residue_index where the library calls it group_index, that is not
a mistake to punish — it is a name worth accepting. Normalization is where you say so,
once, instead of handling it in every function.
Where it runs, and why aliases survive the function contract#
bind_arguments -> normalization -> standardizer -> function contract -> digestion
Normalization runs before both the function contract and the argument digesters, and the order is deliberate. By the time a contract judges a keyword, an alias has already become its canonical name, so declaring a contract never breaks your aliases.
If the contract ran first it would reject every alias as an unknown argument, turning a feature built to help users into a wall. A genuine typo, on the other hand, survives normalization unchanged and is then refused — which is exactly the division of labour you want: normalization translates, the contract judges.
Declaring aliases#
Aliases are data. Drop one module per family of rules into the package named by
NORMALIZATION_SOURCE, declaring table or TABLES:
# mylib/_private/argdigest/normalization/synonyms.py
from argdigest import AliasTable
table = AliasTable(
aliases={'residue_index': 'group_index', 'residue_indices': 'group_index'},
description='anatomical synonyms of the canonical names',
)
# mylib/_argdigest.py
NORMALIZATION_SOURCE = "mylib._private.argdigest.normalization"
That is the whole mechanism. ArgDigest discovers the tables, composes them and applies them; you never write dispatch logic.
Scoping a table to one function or a family#
AliasTable(applies_to='mylib.basic.compare.compare',
aliases={'attributes_type': 'attribute_type'})
AliasTable(applies_to='mylib.form.*', # fnmatch pattern
aliases={'idx': 'index'})
applies_to defaults to "*", every caller. Scope is not a detail. A name that is
an attribute in one function is often an ordinary parameter in another: renaming
atom_indices globally in the reference consumer would have rewritten a real parameter
of every adapter into a name none of them declares. Declare a table globally only when
the alias means the same thing everywhere.
An alias that depends on another argument#
when guards a table on the value of another argument in the same call:
AliasTable(applies_to='mylib.basic.get.get', when={'element': 'atom'},
aliases={'name': 'atom_name', 'index': 'atom_index'})
AliasTable(applies_to='mylib.basic.get.get', when={'element': 'group'},
aliases={'name': 'group_name', 'index': 'group_index'})
So get(molsys, element='atom', name=True) asks for atom_name, and the same name
asks for group_name when the element is a group.
It is an equality test, not an expression language, and the tables are written out rather
than generated from a {element}_{name} template. That is deliberate: a template accepts
combinations that do not exist. In the reference consumer it produced six attribute names
nothing defines, so get(molsys, element='atom', order=True) became a request for
atom_order and failed much further downstream. A table declares only what is real, and
the unreal name is refused where it is written.
How tables compose#
Most specific first. An exact caller beats a longer pattern beats
"*", so a function-scoped alias overrides a global one for the same name.One pass, never a chain. Once
ahas becomeb, it is not reconsidered. Chaining would make the result depend on declaration order, which nobody could reason about.Order is preserved. Arguments keep the order they were written in.
Reading the aliases back#
from argdigest import describe_normalization
It renders the declared tables as plain data. This is the practical reason for declaring rules rather than writing a callable: the alternative names a function accepts can be listed in its documentation, instead of living undocumented inside a branch.
The standardizer hook#
The original mechanism is still supported and still runs, after the declared tables:
def argument_names_standardization(caller, kwargs):
...
return kwargs
Contract: it takes (caller, kwargs) and returns the mapping. ArgDigest checks both —
the signature when the decorator is built, the return value on each call — and reports a
StandardizerContractError naming the standardizer. Forgetting the return used to
surface much later as AttributeError: 'NoneType' object has no attribute 'items'.
Keep it for a rename that a table cannot state, such as one computed rather than compared. Prefer a table whenever the rule can be written as one: a table is readable, reportable, and composes with others.
Migrating an existing standardizer#
Move the rules out one branch at a time. Both mechanisms run, declared tables first, so the behaviour is preserved at every step and the hook shrinks until it is a no-op — or until only the cases that genuinely need code are left.
Next#
Continue with skip_digestion Behavior.