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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpat
3
- Version: 0.2.0
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. `until`
82
- turns the patch off once upstream is fixed; `Version` takes a requirement
83
- string, `Probe` takes a zero-argument callable, and both combine with `|` and
84
- `&`. `review_by` is a nag date only. `on_drift="warn" | "skip" | "raise"`
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]` and an empty `modules` gets a single failing
213
- `mpat::unconfigured` item rather than a green run, so a typo in the module list
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. `until`
61
- turns the patch off once upstream is fixed; `Version` takes a requirement
62
- string, `Probe` takes a zero-argument callable, and both combine with `|` and
63
- `&`. `review_by` is a nag date only. `on_drift="warn" | "skip" | "raise"`
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]` and an empty `modules` gets a single failing
192
- `mpat::unconfigured` item rather than a green run, so a typo in the module list
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "mpat"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Lockfile-based upstream drift tracking for Python monkeypatches"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mpat"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Lockfile-based upstream drift tracking for Python monkeypatches"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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.0"
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 AlreadyPatched, KindMismatch, UpstreamDriftError, UpstreamDriftWarning
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 _drift_status(decl: Declaration, resolved: Resolved) -> str:
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 or decl.target not in lock.entries:
87
- return OK
88
- return compare(locked=lock.entries[decl.target].fingerprint, current=fingerprint(resolved))
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
- status = _drift_status(decl, resolved)
94
- if status == OK:
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
- message = f"{decl.target}: upstream drift ({status}) since mpat.lock. {decl.note}".rstrip()
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 = resolve(canonical)
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 callable_kind(fn) != callable_kind(resolved.obj):
183
- raise KindMismatch(
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 until is not None and until():
202
- decl.status = STATUS_SKIPPED_UNTIL
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
- if not _handle_drift(decl, resolved):
206
- decl.status = STATUS_SKIPPED_DRIFT
207
- return fn
208
- original = _live_original(resolved, decl)
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=None,
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():