Skip to content

audit_trail.checks

Static checks of audited models, meant for CI.

check_models inspects the mapped classes of a registry and returns every problem it finds instead of raising on the first one, so a single CI run reports all of them, warnings included::

def test_audited_models() -> None:
    errors = [i for i in check_models(Base) if i.level == "error"]
    assert not errors, "\n".join(map(str, errors))

IssueCode module-attribute

IssueCode: TypeAlias = Literal[
    "sensitive-column",
    "json-in-place",
    "relationship-both-sides",
    "unknown-relationship",
]

What a ModelIssue is about.

IssueLevel module-attribute

IssueLevel: TypeAlias = Literal['error', 'warning']

How serious a ModelIssue is.

ModelIssue dataclass

ModelIssue(
    code: IssueCode,
    model: type[Any],
    attribute: str,
    message: str,
)

One problem found by :func:check_models.

Attributes:

Name Type Description
code IssueCode

What the problem is about.

model type[Any]

The mapped class it was found on.

attribute str

The attribute key it concerns.

message str

A human-readable explanation, with the fix.

level property

level: IssueLevel

"error" for a leak or a corrupt trail, "warning" otherwise.

check_models

check_models(
    base: registry | type[Any],
    *,
    allow_names: Collection[str] = (),
) -> list[ModelIssue]

Check the audited models of a registry for configuration mistakes.

Configures the registry's mappers first. Reports:

  • sensitive-column (error): a column of an Audited model whose attribute key or database column name looks sensitive and that has no explicit info={"audit": ...} policy. A name looks sensitive when it contains password or passwd, has one of the words pwd, secret(s), token(s), credential(s), apikey or encrypted, or ends in _key; words are split on _ and camelCase. Names given to global_redact do not count: they are an extra safety net, not a decision about the column.
  • json-in-place (warning): an audited JSON column that is neither tracked by sqlalchemy.ext.mutable nor listed in AuditOptions.snapshot_on_load. Its in-place changes (obj.data["a"] = 1) are not detected. Mutable detects them but leaves the old value unknown; snapshot_on_load alone keeps the old value but only helps when the change is flagged (flag_modified).
  • relationship-both-sides (error): both sides of one relationship (back_populates or backref) are tracked, through AuditOptions.track_relationships or track_relationships(); every membership change would be recorded twice.
  • unknown-relationship (error): AuditOptions.track_relationships names something that is not a collection relationship of the model (a missing name, a column or a scalar relationship).

Whether a column is Mutable is found by assigning {} and [] to the attribute of a throwaway instance created without __init__; no session and no SQL are involved, but the model's own set listeners and validators run.

Parameters:

Name Type Description Default
base registry | type[Any]

A registry, or a declarative base class that has one.

required
allow_names Collection[str]

"Model.attribute" entries (class __qualname__ and attribute key) whose names only look sensitive; they are not reported as sensitive-column.

()

Returns:

Type Description
list[ModelIssue]

The issues, sorted by model, attribute and code. Empty when there

list[ModelIssue]

are none.

Raises:

Type Description
TypeError

base is neither a registry nor has one.

FieldPolicyError

A column has an unknown audit policy.

Source code in audit_trail/checks.py
def check_models(
    base: registry | type[Any], *, allow_names: Collection[str] = ()
) -> list[ModelIssue]:
    """Check the audited models of a registry for configuration mistakes.

    Configures the registry's mappers first. Reports:

    - ``sensitive-column`` (error): a column of an ``Audited`` model whose
      attribute key or database column name looks sensitive and that has no
      explicit ``info={"audit": ...}`` policy. A name looks sensitive when it
      contains ``password`` or ``passwd``, has one of the words ``pwd``,
      ``secret(s)``, ``token(s)``, ``credential(s)``, ``apikey`` or
      ``encrypted``, or ends in ``_key``; words are split on ``_`` and
      camelCase. Names given to ``global_redact`` do not count: they are an
      extra safety net, not a decision about the column.
    - ``json-in-place`` (warning): an audited JSON column that is neither
      tracked by ``sqlalchemy.ext.mutable`` nor listed in
      ``AuditOptions.snapshot_on_load``. Its in-place changes
      (``obj.data["a"] = 1``) are not detected. ``Mutable`` detects them but
      leaves the old value unknown; ``snapshot_on_load`` alone keeps the old
      value but only helps when the change is flagged (``flag_modified``).
    - ``relationship-both-sides`` (error): both sides of one relationship
      (``back_populates`` or ``backref``) are tracked, through
      ``AuditOptions.track_relationships`` or ``track_relationships()``;
      every membership change would be recorded twice.
    - ``unknown-relationship`` (error): ``AuditOptions.track_relationships``
      names something that is not a collection relationship of the model
      (a missing name, a column or a scalar relationship).

    Whether a column is ``Mutable`` is found by assigning ``{}`` and ``[]``
    to the attribute of a throwaway instance created without ``__init__``;
    no session and no SQL are involved, but the model's own ``set``
    listeners and validators run.

    Args:
        base: A ``registry``, or a declarative base class that has one.
        allow_names: ``"Model.attribute"`` entries (class ``__qualname__``
            and attribute key) whose names only look sensitive; they are not
            reported as ``sensitive-column``.

    Returns:
        The issues, sorted by model, attribute and code. Empty when there
        are none.

    Raises:
        TypeError: ``base`` is neither a registry nor has one.
        FieldPolicyError: A column has an unknown audit policy.
    """
    reg = _registry_of(base)
    reg.configure()
    allowed = frozenset(allow_names)
    # Base classes first, so an inherited column is reported where it is
    # declared rather than once per subclass.
    mappers = sorted(
        reg.mappers, key=lambda m: (len(m.class_.__mro__), m.class_.__qualname__)
    )
    issues: list[ModelIssue] = []
    seen_columns: set[Column[Any]] = set()
    for mapper in mappers:
        if issubclass(mapper.class_, Audited):
            issues.extend(_check_columns(mapper, allowed, seen_columns))
    issues.extend(_check_relationships(mappers))
    issues.sort(key=lambda i: (i.model.__qualname__, i.attribute, i.code))
    return issues