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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpat
3
- Version: 0.2.1
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. A target declared twice, in code and in
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. A target declared twice, in code and in
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mpat"
3
- version = "0.2.1"
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.1"
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"
@@ -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.1"
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 _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]]:
96
104
  lock = runtime_lock()
97
- if lock is None or decl.target not in lock.entries:
98
- return OK
99
- return compare(
100
- locked=lock.entries[decl.target].fingerprint,
101
- current=fingerprint(resolved, track_value=decl.track_value),
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
- status = _drift_status(decl, resolved)
108
- if status == OK:
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
- 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()
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
- "[tool.mpat] in pyproject.toml lists no modules and no watch entries; "
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()
@@ -6,6 +6,10 @@ class TargetNotFound(MpatError):
6
6
  pass
7
7
 
8
8
 
9
+ class TargetImportError(MpatError, ImportError):
10
+ pass
11
+
12
+
9
13
  class UnsupportedTarget(MpatError):
10
14
  pass
11
15
 
@@ -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
- for name in qualname.split("."):
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
- return node
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
- tree = ast.parse(Path(file).read_text(encoding="utf-8"))
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
- node = _definition_node(tree, qualname)
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 PYPROJECT, Config, OverrideSpec, WatchSpec
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, *, root: Path) -> Declaration:
102
- declared_in = str(root / PYPROJECT)
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, *, root: Path) -> Declaration:
117
- declared_in = str(root / PYPROJECT)
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 pyproject.toml makes, checked against the denylist."""
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, root=config.root) for spec in config.watches),
140
- *(override_declaration(spec, root=config.root) for spec in config.overrides),
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 == PYPROJECT
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: {PYPROJECT} and "
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 pyproject.toml declares, and return it all.
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 ForbiddenTarget, TargetNotFound, UnsupportedTarget
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] allow in pyproject.toml to override"
130
+ "add it to allow in [tool.mpat] or mpat.toml to override"
116
131
  )
117
132
 
118
133
 
@@ -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