mpat 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mpat-0.2.0 → mpat-0.2.2}/PKG-INFO +112 -9
- {mpat-0.2.0 → mpat-0.2.2}/README.md +111 -8
- {mpat-0.2.0 → mpat-0.2.2}/pyproject.toml +1 -1
- {mpat-0.2.0 → mpat-0.2.2}/pyproject.toml.orig +1 -1
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/__init__.py +6 -2
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_api.py +135 -30
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_cli.py +40 -7
- mpat-0.2.2/src/mpat/_config.py +270 -0
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_errors.py +4 -0
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_fingerprint.py +23 -7
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_generated.py +8 -4
- mpat-0.2.2/src/mpat/_hooks.py +98 -0
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_lock.py +27 -8
- mpat-0.2.2/src/mpat/_pytest_plugin.py +244 -0
- mpat-0.2.2/src/mpat/_registry.py +183 -0
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_targets.py +20 -5
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/_until.py +45 -1
- mpat-0.2.0/src/mpat/_config.py +0 -48
- mpat-0.2.0/src/mpat/_pytest_plugin.py +0 -172
- mpat-0.2.0/src/mpat/_registry.py +0 -93
- {mpat-0.2.0 → mpat-0.2.2}/LICENSE +0 -0
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/py.typed +0 -0
- {mpat-0.2.0 → mpat-0.2.2}/src/mpat/testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpat
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Lockfile-based upstream drift tracking for Python monkeypatches
|
|
5
5
|
Author: Daniel Yudelevich
|
|
6
6
|
License-Expression: MIT
|
|
@@ -78,10 +78,11 @@ patching. It tracks the body of functions and classes, the getter of a
|
|
|
78
78
|
property, and the value of scalar constants and tuples of scalars. Every other
|
|
79
79
|
attribute, including dicts, lists and custom descriptors, is recorded as
|
|
80
80
|
existence only, so mutating a watched registry's contents will not fail
|
|
81
|
-
`mpat check`; watch the function or class that populates it instead.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
`mpat check`; watch the function or class that populates it instead.
|
|
82
|
+
`watch(..., track_value=False)` records that a scalar exists and its kind but not
|
|
83
|
+
its value, for settings your application assigns itself; if you want the value
|
|
84
|
+
pinned, put the assignment and the `watch()` in the same module.
|
|
85
|
+
`review_by` is a nag date only. `on_drift="warn" | "skip" | "raise"`
|
|
85
86
|
decides what happens at import when the lock no longer matches; `MPAT_STRICT=1`
|
|
86
87
|
forces `raise`. Passing a target as an object instead of a dotted string works
|
|
87
88
|
too, resolved through `__module__` and `__qualname__`. Two dotted targets that
|
|
@@ -89,6 +90,41 @@ name the same attribute of the same owner are refused as aliases, but the same
|
|
|
89
90
|
inherited method on two sibling subclasses is not an alias: each patch lands on
|
|
90
91
|
its own class and the base class is left alone.
|
|
91
92
|
|
|
93
|
+
Say when a workaround can go. `until` is accepted by `patch` and by `watch`:
|
|
94
|
+
`Version` takes a requirement string, `Probe` takes a zero-argument callable,
|
|
95
|
+
and both combine with `|` and `&`. On a patch it also turns the patch off once
|
|
96
|
+
satisfied. On a watch it has no runtime effect, because a watch applies
|
|
97
|
+
nothing, but either way the generated `still-needed[<target>]` test fails the
|
|
98
|
+
day upstream is fixed. That makes a `watch` with `until` the replacement for a
|
|
99
|
+
hand-written "still needed" test around a workaround that is not a `@patch`
|
|
100
|
+
target, such as a per-instance `setattr` wrapper or an attribute your code
|
|
101
|
+
creates on an upstream object:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
watch(
|
|
105
|
+
"ontospy.core.ontospy.Ontospy",
|
|
106
|
+
until=Version("ontospy>=2.2") | Probe(ontospy_still_needs_shim),
|
|
107
|
+
note="data/upstream_shims.py adds .namespaces; see ontospy#120",
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`patch` imports the target when the decorator runs. For an optional dependency
|
|
112
|
+
that is the wrong moment, so `when_imported=True` defers it:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@patch("optionallib.Client.request", when_imported=True)
|
|
116
|
+
def request(original, self, *args, **kwargs):
|
|
117
|
+
...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The declaration registers without importing anything. A post-import hook
|
|
121
|
+
applies the patch the first time `optionallib` is imported, or right away if it
|
|
122
|
+
already is, and the `until`, drift and kind checks run at that point. If
|
|
123
|
+
`optionallib` is never imported, nothing happens. `mpat.apply_all()` imports
|
|
124
|
+
every module a deferred patch is still waiting on, for code that wants the
|
|
125
|
+
patches in place before it starts. Locking is unchanged: `mpat lock` has to be
|
|
126
|
+
able to import the target.
|
|
127
|
+
|
|
92
128
|
## Lock and check
|
|
93
129
|
|
|
94
130
|
```toml
|
|
@@ -97,11 +133,15 @@ its own class and the base class is left alone.
|
|
|
97
133
|
modules = ["myapp.patches"]
|
|
98
134
|
```
|
|
99
135
|
|
|
136
|
+
Or the same keys, without the `[tool.mpat]` prefix, in a standalone `mpat.toml`
|
|
137
|
+
next to it. `mpat.toml` wins when both exist.
|
|
138
|
+
|
|
100
139
|
```
|
|
101
140
|
mpat lock # fingerprint everything, write mpat.lock, commit it
|
|
102
141
|
mpat check # exit 1 unless every target is ok
|
|
103
142
|
mpat check --json
|
|
104
143
|
mpat show somelib.client.Client.request
|
|
144
|
+
mpat diff somelib.client.Client.request
|
|
105
145
|
```
|
|
106
146
|
|
|
107
147
|
`mpat lock` and `mpat check` import the modules listed in `[tool.mpat] modules`
|
|
@@ -189,6 +229,63 @@ change.
|
|
|
189
229
|
Run `mpat check` on dependency-bump MRs. When it fails, read the upstream
|
|
190
230
|
change, fix or delete the patch, run `mpat lock`, commit.
|
|
191
231
|
|
|
232
|
+
## Declare without code
|
|
233
|
+
|
|
234
|
+
A watch needs no Python at all. Declare it in `pyproject.toml` (or `mpat.toml`) and `mpat lock`,
|
|
235
|
+
`mpat check` and the pytest plugin pick it up alongside the modules:
|
|
236
|
+
|
|
237
|
+
```toml
|
|
238
|
+
[[tool.mpat.watch]]
|
|
239
|
+
target = "qdrant_client.async_qdrant_remote.AsyncQdrantRemote.query_points"
|
|
240
|
+
depends_on = ["qdrant_client.async_qdrant_remote.AsyncQdrantRemote.scroll"]
|
|
241
|
+
review_by = 2026-12-01
|
|
242
|
+
note = "protobuf timeout wrapper in data/qdrant.py relies on this shape"
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`target` is required; `depends_on`, `review_by` and `note` mean what they mean
|
|
246
|
+
on `watch()`. `until` is a string: a requirement with a version specifier such
|
|
247
|
+
as `until = "ontospy>=2.2"` becomes `Version`, and a dotted path such as
|
|
248
|
+
`until = "data.upstream_shims.ontospy_still_needs_shim"` becomes a `Probe`
|
|
249
|
+
that imports the callable when first evaluated. The entry is locked with `declared_in = "pyproject.toml"`, the
|
|
250
|
+
denylist applies to it and its `depends_on`, and a malformed entry is a
|
|
251
|
+
configuration error (exit 2) that names the entry.
|
|
252
|
+
|
|
253
|
+
A patch that only assigns a scalar needs no code either:
|
|
254
|
+
|
|
255
|
+
```toml
|
|
256
|
+
[[tool.mpat.override]]
|
|
257
|
+
target = "engineio.payload.Payload.max_decode_packets"
|
|
258
|
+
value = 500
|
|
259
|
+
note = "engineio default 16 500s long-polling clients (zauberzeug/nicegui#209)"
|
|
260
|
+
review_by = 2026-12-01
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
# the only line the application needs, at startup
|
|
265
|
+
import mpat
|
|
266
|
+
|
|
267
|
+
mpat.apply_overrides()
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`apply_overrides()` assigns every override once per process, after checking
|
|
271
|
+
that the target is an existing attribute of exactly the value's type: `bool`
|
|
272
|
+
is not `int` here, so `value = true` on an int attribute or `value = 1` on a
|
|
273
|
+
bool one raises `UnsupportedTarget`. Each override is also a watch with
|
|
274
|
+
`track_value = false`, so `mpat lock` records that the attribute exists and
|
|
275
|
+
is a scalar, never its value, and `mpat check` is green whether or not the
|
|
276
|
+
override has been applied in that process. A workaround with any logic in it
|
|
277
|
+
is still `@patch`.
|
|
278
|
+
|
|
279
|
+
A project can have `modules`, `[[tool.mpat.watch]]` and `[[tool.mpat.override]]`
|
|
280
|
+
entries in any combination. `modules` is imported before the TOML entries
|
|
281
|
+
resolve, so it is also where a shim that has to run before a watched target can
|
|
282
|
+
be imported at all belongs: a project whose TOML watches target a library that
|
|
283
|
+
only imports once a shim is in place keeps that shim module in `modules`.
|
|
284
|
+
Emptying the list is a configuration error (exit 2) naming the target that could
|
|
285
|
+
not be imported, not a traceback. A target declared twice, in code and in
|
|
286
|
+
`pyproject.toml` or in both TOML forms, is refused rather than silently
|
|
287
|
+
merged, so there is no precedence to remember.
|
|
288
|
+
|
|
192
289
|
## pytest
|
|
193
290
|
|
|
194
291
|
Install mpat, add `[tool.mpat]`, run pytest. The tests register themselves:
|
|
@@ -199,19 +296,25 @@ mpat::still-needed[somelib.client.Client.request] PASSED
|
|
|
199
296
|
```
|
|
200
297
|
|
|
201
298
|
One `drift` item per declared or locked target, one `still-needed` item per
|
|
202
|
-
patch with `until`. They belong to no file of yours, so they are collected by
|
|
299
|
+
patch or watch with `until`. They belong to no file of yours, so they are collected by
|
|
203
300
|
`pytest` and by `pytest <dir>`, and left out when you name a file or a nodeid:
|
|
204
301
|
`pytest tests/test_client.py` runs what you asked for and nothing else. Disable
|
|
205
302
|
them everywhere with `--no-mpat`, or `mpat = false` under
|
|
206
303
|
`[tool.pytest.ini_options]`. `pytest-xdist` works: the items are ordered by
|
|
207
304
|
target, so every worker collects the same list.
|
|
208
305
|
|
|
306
|
+
In a monorepo with one `pyproject.toml` per service, `pytest services/api/tests`
|
|
307
|
+
from the repository root finds the service's `[tool.mpat]` by walking up from
|
|
308
|
+
the directory you named, and collects its items as `mpat[services/api]::...`.
|
|
309
|
+
Name several service directories and each gets its own set, read from its own
|
|
310
|
+
`mpat.lock`.
|
|
311
|
+
|
|
209
312
|
Collecting them imports your patch modules the way your application does, so the
|
|
210
313
|
patches are active for the rest of the test session exactly as in production.
|
|
211
314
|
|
|
212
|
-
A project with `[tool.mpat]`
|
|
213
|
-
`mpat::unconfigured` item rather than a green run,
|
|
214
|
-
cannot turn the safety net green. A project with no `[tool.mpat]` at all collects
|
|
315
|
+
A project with `[tool.mpat]` but no `modules` and no `[[tool.mpat.watch]]`
|
|
316
|
+
entries gets a single failing `mpat::unconfigured` item rather than a green run,
|
|
317
|
+
so a typo in the module list cannot turn the safety net green. A project with no `[tool.mpat]` at all collects
|
|
215
318
|
nothing.
|
|
216
319
|
|
|
217
320
|
The explicit form still works and wins when both are present:
|
|
@@ -57,10 +57,11 @@ patching. It tracks the body of functions and classes, the getter of a
|
|
|
57
57
|
property, and the value of scalar constants and tuples of scalars. Every other
|
|
58
58
|
attribute, including dicts, lists and custom descriptors, is recorded as
|
|
59
59
|
existence only, so mutating a watched registry's contents will not fail
|
|
60
|
-
`mpat check`; watch the function or class that populates it instead.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
`mpat check`; watch the function or class that populates it instead.
|
|
61
|
+
`watch(..., track_value=False)` records that a scalar exists and its kind but not
|
|
62
|
+
its value, for settings your application assigns itself; if you want the value
|
|
63
|
+
pinned, put the assignment and the `watch()` in the same module.
|
|
64
|
+
`review_by` is a nag date only. `on_drift="warn" | "skip" | "raise"`
|
|
64
65
|
decides what happens at import when the lock no longer matches; `MPAT_STRICT=1`
|
|
65
66
|
forces `raise`. Passing a target as an object instead of a dotted string works
|
|
66
67
|
too, resolved through `__module__` and `__qualname__`. Two dotted targets that
|
|
@@ -68,6 +69,41 @@ name the same attribute of the same owner are refused as aliases, but the same
|
|
|
68
69
|
inherited method on two sibling subclasses is not an alias: each patch lands on
|
|
69
70
|
its own class and the base class is left alone.
|
|
70
71
|
|
|
72
|
+
Say when a workaround can go. `until` is accepted by `patch` and by `watch`:
|
|
73
|
+
`Version` takes a requirement string, `Probe` takes a zero-argument callable,
|
|
74
|
+
and both combine with `|` and `&`. On a patch it also turns the patch off once
|
|
75
|
+
satisfied. On a watch it has no runtime effect, because a watch applies
|
|
76
|
+
nothing, but either way the generated `still-needed[<target>]` test fails the
|
|
77
|
+
day upstream is fixed. That makes a `watch` with `until` the replacement for a
|
|
78
|
+
hand-written "still needed" test around a workaround that is not a `@patch`
|
|
79
|
+
target, such as a per-instance `setattr` wrapper or an attribute your code
|
|
80
|
+
creates on an upstream object:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
watch(
|
|
84
|
+
"ontospy.core.ontospy.Ontospy",
|
|
85
|
+
until=Version("ontospy>=2.2") | Probe(ontospy_still_needs_shim),
|
|
86
|
+
note="data/upstream_shims.py adds .namespaces; see ontospy#120",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`patch` imports the target when the decorator runs. For an optional dependency
|
|
91
|
+
that is the wrong moment, so `when_imported=True` defers it:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
@patch("optionallib.Client.request", when_imported=True)
|
|
95
|
+
def request(original, self, *args, **kwargs):
|
|
96
|
+
...
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The declaration registers without importing anything. A post-import hook
|
|
100
|
+
applies the patch the first time `optionallib` is imported, or right away if it
|
|
101
|
+
already is, and the `until`, drift and kind checks run at that point. If
|
|
102
|
+
`optionallib` is never imported, nothing happens. `mpat.apply_all()` imports
|
|
103
|
+
every module a deferred patch is still waiting on, for code that wants the
|
|
104
|
+
patches in place before it starts. Locking is unchanged: `mpat lock` has to be
|
|
105
|
+
able to import the target.
|
|
106
|
+
|
|
71
107
|
## Lock and check
|
|
72
108
|
|
|
73
109
|
```toml
|
|
@@ -76,11 +112,15 @@ its own class and the base class is left alone.
|
|
|
76
112
|
modules = ["myapp.patches"]
|
|
77
113
|
```
|
|
78
114
|
|
|
115
|
+
Or the same keys, without the `[tool.mpat]` prefix, in a standalone `mpat.toml`
|
|
116
|
+
next to it. `mpat.toml` wins when both exist.
|
|
117
|
+
|
|
79
118
|
```
|
|
80
119
|
mpat lock # fingerprint everything, write mpat.lock, commit it
|
|
81
120
|
mpat check # exit 1 unless every target is ok
|
|
82
121
|
mpat check --json
|
|
83
122
|
mpat show somelib.client.Client.request
|
|
123
|
+
mpat diff somelib.client.Client.request
|
|
84
124
|
```
|
|
85
125
|
|
|
86
126
|
`mpat lock` and `mpat check` import the modules listed in `[tool.mpat] modules`
|
|
@@ -168,6 +208,63 @@ change.
|
|
|
168
208
|
Run `mpat check` on dependency-bump MRs. When it fails, read the upstream
|
|
169
209
|
change, fix or delete the patch, run `mpat lock`, commit.
|
|
170
210
|
|
|
211
|
+
## Declare without code
|
|
212
|
+
|
|
213
|
+
A watch needs no Python at all. Declare it in `pyproject.toml` (or `mpat.toml`) and `mpat lock`,
|
|
214
|
+
`mpat check` and the pytest plugin pick it up alongside the modules:
|
|
215
|
+
|
|
216
|
+
```toml
|
|
217
|
+
[[tool.mpat.watch]]
|
|
218
|
+
target = "qdrant_client.async_qdrant_remote.AsyncQdrantRemote.query_points"
|
|
219
|
+
depends_on = ["qdrant_client.async_qdrant_remote.AsyncQdrantRemote.scroll"]
|
|
220
|
+
review_by = 2026-12-01
|
|
221
|
+
note = "protobuf timeout wrapper in data/qdrant.py relies on this shape"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`target` is required; `depends_on`, `review_by` and `note` mean what they mean
|
|
225
|
+
on `watch()`. `until` is a string: a requirement with a version specifier such
|
|
226
|
+
as `until = "ontospy>=2.2"` becomes `Version`, and a dotted path such as
|
|
227
|
+
`until = "data.upstream_shims.ontospy_still_needs_shim"` becomes a `Probe`
|
|
228
|
+
that imports the callable when first evaluated. The entry is locked with `declared_in = "pyproject.toml"`, the
|
|
229
|
+
denylist applies to it and its `depends_on`, and a malformed entry is a
|
|
230
|
+
configuration error (exit 2) that names the entry.
|
|
231
|
+
|
|
232
|
+
A patch that only assigns a scalar needs no code either:
|
|
233
|
+
|
|
234
|
+
```toml
|
|
235
|
+
[[tool.mpat.override]]
|
|
236
|
+
target = "engineio.payload.Payload.max_decode_packets"
|
|
237
|
+
value = 500
|
|
238
|
+
note = "engineio default 16 500s long-polling clients (zauberzeug/nicegui#209)"
|
|
239
|
+
review_by = 2026-12-01
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
# the only line the application needs, at startup
|
|
244
|
+
import mpat
|
|
245
|
+
|
|
246
|
+
mpat.apply_overrides()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`apply_overrides()` assigns every override once per process, after checking
|
|
250
|
+
that the target is an existing attribute of exactly the value's type: `bool`
|
|
251
|
+
is not `int` here, so `value = true` on an int attribute or `value = 1` on a
|
|
252
|
+
bool one raises `UnsupportedTarget`. Each override is also a watch with
|
|
253
|
+
`track_value = false`, so `mpat lock` records that the attribute exists and
|
|
254
|
+
is a scalar, never its value, and `mpat check` is green whether or not the
|
|
255
|
+
override has been applied in that process. A workaround with any logic in it
|
|
256
|
+
is still `@patch`.
|
|
257
|
+
|
|
258
|
+
A project can have `modules`, `[[tool.mpat.watch]]` and `[[tool.mpat.override]]`
|
|
259
|
+
entries in any combination. `modules` is imported before the TOML entries
|
|
260
|
+
resolve, so it is also where a shim that has to run before a watched target can
|
|
261
|
+
be imported at all belongs: a project whose TOML watches target a library that
|
|
262
|
+
only imports once a shim is in place keeps that shim module in `modules`.
|
|
263
|
+
Emptying the list is a configuration error (exit 2) naming the target that could
|
|
264
|
+
not be imported, not a traceback. A target declared twice, in code and in
|
|
265
|
+
`pyproject.toml` or in both TOML forms, is refused rather than silently
|
|
266
|
+
merged, so there is no precedence to remember.
|
|
267
|
+
|
|
171
268
|
## pytest
|
|
172
269
|
|
|
173
270
|
Install mpat, add `[tool.mpat]`, run pytest. The tests register themselves:
|
|
@@ -178,19 +275,25 @@ mpat::still-needed[somelib.client.Client.request] PASSED
|
|
|
178
275
|
```
|
|
179
276
|
|
|
180
277
|
One `drift` item per declared or locked target, one `still-needed` item per
|
|
181
|
-
patch with `until`. They belong to no file of yours, so they are collected by
|
|
278
|
+
patch or watch with `until`. They belong to no file of yours, so they are collected by
|
|
182
279
|
`pytest` and by `pytest <dir>`, and left out when you name a file or a nodeid:
|
|
183
280
|
`pytest tests/test_client.py` runs what you asked for and nothing else. Disable
|
|
184
281
|
them everywhere with `--no-mpat`, or `mpat = false` under
|
|
185
282
|
`[tool.pytest.ini_options]`. `pytest-xdist` works: the items are ordered by
|
|
186
283
|
target, so every worker collects the same list.
|
|
187
284
|
|
|
285
|
+
In a monorepo with one `pyproject.toml` per service, `pytest services/api/tests`
|
|
286
|
+
from the repository root finds the service's `[tool.mpat]` by walking up from
|
|
287
|
+
the directory you named, and collects its items as `mpat[services/api]::...`.
|
|
288
|
+
Name several service directories and each gets its own set, read from its own
|
|
289
|
+
`mpat.lock`.
|
|
290
|
+
|
|
188
291
|
Collecting them imports your patch modules the way your application does, so the
|
|
189
292
|
patches are active for the rest of the test session exactly as in production.
|
|
190
293
|
|
|
191
|
-
A project with `[tool.mpat]`
|
|
192
|
-
`mpat::unconfigured` item rather than a green run,
|
|
193
|
-
cannot turn the safety net green. A project with no `[tool.mpat]` at all collects
|
|
294
|
+
A project with `[tool.mpat]` but no `modules` and no `[[tool.mpat.watch]]`
|
|
295
|
+
entries gets a single failing `mpat::unconfigured` item rather than a green run,
|
|
296
|
+
so a typo in the module list cannot turn the safety net green. A project with no `[tool.mpat]` at all collects
|
|
194
297
|
nothing.
|
|
195
298
|
|
|
196
299
|
The explicit form still works and wins when both are present:
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""mpat: lockfile-based upstream drift tracking for Python monkeypatches."""
|
|
2
2
|
|
|
3
|
-
from mpat._api import patch, watch
|
|
3
|
+
from mpat._api import apply_all, apply_overrides, patch, watch
|
|
4
4
|
from mpat._errors import (
|
|
5
5
|
AlreadyPatched,
|
|
6
6
|
ForbiddenTarget,
|
|
7
7
|
KindMismatch,
|
|
8
8
|
LockError,
|
|
9
9
|
MpatError,
|
|
10
|
+
TargetImportError,
|
|
10
11
|
TargetNotFound,
|
|
11
12
|
UnsupportedTarget,
|
|
12
13
|
UpstreamDriftError,
|
|
@@ -14,7 +15,7 @@ from mpat._errors import (
|
|
|
14
15
|
)
|
|
15
16
|
from mpat._until import Probe, Until, Version
|
|
16
17
|
|
|
17
|
-
__version__ = "0.2.
|
|
18
|
+
__version__ = "0.2.2"
|
|
18
19
|
|
|
19
20
|
__all__ = [
|
|
20
21
|
"AlreadyPatched",
|
|
@@ -23,12 +24,15 @@ __all__ = [
|
|
|
23
24
|
"LockError",
|
|
24
25
|
"MpatError",
|
|
25
26
|
"Probe",
|
|
27
|
+
"TargetImportError",
|
|
26
28
|
"TargetNotFound",
|
|
27
29
|
"UnsupportedTarget",
|
|
28
30
|
"Until",
|
|
29
31
|
"UpstreamDriftError",
|
|
30
32
|
"UpstreamDriftWarning",
|
|
31
33
|
"Version",
|
|
34
|
+
"apply_all",
|
|
35
|
+
"apply_overrides",
|
|
32
36
|
"patch",
|
|
33
37
|
"watch",
|
|
34
38
|
]
|
|
@@ -9,15 +9,25 @@ from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
|
|
9
9
|
from datetime import date
|
|
10
10
|
from typing import Any, Protocol, TypeVar
|
|
11
11
|
|
|
12
|
+
from mpat import _hooks
|
|
12
13
|
from mpat._config import runtime_config
|
|
13
|
-
from mpat._errors import
|
|
14
|
+
from mpat._errors import (
|
|
15
|
+
AlreadyPatched,
|
|
16
|
+
KindMismatch,
|
|
17
|
+
TargetNotFound,
|
|
18
|
+
UnsupportedTarget,
|
|
19
|
+
UpstreamDriftError,
|
|
20
|
+
UpstreamDriftWarning,
|
|
21
|
+
)
|
|
14
22
|
from mpat._fingerprint import OK, compare, fingerprint
|
|
15
|
-
from mpat._lock import runtime_lock
|
|
23
|
+
from mpat._lock import expand, runtime_lock
|
|
16
24
|
from mpat._registry import (
|
|
17
25
|
ENV_ON,
|
|
26
|
+
ON_DRIFT_WARN,
|
|
18
27
|
ROLE_PATCH,
|
|
19
28
|
ROLE_WATCH,
|
|
20
29
|
STATUS_APPLIED,
|
|
30
|
+
STATUS_DEFERRED,
|
|
21
31
|
STATUS_SKIPPED_DRIFT,
|
|
22
32
|
STATUS_SKIPPED_UNTIL,
|
|
23
33
|
STATUS_WATCHED,
|
|
@@ -25,7 +35,10 @@ from mpat._registry import (
|
|
|
25
35
|
Declaration,
|
|
26
36
|
applied_for,
|
|
27
37
|
collect_mode,
|
|
38
|
+
config_declarations,
|
|
28
39
|
mark_applied,
|
|
40
|
+
override_originals,
|
|
41
|
+
record_override,
|
|
29
42
|
register,
|
|
30
43
|
)
|
|
31
44
|
from mpat._targets import (
|
|
@@ -41,7 +54,6 @@ from mpat._targets import (
|
|
|
41
54
|
)
|
|
42
55
|
from mpat._until import Until
|
|
43
56
|
|
|
44
|
-
ON_DRIFT_WARN = "warn"
|
|
45
57
|
ON_DRIFT_SKIP = "skip"
|
|
46
58
|
ON_DRIFT_RAISE = "raise"
|
|
47
59
|
ON_DRIFT_VALUES = (ON_DRIFT_WARN, ON_DRIFT_SKIP, ON_DRIFT_RAISE)
|
|
@@ -81,20 +93,48 @@ def _check_declaration_forbidden(canonical: str, depends_on: Sequence[str]) -> N
|
|
|
81
93
|
check_forbidden(canonical_target(dep), allow=allow)
|
|
82
94
|
|
|
83
95
|
|
|
84
|
-
def
|
|
96
|
+
def _dependency(target: str) -> Resolved | None:
|
|
97
|
+
try:
|
|
98
|
+
return resolve(target)
|
|
99
|
+
except (TargetNotFound, ImportError):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _drifted(decl: Declaration, resolved: Resolved) -> list[tuple[str, str]]:
|
|
85
104
|
lock = runtime_lock()
|
|
86
|
-
if lock is None
|
|
87
|
-
return
|
|
88
|
-
|
|
105
|
+
if lock is None:
|
|
106
|
+
return []
|
|
107
|
+
found: list[tuple[str, str]] = []
|
|
108
|
+
for wanted in expand([decl]):
|
|
109
|
+
entry = lock.entries.get(wanted.target)
|
|
110
|
+
if entry is None:
|
|
111
|
+
continue
|
|
112
|
+
current = resolved if wanted.target == decl.target else _dependency(wanted.target)
|
|
113
|
+
if current is None:
|
|
114
|
+
continue
|
|
115
|
+
status = compare(
|
|
116
|
+
locked=entry.fingerprint,
|
|
117
|
+
current=fingerprint(current, track_value=wanted.track_value),
|
|
118
|
+
)
|
|
119
|
+
if status != OK:
|
|
120
|
+
found.append((wanted.target, status))
|
|
121
|
+
return found
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _drift_detail(decl: Declaration, drifted: Sequence[tuple[str, str]]) -> str:
|
|
125
|
+
if len(drifted) == 1 and drifted[0][0] == decl.target:
|
|
126
|
+
return drifted[0][1]
|
|
127
|
+
return ", ".join(f"{target}: {status}" for target, status in drifted)
|
|
89
128
|
|
|
90
129
|
|
|
91
130
|
def _handle_drift(decl: Declaration, resolved: Resolved) -> bool:
|
|
92
131
|
"""Return True when the patch should still be applied."""
|
|
93
|
-
|
|
94
|
-
if
|
|
132
|
+
drifted = _drifted(decl, resolved)
|
|
133
|
+
if not drifted:
|
|
95
134
|
return True
|
|
96
135
|
action = ON_DRIFT_RAISE if os.environ.get(STRICT_ENV) == ENV_ON else decl.on_drift
|
|
97
|
-
|
|
136
|
+
detail = _drift_detail(decl, drifted)
|
|
137
|
+
message = f"{decl.target}: upstream drift ({detail}) since mpat.lock. {decl.note}".rstrip()
|
|
98
138
|
if action == ON_DRIFT_RAISE:
|
|
99
139
|
raise UpstreamDriftError(message)
|
|
100
140
|
warnings.warn(message, UpstreamDriftWarning, stacklevel=_WARN_STACKLEVEL)
|
|
@@ -161,6 +201,46 @@ def _wrap(fn: Callable[..., Any], original: Any, decl: Declaration) -> Callable[
|
|
|
161
201
|
return wrapper
|
|
162
202
|
|
|
163
203
|
|
|
204
|
+
def _check_kind(fn: Replacement, resolved: Resolved) -> None:
|
|
205
|
+
if callable_kind(fn) != callable_kind(resolved.obj):
|
|
206
|
+
raise KindMismatch(
|
|
207
|
+
f"{resolved.target}: original is {callable_kind(resolved.obj)}, "
|
|
208
|
+
f"replacement is {callable_kind(fn)}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _resolve_patchable(canonical: str) -> Resolved:
|
|
213
|
+
resolved = resolve(canonical)
|
|
214
|
+
check_supported(resolved)
|
|
215
|
+
return resolved
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _apply(*, decl: Declaration, fn: Replacement, resolved: Resolved) -> None:
|
|
219
|
+
if decl.until is not None and decl.until():
|
|
220
|
+
decl.status = STATUS_SKIPPED_UNTIL
|
|
221
|
+
log.info("%s: not applied, until=%r is satisfied. %s", decl.target, decl.until, decl.note)
|
|
222
|
+
return
|
|
223
|
+
if not _handle_drift(decl, resolved):
|
|
224
|
+
decl.status = STATUS_SKIPPED_DRIFT
|
|
225
|
+
return
|
|
226
|
+
original = _live_original(resolved, decl)
|
|
227
|
+
wrapper = _wrap(fn=fn, original=original, decl=decl)
|
|
228
|
+
replacement = resolved.descriptor(wrapper) if resolved.descriptor else wrapper
|
|
229
|
+
setattr(resolved.parent, resolved.attr, replacement)
|
|
230
|
+
mark_applied(id(original), decl)
|
|
231
|
+
decl.status = STATUS_APPLIED
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _apply_deferred(*, decl: Declaration, fn: Replacement) -> None:
|
|
235
|
+
resolved = _resolve_patchable(decl.target)
|
|
236
|
+
_check_kind(fn, resolved)
|
|
237
|
+
_apply(decl=decl, fn=fn, resolved=resolved)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _top_level(canonical: str) -> str:
|
|
241
|
+
return canonical.partition(".")[0]
|
|
242
|
+
|
|
243
|
+
|
|
164
244
|
def patch(
|
|
165
245
|
target: str | object,
|
|
166
246
|
*,
|
|
@@ -169,21 +249,18 @@ def patch(
|
|
|
169
249
|
on_drift: str = ON_DRIFT_WARN,
|
|
170
250
|
review_by: date | None = None,
|
|
171
251
|
note: str = "",
|
|
252
|
+
when_imported: bool = False,
|
|
172
253
|
) -> Callable[[F], F]:
|
|
173
254
|
if on_drift not in ON_DRIFT_VALUES:
|
|
174
255
|
raise ValueError(f"on_drift must be one of {ON_DRIFT_VALUES}, got {on_drift!r}")
|
|
175
256
|
canonical = canonical_target(target)
|
|
176
257
|
_check_declaration_forbidden(canonical, depends_on)
|
|
177
|
-
resolved =
|
|
178
|
-
check_supported(resolved)
|
|
258
|
+
resolved = None if when_imported else _resolve_patchable(canonical)
|
|
179
259
|
declared_in = _caller_file()
|
|
180
260
|
|
|
181
261
|
def decorator(fn: F) -> F:
|
|
182
|
-
if
|
|
183
|
-
|
|
184
|
-
f"{canonical}: original is {callable_kind(resolved.obj)}, "
|
|
185
|
-
f"replacement is {callable_kind(fn)}"
|
|
186
|
-
)
|
|
262
|
+
if resolved is not None:
|
|
263
|
+
_check_kind(fn, resolved)
|
|
187
264
|
decl = Declaration(
|
|
188
265
|
target=canonical,
|
|
189
266
|
role=ROLE_PATCH,
|
|
@@ -198,30 +275,57 @@ def patch(
|
|
|
198
275
|
register(decl)
|
|
199
276
|
if collect_mode():
|
|
200
277
|
return fn
|
|
201
|
-
if
|
|
202
|
-
decl
|
|
203
|
-
log.info("%s: not applied, until=%r is satisfied. %s", canonical, until, note)
|
|
278
|
+
if resolved is not None:
|
|
279
|
+
_apply(decl=decl, fn=fn, resolved=resolved)
|
|
204
280
|
return fn
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
wrapper = _wrap(fn=fn, original=original, decl=decl)
|
|
210
|
-
replacement = resolved.descriptor(wrapper) if resolved.descriptor else wrapper
|
|
211
|
-
setattr(resolved.parent, resolved.attr, replacement)
|
|
212
|
-
mark_applied(id(original), decl)
|
|
213
|
-
decl.status = STATUS_APPLIED
|
|
281
|
+
decl.status = STATUS_DEFERRED
|
|
282
|
+
_hooks.when_imported(
|
|
283
|
+
_top_level(canonical), functools.partial(_apply_deferred, decl=decl, fn=fn)
|
|
284
|
+
)
|
|
214
285
|
return fn
|
|
215
286
|
|
|
216
287
|
return decorator
|
|
217
288
|
|
|
218
289
|
|
|
290
|
+
def apply_all() -> None:
|
|
291
|
+
"""Import every module a `when_imported=True` patch is still waiting on."""
|
|
292
|
+
_hooks.import_pending()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def apply_overrides() -> None:
|
|
296
|
+
"""Assign every `[[tool.mpat.override]]` value, once per process."""
|
|
297
|
+
config = runtime_config()
|
|
298
|
+
if config is None:
|
|
299
|
+
return
|
|
300
|
+
declarations = config_declarations(config)
|
|
301
|
+
for decl in declarations:
|
|
302
|
+
register(decl)
|
|
303
|
+
if collect_mode():
|
|
304
|
+
return
|
|
305
|
+
done = override_originals()
|
|
306
|
+
by_target = {decl.target: decl for decl in declarations}
|
|
307
|
+
for spec in config.overrides:
|
|
308
|
+
if spec.target in done:
|
|
309
|
+
continue
|
|
310
|
+
resolved = resolve(spec.target)
|
|
311
|
+
_handle_drift(by_target[spec.target], resolved)
|
|
312
|
+
if type(resolved.static) is not type(spec.value):
|
|
313
|
+
raise UnsupportedTarget(
|
|
314
|
+
f"{spec.target}: override value is {type(spec.value).__name__}, "
|
|
315
|
+
f"attribute is {type(resolved.static).__name__}"
|
|
316
|
+
)
|
|
317
|
+
record_override(spec.target, resolved.static)
|
|
318
|
+
setattr(resolved.parent, resolved.attr, spec.value)
|
|
319
|
+
|
|
320
|
+
|
|
219
321
|
def watch(
|
|
220
322
|
target: str | object,
|
|
221
323
|
*,
|
|
222
324
|
depends_on: Sequence[str] = (),
|
|
325
|
+
until: Until | None = None,
|
|
223
326
|
review_by: date | None = None,
|
|
224
327
|
note: str = "",
|
|
328
|
+
track_value: bool = True,
|
|
225
329
|
) -> None:
|
|
226
330
|
canonical = canonical_target(target)
|
|
227
331
|
_check_declaration_forbidden(canonical, depends_on)
|
|
@@ -231,12 +335,13 @@ def watch(
|
|
|
231
335
|
target=canonical,
|
|
232
336
|
role=ROLE_WATCH,
|
|
233
337
|
depends_on=tuple(depends_on),
|
|
234
|
-
until=
|
|
338
|
+
until=until,
|
|
235
339
|
on_drift=ON_DRIFT_WARN,
|
|
236
340
|
review_by=review_by,
|
|
237
341
|
note=note,
|
|
238
342
|
declared_in=declared_in,
|
|
239
343
|
identity=(declared_in, f"watch:{canonical}"),
|
|
344
|
+
track_value=track_value,
|
|
240
345
|
)
|
|
241
346
|
register(decl)
|
|
242
347
|
if collect_mode():
|