FAQ¶
Is this a sandbox?¶
No. mpat is not a security boundary. Anything running in your interpreter can
setattr on anything without asking, and mpat does not try to stop it.
What it does is record what you patched and tell you when upstream moved. The
denylist covers mpat, builtins, sys, importlib, ssl, hashlib, hmac,
secrets, cryptography and certifi. It is there so nobody patches or watches
those by accident, not because a determined caller could not reach them anyway.
[tool.mpat] allow turns any of it off.
Why hash the source file instead of the live object?¶
Because a hash of the live object would not survive a Python upgrade. Bytecode in
__code__.co_code changes between interpreter versions, between optimization
levels, and sometimes between patch releases, so a lockfile committed on 3.11
would report drift everywhere on 3.12 while upstream had not changed at all.
Hashing the unparsed AST of the definition gives the opposite properties. It
ignores formatting and comments, so ruff format upstream does not produce a red
build. It reacts to a changed default, a renamed variable, a new branch. It is the
same value on every interpreter that can parse the file.
It also generalises. Classes and constants have no code object, and mpat has to
fingerprint those too. Signature and async-ness come from the live object,
because that is where they are accurate; the body comes from the file.
The cost is that some targets have no readable source, which is what
no_source is for.
Why does the runtime only warn by default?¶
Because import time is the wrong place to fail. mpat check in CI is the guard;
by the time your application starts, the decision has already been made or
missed, and refusing to boot production because a dependency bump changed a
docstring is worse than the drift.
The default is also the safer of the two behaviours for the patch itself. A patch
whose target drifted is usually still closer to correct than no patch at all, so
on_drift="warn" applies it and tells you.
When you want the strict reading, ask for it. on_drift="raise" per declaration,
on_drift="skip" when a stale patch is more dangerous than a missing one, and
MPAT_STRICT=1 in the environment to force raise everywhere. Setting
MPAT_STRICT=1 in your test run is a good idea.
Does it work with namespace packages?¶
The fingerprint does. The distribution attribution may be approximate.
resolved, signature, source_hash and source_file are computed from the
module and the file, so they are correct either way. dist and dist_version
come from mapping the top-level package name back to an installed distribution.
For a namespace package that several distributions contribute to, that mapping has
more than one answer and mpat records the first one it is given.
The consequence is cosmetic. A row can be grouped under a sibling distribution in
the mpat check table, and the 2.3.1 -> 2.5.0 version hint can refer to that
sibling. Drift detection itself does not use those two fields.
What about .pyc-only installs?¶
They are locked on signature alone. mpat asks inspect for the source file and
then checks that the file exists; for a distribution shipped without its .py
files it does not, so there is no AST to hash.
Those entries get no_source = true, one warning from mpat lock, and a
[signature-only] marker on every mpat check row. Signature and sync/async
changes are still caught. Body changes are not. Treat such a target as much weaker
coverage than the rest of the table, and prefer watching something in your own or
another pure-Python package that would have to change alongside it.
Builtins and compiled extension modules behave the same way, for the same reason.
What cannot be fingerprinted?¶
Anything whose value is not a scalar or a tuple of scalars, and is not a function,
class or property, is recorded as existence only. Its value_repr is
<unhashable> and it stays <unhashable> no matter what happens to its contents.
That includes the case people hit first: a dict used as a registry.
Add a key upstream, remove one, change a handler, and mpat check stays green.
There is no fix inside mpat for this. A dict's contents depend on import order
and on whatever ran before the fingerprint was taken, so a hash of them would be
unstable in the other direction.
Watch the code that populates the registry instead.
Functions defined inside other functions are the other gap. Their __qualname__
contains <locals>, so they cannot be located in the module's AST and get no body
hash. That case is at least visible: the entry is locked with no_source = true
and the row is marked [signature-only]. Watch the enclosing function instead.