mpat 0.2.1__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.1 → mpat-0.2.2}/PKG-INFO +12 -3
- {mpat-0.2.1 → mpat-0.2.2}/README.md +11 -2
- {mpat-0.2.1 → mpat-0.2.2}/pyproject.toml +1 -1
- {mpat-0.2.1 → mpat-0.2.2}/pyproject.toml.orig +1 -1
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/__init__.py +3 -1
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_api.py +37 -11
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_cli.py +36 -6
- mpat-0.2.2/src/mpat/_config.py +270 -0
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_errors.py +4 -0
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_fingerprint.py +21 -5
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_generated.py +2 -1
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_registry.py +11 -11
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_targets.py +20 -5
- mpat-0.2.1/src/mpat/_config.py +0 -181
- {mpat-0.2.1 → mpat-0.2.2}/LICENSE +0 -0
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_hooks.py +0 -0
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_lock.py +0 -0
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_pytest_plugin.py +0 -0
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/_until.py +0 -0
- {mpat-0.2.1 → mpat-0.2.2}/src/mpat/py.typed +0 -0
- {mpat-0.2.1 → 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
|
|
@@ -133,11 +133,15 @@ able to import the target.
|
|
|
133
133
|
modules = ["myapp.patches"]
|
|
134
134
|
```
|
|
135
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
|
+
|
|
136
139
|
```
|
|
137
140
|
mpat lock # fingerprint everything, write mpat.lock, commit it
|
|
138
141
|
mpat check # exit 1 unless every target is ok
|
|
139
142
|
mpat check --json
|
|
140
143
|
mpat show somelib.client.Client.request
|
|
144
|
+
mpat diff somelib.client.Client.request
|
|
141
145
|
```
|
|
142
146
|
|
|
143
147
|
`mpat lock` and `mpat check` import the modules listed in `[tool.mpat] modules`
|
|
@@ -227,7 +231,7 @@ change, fix or delete the patch, run `mpat lock`, commit.
|
|
|
227
231
|
|
|
228
232
|
## Declare without code
|
|
229
233
|
|
|
230
|
-
A watch needs no Python at all. Declare it in `pyproject.toml` and `mpat lock`,
|
|
234
|
+
A watch needs no Python at all. Declare it in `pyproject.toml` (or `mpat.toml`) and `mpat lock`,
|
|
231
235
|
`mpat check` and the pytest plugin pick it up alongside the modules:
|
|
232
236
|
|
|
233
237
|
```toml
|
|
@@ -273,7 +277,12 @@ override has been applied in that process. A workaround with any logic in it
|
|
|
273
277
|
is still `@patch`.
|
|
274
278
|
|
|
275
279
|
A project can have `modules`, `[[tool.mpat.watch]]` and `[[tool.mpat.override]]`
|
|
276
|
-
entries in any combination.
|
|
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
|
|
277
286
|
`pyproject.toml` or in both TOML forms, is refused rather than silently
|
|
278
287
|
merged, so there is no precedence to remember.
|
|
279
288
|
|
|
@@ -112,11 +112,15 @@ able to import the target.
|
|
|
112
112
|
modules = ["myapp.patches"]
|
|
113
113
|
```
|
|
114
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
|
+
|
|
115
118
|
```
|
|
116
119
|
mpat lock # fingerprint everything, write mpat.lock, commit it
|
|
117
120
|
mpat check # exit 1 unless every target is ok
|
|
118
121
|
mpat check --json
|
|
119
122
|
mpat show somelib.client.Client.request
|
|
123
|
+
mpat diff somelib.client.Client.request
|
|
120
124
|
```
|
|
121
125
|
|
|
122
126
|
`mpat lock` and `mpat check` import the modules listed in `[tool.mpat] modules`
|
|
@@ -206,7 +210,7 @@ change, fix or delete the patch, run `mpat lock`, commit.
|
|
|
206
210
|
|
|
207
211
|
## Declare without code
|
|
208
212
|
|
|
209
|
-
A watch needs no Python at all. Declare it in `pyproject.toml` and `mpat lock`,
|
|
213
|
+
A watch needs no Python at all. Declare it in `pyproject.toml` (or `mpat.toml`) and `mpat lock`,
|
|
210
214
|
`mpat check` and the pytest plugin pick it up alongside the modules:
|
|
211
215
|
|
|
212
216
|
```toml
|
|
@@ -252,7 +256,12 @@ override has been applied in that process. A workaround with any logic in it
|
|
|
252
256
|
is still `@patch`.
|
|
253
257
|
|
|
254
258
|
A project can have `modules`, `[[tool.mpat.watch]]` and `[[tool.mpat.override]]`
|
|
255
|
-
entries in any combination.
|
|
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
|
|
256
265
|
`pyproject.toml` or in both TOML forms, is refused rather than silently
|
|
257
266
|
merged, so there is no precedence to remember.
|
|
258
267
|
|
|
@@ -7,6 +7,7 @@ from mpat._errors import (
|
|
|
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,6 +24,7 @@ __all__ = [
|
|
|
23
24
|
"LockError",
|
|
24
25
|
"MpatError",
|
|
25
26
|
"Probe",
|
|
27
|
+
"TargetImportError",
|
|
26
28
|
"TargetNotFound",
|
|
27
29
|
"UnsupportedTarget",
|
|
28
30
|
"Until",
|
|
@@ -14,12 +14,13 @@ from mpat._config import runtime_config
|
|
|
14
14
|
from mpat._errors import (
|
|
15
15
|
AlreadyPatched,
|
|
16
16
|
KindMismatch,
|
|
17
|
+
TargetNotFound,
|
|
17
18
|
UnsupportedTarget,
|
|
18
19
|
UpstreamDriftError,
|
|
19
20
|
UpstreamDriftWarning,
|
|
20
21
|
)
|
|
21
22
|
from mpat._fingerprint import OK, compare, fingerprint
|
|
22
|
-
from mpat._lock import runtime_lock
|
|
23
|
+
from mpat._lock import expand, runtime_lock
|
|
23
24
|
from mpat._registry import (
|
|
24
25
|
ENV_ON,
|
|
25
26
|
ON_DRIFT_WARN,
|
|
@@ -92,23 +93,48 @@ def _check_declaration_forbidden(canonical: str, depends_on: Sequence[str]) -> N
|
|
|
92
93
|
check_forbidden(canonical_target(dep), allow=allow)
|
|
93
94
|
|
|
94
95
|
|
|
95
|
-
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]]:
|
|
96
104
|
lock = runtime_lock()
|
|
97
|
-
if lock is None
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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)
|
|
103
128
|
|
|
104
129
|
|
|
105
130
|
def _handle_drift(decl: Declaration, resolved: Resolved) -> bool:
|
|
106
131
|
"""Return True when the patch should still be applied."""
|
|
107
|
-
|
|
108
|
-
if
|
|
132
|
+
drifted = _drifted(decl, resolved)
|
|
133
|
+
if not drifted:
|
|
109
134
|
return True
|
|
110
135
|
action = ON_DRIFT_RAISE if os.environ.get(STRICT_ENV) == ENV_ON else decl.on_drift
|
|
111
|
-
|
|
136
|
+
detail = _drift_detail(decl, drifted)
|
|
137
|
+
message = f"{decl.target}: upstream drift ({detail}) since mpat.lock. {decl.note}".rstrip()
|
|
112
138
|
if action == ON_DRIFT_RAISE:
|
|
113
139
|
raise UpstreamDriftError(message)
|
|
114
140
|
warnings.warn(message, UpstreamDriftWarning, stacklevel=_WARN_STACKLEVEL)
|
|
@@ -9,8 +9,8 @@ from collections.abc import Sequence
|
|
|
9
9
|
from datetime import date
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
from mpat._config import Config, load_config
|
|
13
|
-
from mpat._errors import LockError, MpatError
|
|
12
|
+
from mpat._config import CONFIG_FILENAME, PYPROJECT, Config, load_config
|
|
13
|
+
from mpat._errors import LockError, MpatError, TargetNotFound
|
|
14
14
|
from mpat._fingerprint import (
|
|
15
15
|
BODY,
|
|
16
16
|
MOVED,
|
|
@@ -18,6 +18,8 @@ from mpat._fingerprint import (
|
|
|
18
18
|
OK,
|
|
19
19
|
SIGNATURE,
|
|
20
20
|
VALUE,
|
|
21
|
+
Fingerprint,
|
|
22
|
+
compare,
|
|
21
23
|
fingerprint,
|
|
22
24
|
)
|
|
23
25
|
from mpat._lock import (
|
|
@@ -53,6 +55,7 @@ _NO_DECLARATIONS = "no declarations found"
|
|
|
53
55
|
_NO_SOURCE_MARKER = " [signature-only]"
|
|
54
56
|
_NO_SOURCE_NOTICE = "no source available, only the signature is locked"
|
|
55
57
|
_NO_DIST = "(no distribution)"
|
|
58
|
+
_MISSING_CELL = "(missing)"
|
|
56
59
|
_DRIFT_STATUSES = (MISSING, MOVED, SIGNATURE, BODY, VALUE, NO_SOURCE)
|
|
57
60
|
_FOOTERS: tuple[tuple[tuple[str, ...], str], ...] = (
|
|
58
61
|
((UNLOCKED,), "unlocked: run 'mpat lock'"),
|
|
@@ -68,12 +71,11 @@ _FOOTERS: tuple[tuple[tuple[str, ...], str], ...] = (
|
|
|
68
71
|
def _require_config() -> Config:
|
|
69
72
|
config = load_config()
|
|
70
73
|
if config is None:
|
|
71
|
-
raise MpatError("no pyproject.toml found in the current directory or its parents")
|
|
72
|
-
if not config.declares_anything:
|
|
73
74
|
raise MpatError(
|
|
74
|
-
"
|
|
75
|
-
"nothing to collect"
|
|
75
|
+
f"no {PYPROJECT} or {CONFIG_FILENAME} found in the current directory or its parents"
|
|
76
76
|
)
|
|
77
|
+
if not config.declares_anything:
|
|
78
|
+
raise MpatError(f"{config.where} lists no modules and no watch entries; nothing to collect")
|
|
77
79
|
return config
|
|
78
80
|
|
|
79
81
|
|
|
@@ -185,6 +187,31 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
185
187
|
return EXIT_OK
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
def _diff_lines(locked: Fingerprint, current: Fingerprint | None) -> list[str]:
|
|
191
|
+
lines: list[str] = []
|
|
192
|
+
for name, before in dataclasses.asdict(locked).items():
|
|
193
|
+
after = _MISSING_CELL if current is None else getattr(current, name)
|
|
194
|
+
if before != after:
|
|
195
|
+
lines.append(f"{name}: {before} -> {after}")
|
|
196
|
+
return lines
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_diff(args: argparse.Namespace) -> int:
|
|
200
|
+
config = _require_config()
|
|
201
|
+
entry = _existing_lock(config.root).entries.get(args.target)
|
|
202
|
+
if entry is None:
|
|
203
|
+
raise MpatError(f"{args.target}: not in {LOCK_FILENAME}")
|
|
204
|
+
try:
|
|
205
|
+
current = fingerprint(resolve(args.target), track_value=entry.track_value)
|
|
206
|
+
except TargetNotFound:
|
|
207
|
+
current = None
|
|
208
|
+
status = MISSING if current is None else compare(locked=entry.fingerprint, current=current)
|
|
209
|
+
print(f"status: {status}")
|
|
210
|
+
for line in _diff_lines(entry.fingerprint, current):
|
|
211
|
+
print(line)
|
|
212
|
+
return EXIT_OK if status == OK else EXIT_DRIFT
|
|
213
|
+
|
|
214
|
+
|
|
188
215
|
def _parser() -> argparse.ArgumentParser:
|
|
189
216
|
parser = argparse.ArgumentParser(prog="mpat")
|
|
190
217
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -196,6 +223,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
196
223
|
show_parser = sub.add_parser("show", help="print fingerprint and source of a target")
|
|
197
224
|
show_parser.add_argument("target")
|
|
198
225
|
show_parser.set_defaults(fn=cmd_show)
|
|
226
|
+
diff_parser = sub.add_parser("diff", help="show which locked fields of a target changed")
|
|
227
|
+
diff_parser.add_argument("target")
|
|
228
|
+
diff_parser.set_defaults(fn=cmd_diff)
|
|
199
229
|
return parser
|
|
200
230
|
|
|
201
231
|
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from mpat._errors import MpatError
|
|
12
|
+
|
|
13
|
+
PYPROJECT = "pyproject.toml"
|
|
14
|
+
CONFIG_FILENAME = "mpat.toml"
|
|
15
|
+
CONFIG_FILENAMES = (CONFIG_FILENAME, PYPROJECT)
|
|
16
|
+
TOOL_SECTION = "mpat"
|
|
17
|
+
WATCH_KEY = "watch"
|
|
18
|
+
OVERRIDE_KEY = "override"
|
|
19
|
+
|
|
20
|
+
_MODULES = "modules"
|
|
21
|
+
_ALLOW = "allow"
|
|
22
|
+
_SECTION_KEYS = frozenset({_MODULES, _ALLOW, WATCH_KEY, OVERRIDE_KEY})
|
|
23
|
+
_TARGET = "target"
|
|
24
|
+
_DEPENDS_ON = "depends_on"
|
|
25
|
+
_REVIEW_BY = "review_by"
|
|
26
|
+
_NOTE = "note"
|
|
27
|
+
_VALUE = "value"
|
|
28
|
+
_UNTIL = "until"
|
|
29
|
+
_TOML_SCALARS = (int, float, str, bool)
|
|
30
|
+
_WATCH_FIELDS: dict[str, type | tuple[type, ...]] = {
|
|
31
|
+
_TARGET: str,
|
|
32
|
+
_DEPENDS_ON: list,
|
|
33
|
+
_UNTIL: str,
|
|
34
|
+
_REVIEW_BY: date,
|
|
35
|
+
_NOTE: str,
|
|
36
|
+
}
|
|
37
|
+
_OVERRIDE_FIELDS: dict[str, type | tuple[type, ...]] = {
|
|
38
|
+
_TARGET: str,
|
|
39
|
+
_VALUE: _TOML_SCALARS,
|
|
40
|
+
_REVIEW_BY: date,
|
|
41
|
+
_NOTE: str,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class WatchSpec:
|
|
47
|
+
target: str
|
|
48
|
+
depends_on: tuple[str, ...] = ()
|
|
49
|
+
until: str | None = None
|
|
50
|
+
review_by: date | None = None
|
|
51
|
+
note: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class OverrideSpec:
|
|
56
|
+
target: str
|
|
57
|
+
value: int | float | str | bool
|
|
58
|
+
note: str = ""
|
|
59
|
+
review_by: date | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class _Source:
|
|
64
|
+
"""Where the section came from: the file, and how its keys are spelled there."""
|
|
65
|
+
|
|
66
|
+
path: Path
|
|
67
|
+
prefix: str
|
|
68
|
+
section: str
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def name(self) -> str:
|
|
72
|
+
return self.path.name
|
|
73
|
+
|
|
74
|
+
def table(self, key: str) -> str:
|
|
75
|
+
return f"[[{self.prefix}{key}]]"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_MPAT_TOML_SOURCE = {"prefix": "", "section": ""}
|
|
79
|
+
_PYPROJECT_SOURCE = {"prefix": f"tool.{TOOL_SECTION}.", "section": f"[tool.{TOOL_SECTION}] "}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Config:
|
|
84
|
+
root: Path
|
|
85
|
+
path: Path
|
|
86
|
+
modules: tuple[str, ...]
|
|
87
|
+
allow: tuple[str, ...]
|
|
88
|
+
declared: bool = False
|
|
89
|
+
watches: tuple[WatchSpec, ...] = ()
|
|
90
|
+
overrides: tuple[OverrideSpec, ...] = ()
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def declares_anything(self) -> bool:
|
|
94
|
+
return bool(self.modules or self.watches or self.overrides)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def where(self) -> str:
|
|
98
|
+
if self.path.name == CONFIG_FILENAME:
|
|
99
|
+
return CONFIG_FILENAME
|
|
100
|
+
return f"[tool.{TOOL_SECTION}] in {PYPROJECT}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def config_path(root: Path) -> Path | None:
|
|
104
|
+
for name in CONFIG_FILENAMES:
|
|
105
|
+
if (root / name).is_file():
|
|
106
|
+
return root / name
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_config_file(start: Path) -> Path | None:
|
|
111
|
+
for candidate in (start, *start.parents):
|
|
112
|
+
path = config_path(candidate)
|
|
113
|
+
if path is not None:
|
|
114
|
+
return path
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def find_project_root(start: Path) -> Path | None:
|
|
119
|
+
path = find_config_file(start)
|
|
120
|
+
return None if path is None else path.parent
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _entry_error(source: _Source, key: str, label: str, problem: str) -> MpatError:
|
|
124
|
+
return MpatError(f"{source.name}: {source.table(key)} {label}: {problem}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _typed_fields(
|
|
128
|
+
source: _Source,
|
|
129
|
+
key: str,
|
|
130
|
+
label: str,
|
|
131
|
+
data: Mapping[str, Any],
|
|
132
|
+
fields: Mapping[str, type | tuple[type, ...]],
|
|
133
|
+
) -> None:
|
|
134
|
+
for name, value in data.items():
|
|
135
|
+
if name not in fields:
|
|
136
|
+
raise _entry_error(source, key, label, f"unknown key {name!r}")
|
|
137
|
+
if not isinstance(value, fields[name]):
|
|
138
|
+
raise _entry_error(
|
|
139
|
+
source,
|
|
140
|
+
key,
|
|
141
|
+
label,
|
|
142
|
+
f"{name} must be {_type_name(fields[name])}, got {type(value).__name__}",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _type_name(expected: type | tuple[type, ...]) -> str:
|
|
147
|
+
if isinstance(expected, tuple):
|
|
148
|
+
return " or ".join(t.__name__ for t in expected)
|
|
149
|
+
return expected.__name__
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _entries(
|
|
153
|
+
source: _Source, section: Mapping[str, Any], key: str
|
|
154
|
+
) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
155
|
+
raw = section.get(key, [])
|
|
156
|
+
if not isinstance(raw, list):
|
|
157
|
+
raise MpatError(
|
|
158
|
+
f"{source.name}: {source.prefix}{key} must be an array of tables, "
|
|
159
|
+
f"got {type(raw).__name__}"
|
|
160
|
+
)
|
|
161
|
+
for index, data in enumerate(raw):
|
|
162
|
+
if not isinstance(data, dict):
|
|
163
|
+
raise _entry_error(
|
|
164
|
+
source, key, f"#{index}", f"must be a table, got {type(data).__name__}"
|
|
165
|
+
)
|
|
166
|
+
target = data.get(_TARGET)
|
|
167
|
+
label = f"#{index}" if not isinstance(target, str) else f"#{index} ({target})"
|
|
168
|
+
if _TARGET not in data:
|
|
169
|
+
raise _entry_error(source, key, label, f"missing {_TARGET}")
|
|
170
|
+
yield label, data
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _watch_spec(source: _Source, label: str, data: dict[str, Any]) -> WatchSpec:
|
|
174
|
+
_typed_fields(source, WATCH_KEY, label, data, _WATCH_FIELDS)
|
|
175
|
+
depends_on = data.get(_DEPENDS_ON, [])
|
|
176
|
+
if not all(isinstance(dep, str) for dep in depends_on):
|
|
177
|
+
raise _entry_error(source, WATCH_KEY, label, f"{_DEPENDS_ON} must be a list of str")
|
|
178
|
+
return WatchSpec(
|
|
179
|
+
target=data[_TARGET],
|
|
180
|
+
depends_on=tuple(depends_on),
|
|
181
|
+
until=data.get(_UNTIL),
|
|
182
|
+
review_by=data.get(_REVIEW_BY),
|
|
183
|
+
note=data.get(_NOTE, ""),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _override_spec(source: _Source, label: str, data: dict[str, Any]) -> OverrideSpec:
|
|
188
|
+
_typed_fields(source, OVERRIDE_KEY, label, data, _OVERRIDE_FIELDS)
|
|
189
|
+
if _VALUE not in data:
|
|
190
|
+
raise _entry_error(source, OVERRIDE_KEY, label, f"missing {_VALUE}")
|
|
191
|
+
return OverrideSpec(
|
|
192
|
+
target=data[_TARGET],
|
|
193
|
+
value=data[_VALUE],
|
|
194
|
+
note=data.get(_NOTE, ""),
|
|
195
|
+
review_by=data.get(_REVIEW_BY),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _unique_targets(source: _Source, specs: Sequence[WatchSpec | OverrideSpec]) -> None:
|
|
200
|
+
seen: set[str] = set()
|
|
201
|
+
for spec in specs:
|
|
202
|
+
if spec.target in seen:
|
|
203
|
+
raise MpatError(f"{source.name}: {spec.target} is declared twice")
|
|
204
|
+
seen.add(spec.target)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _table(
|
|
208
|
+
source: _Source, data: Mapping[str, Any], key: str, *, parent: str = ""
|
|
209
|
+
) -> Mapping[str, Any]:
|
|
210
|
+
value = data.get(key, {})
|
|
211
|
+
if not isinstance(value, dict):
|
|
212
|
+
name = f"{parent}.{key}" if parent else key
|
|
213
|
+
raise MpatError(f"{source.name}: {name} must be a table, got {type(value).__name__}")
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _known_keys(source: _Source, section: Mapping[str, Any]) -> None:
|
|
218
|
+
for key in section:
|
|
219
|
+
if key not in _SECTION_KEYS:
|
|
220
|
+
raise MpatError(f"{source.name}: {source.section}unknown key {key!r}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _str_list(source: _Source, section: Mapping[str, Any], key: str) -> tuple[str, ...]:
|
|
224
|
+
value = section.get(key, [])
|
|
225
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
226
|
+
raise MpatError(f"{source.name}: {source.section}{key} must be a list of str")
|
|
227
|
+
return tuple(value)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _section(path: Path) -> tuple[_Source, Mapping[str, Any], bool]:
|
|
231
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
232
|
+
if path.name == CONFIG_FILENAME:
|
|
233
|
+
return _Source(path=path, **_MPAT_TOML_SOURCE), data, True
|
|
234
|
+
source = _Source(path=path, **_PYPROJECT_SOURCE)
|
|
235
|
+
tool = _table(source, data, "tool")
|
|
236
|
+
return source, _table(source, tool, TOOL_SECTION, parent="tool"), TOOL_SECTION in tool
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def load_config(start: Path | None = None) -> Config | None:
|
|
240
|
+
path = find_config_file(start or Path.cwd())
|
|
241
|
+
if path is None:
|
|
242
|
+
return None
|
|
243
|
+
source, section, declared = _section(path)
|
|
244
|
+
_known_keys(source, section)
|
|
245
|
+
watches = tuple(
|
|
246
|
+
_watch_spec(source, label, entry) for label, entry in _entries(source, section, WATCH_KEY)
|
|
247
|
+
)
|
|
248
|
+
overrides = tuple(
|
|
249
|
+
_override_spec(source, label, entry)
|
|
250
|
+
for label, entry in _entries(source, section, OVERRIDE_KEY)
|
|
251
|
+
)
|
|
252
|
+
_unique_targets(source, (*watches, *overrides))
|
|
253
|
+
return Config(
|
|
254
|
+
root=path.parent,
|
|
255
|
+
path=path,
|
|
256
|
+
modules=_str_list(source, section, _MODULES),
|
|
257
|
+
allow=_str_list(source, section, _ALLOW),
|
|
258
|
+
declared=declared,
|
|
259
|
+
watches=watches,
|
|
260
|
+
overrides=overrides,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@functools.cache
|
|
265
|
+
def runtime_config() -> Config | None:
|
|
266
|
+
return load_config()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def reset_caches() -> None:
|
|
270
|
+
runtime_config.cache_clear()
|
|
@@ -62,19 +62,28 @@ def _iter_defs(
|
|
|
62
62
|
yield from _iter_defs(handler.body)
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
def _first_line(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> int:
|
|
66
|
+
return min([node.lineno, *(d.lineno for d in node.decorator_list)])
|
|
67
|
+
|
|
68
|
+
|
|
65
69
|
def _definition_node(
|
|
66
|
-
tree: ast.Module, qualname: str
|
|
70
|
+
tree: ast.Module, qualname: str, *, lineno: int | None = None
|
|
67
71
|
) -> ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | None:
|
|
68
72
|
if "<locals>" in qualname:
|
|
69
73
|
return None
|
|
70
74
|
body: list[ast.stmt] = tree.body
|
|
71
75
|
node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | None = None
|
|
72
|
-
|
|
76
|
+
*parents, leaf = qualname.split(".")
|
|
77
|
+
for name in parents:
|
|
73
78
|
node = next((n for n in _iter_defs(body) if n.name == name), None)
|
|
74
79
|
if node is None:
|
|
75
80
|
return None
|
|
76
81
|
body = node.body
|
|
77
|
-
|
|
82
|
+
candidates = [n for n in _iter_defs(body) if n.name == leaf]
|
|
83
|
+
if not candidates:
|
|
84
|
+
return None
|
|
85
|
+
at_line = [n for n in candidates if _first_line(n) == lineno]
|
|
86
|
+
return at_line[0] if at_line else candidates[0]
|
|
78
87
|
|
|
79
88
|
|
|
80
89
|
def _hash(node: ast.AST) -> str:
|
|
@@ -91,14 +100,21 @@ def _source_file(obj: Any) -> str | None:
|
|
|
91
100
|
return None
|
|
92
101
|
|
|
93
102
|
|
|
103
|
+
_SOURCE_FAILURES = (OSError, UnicodeDecodeError, SyntaxError, ValueError)
|
|
104
|
+
|
|
105
|
+
|
|
94
106
|
def _source_hash(obj: Any, file: str) -> str | None:
|
|
95
|
-
|
|
107
|
+
try:
|
|
108
|
+
tree = ast.parse(Path(file).read_text(encoding="utf-8"))
|
|
109
|
+
except _SOURCE_FAILURES:
|
|
110
|
+
return None
|
|
96
111
|
if inspect.ismodule(obj):
|
|
97
112
|
return _hash(tree)
|
|
98
113
|
qualname = getattr(obj, "__qualname__", None)
|
|
99
114
|
if not qualname:
|
|
100
115
|
return None
|
|
101
|
-
|
|
116
|
+
code = getattr(obj, "__code__", None)
|
|
117
|
+
node = _definition_node(tree, qualname, lineno=getattr(code, "co_firstlineno", None))
|
|
102
118
|
return None if node is None else _hash(node)
|
|
103
119
|
|
|
104
120
|
|
|
@@ -20,7 +20,8 @@ UNCONFIGURED_ROLE = "config"
|
|
|
20
20
|
UNCONFIGURED_STATUS = "unconfigured"
|
|
21
21
|
UNCONFIGURED_NOTE = (
|
|
22
22
|
"[tool.mpat] declares nothing; add modules, [[tool.mpat.watch]] or "
|
|
23
|
-
"[[tool.mpat.override]] to pyproject.toml"
|
|
23
|
+
"[[tool.mpat.override]] to pyproject.toml, or the same keys without the "
|
|
24
|
+
"tool.mpat prefix to mpat.toml"
|
|
24
25
|
)
|
|
25
26
|
|
|
26
27
|
|
|
@@ -8,7 +8,7 @@ from datetime import date
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
-
from mpat._config import
|
|
11
|
+
from mpat._config import CONFIG_FILENAMES, Config, OverrideSpec, WatchSpec
|
|
12
12
|
from mpat._errors import MpatError
|
|
13
13
|
from mpat._targets import check_forbidden
|
|
14
14
|
from mpat._until import Until, until_from_string
|
|
@@ -98,8 +98,8 @@ def _check_forbidden(target: str, depends_on: Sequence[str], *, allow: Sequence[
|
|
|
98
98
|
check_forbidden(dep, allow=allow)
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
def watch_declaration(spec: WatchSpec, *,
|
|
102
|
-
declared_in = str(
|
|
101
|
+
def watch_declaration(spec: WatchSpec, *, path: Path) -> Declaration:
|
|
102
|
+
declared_in = str(path)
|
|
103
103
|
return Declaration(
|
|
104
104
|
target=spec.target,
|
|
105
105
|
role=ROLE_WATCH,
|
|
@@ -113,8 +113,8 @@ def watch_declaration(spec: WatchSpec, *, root: Path) -> Declaration:
|
|
|
113
113
|
)
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
def override_declaration(spec: OverrideSpec, *,
|
|
117
|
-
declared_in = str(
|
|
116
|
+
def override_declaration(spec: OverrideSpec, *, path: Path) -> Declaration:
|
|
117
|
+
declared_in = str(path)
|
|
118
118
|
return Declaration(
|
|
119
119
|
target=spec.target,
|
|
120
120
|
role=ROLE_WATCH,
|
|
@@ -130,19 +130,19 @@ def override_declaration(spec: OverrideSpec, *, root: Path) -> Declaration:
|
|
|
130
130
|
|
|
131
131
|
|
|
132
132
|
def config_declarations(config: Config) -> list[Declaration]:
|
|
133
|
-
"""Build the declarations
|
|
133
|
+
"""Build the declarations the configuration file makes, checked against the denylist."""
|
|
134
134
|
for spec in config.watches:
|
|
135
135
|
_check_forbidden(spec.target, spec.depends_on, allow=config.allow)
|
|
136
136
|
for spec in config.overrides:
|
|
137
137
|
_check_forbidden(spec.target, (), allow=config.allow)
|
|
138
138
|
return [
|
|
139
|
-
*(watch_declaration(spec,
|
|
140
|
-
*(override_declaration(spec,
|
|
139
|
+
*(watch_declaration(spec, path=config.path) for spec in config.watches),
|
|
140
|
+
*(override_declaration(spec, path=config.path) for spec in config.overrides),
|
|
141
141
|
]
|
|
142
142
|
|
|
143
143
|
|
|
144
144
|
def from_config(decl: Declaration) -> bool:
|
|
145
|
-
return Path(decl.declared_in).name
|
|
145
|
+
return Path(decl.declared_in).name in CONFIG_FILENAMES
|
|
146
146
|
|
|
147
147
|
|
|
148
148
|
def _register_config(config: Config) -> None:
|
|
@@ -150,14 +150,14 @@ def _register_config(config: Config) -> None:
|
|
|
150
150
|
for decl in config_declarations(config):
|
|
151
151
|
if decl.target in from_code:
|
|
152
152
|
raise MpatError(
|
|
153
|
-
f"{decl.target}: declared twice: {
|
|
153
|
+
f"{decl.target}: declared twice: {config.path.name} and "
|
|
154
154
|
f"{from_code[decl.target].declared_in}"
|
|
155
155
|
)
|
|
156
156
|
register(decl)
|
|
157
157
|
|
|
158
158
|
|
|
159
159
|
def collect_declarations(config: Config, *, apply: bool) -> list[Declaration]:
|
|
160
|
-
"""Import the patch modules, add what
|
|
160
|
+
"""Import the patch modules, add what the configuration file declares, and return it all.
|
|
161
161
|
|
|
162
162
|
apply=True imports the modules the way the application does, so the patches
|
|
163
163
|
take effect and a module cached by this import is the patched one. apply=False
|
|
@@ -7,7 +7,12 @@ from collections.abc import Sequence
|
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from mpat._errors import
|
|
10
|
+
from mpat._errors import (
|
|
11
|
+
ForbiddenTarget,
|
|
12
|
+
TargetImportError,
|
|
13
|
+
TargetNotFound,
|
|
14
|
+
UnsupportedTarget,
|
|
15
|
+
)
|
|
11
16
|
|
|
12
17
|
DEFAULT_DENY: tuple[str, ...] = (
|
|
13
18
|
"mpat",
|
|
@@ -50,19 +55,29 @@ def canonical_target(target: str | object) -> str:
|
|
|
50
55
|
return f"{module}.{qualname}"
|
|
51
56
|
|
|
52
57
|
|
|
53
|
-
def _import_prefix(parts: list[str]) -> tuple[types.ModuleType, list[str]]:
|
|
58
|
+
def _import_prefix(parts: list[str], *, target: str) -> tuple[types.ModuleType, list[str]]:
|
|
54
59
|
for i in range(len(parts), 0, -1):
|
|
55
60
|
name = ".".join(parts[:i])
|
|
56
61
|
try:
|
|
57
62
|
return importlib.import_module(name), parts[i:]
|
|
58
63
|
except ModuleNotFoundError as exc:
|
|
59
64
|
if exc.name is None or not name.startswith(exc.name):
|
|
60
|
-
raise
|
|
65
|
+
raise _import_failed(target=target, exc=exc) from exc
|
|
66
|
+
except ImportError as exc:
|
|
67
|
+
raise _import_failed(target=target, exc=exc) from exc
|
|
61
68
|
raise TargetNotFound(f"no importable module prefix in {'.'.join(parts)!r}")
|
|
62
69
|
|
|
63
70
|
|
|
71
|
+
def _import_failed(*, target: str, exc: ImportError) -> TargetImportError:
|
|
72
|
+
return TargetImportError(
|
|
73
|
+
f"{target!r}: importing it raised {type(exc).__name__}: {exc}. "
|
|
74
|
+
f"If another module has to be imported first for this target to import, "
|
|
75
|
+
f"list that module in [tool.mpat] modules."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
64
79
|
def resolve(target: str) -> Resolved:
|
|
65
|
-
module, rest = _import_prefix(target.split("."))
|
|
80
|
+
module, rest = _import_prefix(target.split("."), target=target)
|
|
66
81
|
if not rest:
|
|
67
82
|
raise TargetNotFound(f"{target!r} is a module, not an attribute of one")
|
|
68
83
|
parent: Any = module
|
|
@@ -112,7 +127,7 @@ def check_forbidden(target: str, *, allow: Sequence[str] = ()) -> None:
|
|
|
112
127
|
if _matches(target, denied):
|
|
113
128
|
raise ForbiddenTarget(
|
|
114
129
|
f"{target!r} matches denylist entry {denied!r}; "
|
|
115
|
-
"add it to [tool.mpat]
|
|
130
|
+
"add it to allow in [tool.mpat] or mpat.toml to override"
|
|
116
131
|
)
|
|
117
132
|
|
|
118
133
|
|
mpat-0.2.1/src/mpat/_config.py
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import functools
|
|
4
|
-
import tomllib
|
|
5
|
-
from collections.abc import Iterator, Mapping, Sequence
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from datetime import date
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
from mpat._errors import MpatError
|
|
12
|
-
|
|
13
|
-
PYPROJECT = "pyproject.toml"
|
|
14
|
-
TOOL_SECTION = "mpat"
|
|
15
|
-
WATCH_KEY = "watch"
|
|
16
|
-
OVERRIDE_KEY = "override"
|
|
17
|
-
|
|
18
|
-
_TARGET = "target"
|
|
19
|
-
_DEPENDS_ON = "depends_on"
|
|
20
|
-
_REVIEW_BY = "review_by"
|
|
21
|
-
_NOTE = "note"
|
|
22
|
-
_VALUE = "value"
|
|
23
|
-
_UNTIL = "until"
|
|
24
|
-
_TOML_SCALARS = (int, float, str, bool)
|
|
25
|
-
_WATCH_FIELDS: dict[str, type | tuple[type, ...]] = {
|
|
26
|
-
_TARGET: str,
|
|
27
|
-
_DEPENDS_ON: list,
|
|
28
|
-
_UNTIL: str,
|
|
29
|
-
_REVIEW_BY: date,
|
|
30
|
-
_NOTE: str,
|
|
31
|
-
}
|
|
32
|
-
_OVERRIDE_FIELDS: dict[str, type | tuple[type, ...]] = {
|
|
33
|
-
_TARGET: str,
|
|
34
|
-
_VALUE: _TOML_SCALARS,
|
|
35
|
-
_REVIEW_BY: date,
|
|
36
|
-
_NOTE: str,
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@dataclass(frozen=True)
|
|
41
|
-
class WatchSpec:
|
|
42
|
-
target: str
|
|
43
|
-
depends_on: tuple[str, ...] = ()
|
|
44
|
-
until: str | None = None
|
|
45
|
-
review_by: date | None = None
|
|
46
|
-
note: str = ""
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@dataclass(frozen=True)
|
|
50
|
-
class OverrideSpec:
|
|
51
|
-
target: str
|
|
52
|
-
value: int | float | str | bool
|
|
53
|
-
note: str = ""
|
|
54
|
-
review_by: date | None = None
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@dataclass(frozen=True)
|
|
58
|
-
class Config:
|
|
59
|
-
root: Path
|
|
60
|
-
modules: tuple[str, ...]
|
|
61
|
-
allow: tuple[str, ...]
|
|
62
|
-
declared: bool = False
|
|
63
|
-
watches: tuple[WatchSpec, ...] = ()
|
|
64
|
-
overrides: tuple[OverrideSpec, ...] = ()
|
|
65
|
-
|
|
66
|
-
@property
|
|
67
|
-
def declares_anything(self) -> bool:
|
|
68
|
-
return bool(self.modules or self.watches or self.overrides)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def find_project_root(start: Path) -> Path | None:
|
|
72
|
-
for candidate in (start, *start.parents):
|
|
73
|
-
if (candidate / PYPROJECT).is_file():
|
|
74
|
-
return candidate
|
|
75
|
-
return None
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _entry_error(key: str, label: str, problem: str) -> MpatError:
|
|
79
|
-
return MpatError(f"{PYPROJECT}: [[tool.{TOOL_SECTION}.{key}]] {label}: {problem}")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _typed_fields(
|
|
83
|
-
key: str, label: str, data: Mapping[str, Any], fields: Mapping[str, type | tuple[type, ...]]
|
|
84
|
-
) -> None:
|
|
85
|
-
for name, value in data.items():
|
|
86
|
-
if name not in fields:
|
|
87
|
-
raise _entry_error(key, label, f"unknown key {name!r}")
|
|
88
|
-
if not isinstance(value, fields[name]):
|
|
89
|
-
raise _entry_error(
|
|
90
|
-
key, label, f"{name} must be {_type_name(fields[name])}, got {type(value).__name__}"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _type_name(expected: type | tuple[type, ...]) -> str:
|
|
95
|
-
if isinstance(expected, tuple):
|
|
96
|
-
return " or ".join(t.__name__ for t in expected)
|
|
97
|
-
return expected.__name__
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _entries(section: Mapping[str, Any], key: str) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
101
|
-
raw = section.get(key, [])
|
|
102
|
-
if not isinstance(raw, list):
|
|
103
|
-
raise MpatError(
|
|
104
|
-
f"{PYPROJECT}: tool.{TOOL_SECTION}.{key} must be an array of tables, "
|
|
105
|
-
f"got {type(raw).__name__}"
|
|
106
|
-
)
|
|
107
|
-
for index, data in enumerate(raw):
|
|
108
|
-
if not isinstance(data, dict):
|
|
109
|
-
raise _entry_error(key, f"#{index}", f"must be a table, got {type(data).__name__}")
|
|
110
|
-
target = data.get(_TARGET)
|
|
111
|
-
label = f"#{index}" if not isinstance(target, str) else f"#{index} ({target})"
|
|
112
|
-
if _TARGET not in data:
|
|
113
|
-
raise _entry_error(key, label, f"missing {_TARGET}")
|
|
114
|
-
yield label, data
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _watch_spec(label: str, data: dict[str, Any]) -> WatchSpec:
|
|
118
|
-
_typed_fields(WATCH_KEY, label, data, _WATCH_FIELDS)
|
|
119
|
-
depends_on = data.get(_DEPENDS_ON, [])
|
|
120
|
-
if not all(isinstance(dep, str) for dep in depends_on):
|
|
121
|
-
raise _entry_error(WATCH_KEY, label, f"{_DEPENDS_ON} must be a list of str")
|
|
122
|
-
return WatchSpec(
|
|
123
|
-
target=data[_TARGET],
|
|
124
|
-
depends_on=tuple(depends_on),
|
|
125
|
-
until=data.get(_UNTIL),
|
|
126
|
-
review_by=data.get(_REVIEW_BY),
|
|
127
|
-
note=data.get(_NOTE, ""),
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def _override_spec(label: str, data: dict[str, Any]) -> OverrideSpec:
|
|
132
|
-
_typed_fields(OVERRIDE_KEY, label, data, _OVERRIDE_FIELDS)
|
|
133
|
-
if _VALUE not in data:
|
|
134
|
-
raise _entry_error(OVERRIDE_KEY, label, f"missing {_VALUE}")
|
|
135
|
-
return OverrideSpec(
|
|
136
|
-
target=data[_TARGET],
|
|
137
|
-
value=data[_VALUE],
|
|
138
|
-
note=data.get(_NOTE, ""),
|
|
139
|
-
review_by=data.get(_REVIEW_BY),
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _unique_targets(specs: Sequence[WatchSpec | OverrideSpec]) -> None:
|
|
144
|
-
seen: set[str] = set()
|
|
145
|
-
for spec in specs:
|
|
146
|
-
if spec.target in seen:
|
|
147
|
-
raise MpatError(
|
|
148
|
-
f"{PYPROJECT}: {spec.target} is declared twice in [tool.{TOOL_SECTION}]"
|
|
149
|
-
)
|
|
150
|
-
seen.add(spec.target)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def load_config(start: Path | None = None) -> Config | None:
|
|
154
|
-
root = find_project_root(start or Path.cwd())
|
|
155
|
-
if root is None:
|
|
156
|
-
return None
|
|
157
|
-
data = tomllib.loads((root / PYPROJECT).read_text(encoding="utf-8"))
|
|
158
|
-
tool = data.get("tool", {})
|
|
159
|
-
section = tool.get(TOOL_SECTION, {})
|
|
160
|
-
watches = tuple(_watch_spec(label, entry) for label, entry in _entries(section, WATCH_KEY))
|
|
161
|
-
overrides = tuple(
|
|
162
|
-
_override_spec(label, entry) for label, entry in _entries(section, OVERRIDE_KEY)
|
|
163
|
-
)
|
|
164
|
-
_unique_targets((*watches, *overrides))
|
|
165
|
-
return Config(
|
|
166
|
-
root=root,
|
|
167
|
-
modules=tuple(section.get("modules", ())),
|
|
168
|
-
allow=tuple(section.get("allow", ())),
|
|
169
|
-
declared=TOOL_SECTION in tool,
|
|
170
|
-
watches=watches,
|
|
171
|
-
overrides=overrides,
|
|
172
|
-
)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
@functools.cache
|
|
176
|
-
def runtime_config() -> Config | None:
|
|
177
|
-
return load_config()
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def reset_caches() -> None:
|
|
181
|
-
runtime_config.cache_clear()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|