mpat 0.2.2__tar.gz → 0.3.0__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.2
3
+ Version: 0.3.0
4
4
  Summary: Lockfile-based upstream drift tracking for Python monkeypatches
5
5
  Author: Daniel Yudelevich
6
6
  License-Expression: MIT
@@ -140,6 +140,7 @@ next to it. `mpat.toml` wins when both exist.
140
140
  mpat lock # fingerprint everything, write mpat.lock, commit it
141
141
  mpat check # exit 1 unless every target is ok
142
142
  mpat check --json
143
+ mpat check --gitlab # GitLab Code Quality report
143
144
  mpat show somelib.client.Client.request
144
145
  mpat diff somelib.client.Client.request
145
146
  ```
@@ -119,6 +119,7 @@ next to it. `mpat.toml` wins when both exist.
119
119
  mpat lock # fingerprint everything, write mpat.lock, commit it
120
120
  mpat check # exit 1 unless every target is ok
121
121
  mpat check --json
122
+ mpat check --gitlab # GitLab Code Quality report
122
123
  mpat show somelib.client.Client.request
123
124
  mpat diff somelib.client.Client.request
124
125
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mpat"
3
- version = "0.2.2"
3
+ version = "0.3.0"
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.2"
3
+ version = "0.3.0"
4
4
  description = "Lockfile-based upstream drift tracking for Python monkeypatches"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -15,7 +15,7 @@ from mpat._errors import (
15
15
  )
16
16
  from mpat._until import Probe, Until, Version
17
17
 
18
- __version__ = "0.2.2"
18
+ __version__ = "0.3.0"
19
19
 
20
20
  __all__ = [
21
21
  "AlreadyPatched",
@@ -22,6 +22,7 @@ from mpat._fingerprint import (
22
22
  compare,
23
23
  fingerprint,
24
24
  )
25
+ from mpat._gitlab import code_quality_report
25
26
  from mpat._lock import (
26
27
  LOCK_FILENAME,
27
28
  MISSING,
@@ -167,6 +168,13 @@ def cmd_check(args: argparse.Namespace) -> int:
167
168
  )
168
169
  if args.json:
169
170
  print(json.dumps([dataclasses.asdict(r) for r in results], indent=_JSON_INDENT))
171
+ elif args.gitlab:
172
+ print(
173
+ json.dumps(
174
+ code_quality_report(results, root=config.root, base=args.repo_root),
175
+ indent=_JSON_INDENT,
176
+ )
177
+ )
170
178
  elif not results:
171
179
  print(_NO_DECLARATIONS)
172
180
  else:
@@ -218,7 +226,19 @@ def _parser() -> argparse.ArgumentParser:
218
226
  lock_parser = sub.add_parser("lock", help="fingerprint declared targets and write mpat.lock")
219
227
  lock_parser.set_defaults(fn=cmd_lock)
220
228
  check_parser = sub.add_parser("check", help="compare mpat.lock against installed upstream")
221
- check_parser.add_argument("--json", action="store_true")
229
+ output = check_parser.add_mutually_exclusive_group()
230
+ output.add_argument("--json", action="store_true")
231
+ output.add_argument(
232
+ "--gitlab",
233
+ action="store_true",
234
+ help="print a GitLab Code Quality report of every non-ok target",
235
+ )
236
+ check_parser.add_argument(
237
+ "--repo-root",
238
+ type=Path,
239
+ default=None,
240
+ help="base for report paths (default: the nearest .git above the mpat root)",
241
+ )
222
242
  check_parser.set_defaults(fn=cmd_check)
223
243
  show_parser = sub.add_parser("show", help="print fingerprint and source of a target")
224
244
  show_parser.add_argument("target")
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from mpat._fingerprint import BODY, MOVED, NO_SOURCE, OK, SIGNATURE, VALUE
9
+ from mpat._lock import MISSING, REVIEW, STALE, UNLOCKED, CheckResult
10
+
11
+ SEVERITY_BLOCKER = "blocker"
12
+ SEVERITY_MAJOR = "major"
13
+ SEVERITY_MINOR = "minor"
14
+ SEVERITY_INFO = "info"
15
+
16
+ _GIT = ".git"
17
+ _CHECK_NAME_PREFIX = "mpat"
18
+ _FIRST_LINE = 1
19
+ _FINGERPRINT_SEPARATOR = "|"
20
+
21
+ _SEVERITIES = {
22
+ MISSING: SEVERITY_BLOCKER,
23
+ MOVED: SEVERITY_BLOCKER,
24
+ SIGNATURE: SEVERITY_MAJOR,
25
+ BODY: SEVERITY_MAJOR,
26
+ VALUE: SEVERITY_MAJOR,
27
+ UNLOCKED: SEVERITY_MAJOR,
28
+ NO_SOURCE: SEVERITY_MINOR,
29
+ REVIEW: SEVERITY_MINOR,
30
+ STALE: SEVERITY_INFO,
31
+ }
32
+ _DEFAULT_SEVERITY = SEVERITY_MAJOR
33
+
34
+ _DESCRIPTIONS = {
35
+ MISSING: "no longer exists upstream",
36
+ MOVED: "moved upstream",
37
+ SIGNATURE: "the upstream signature changed",
38
+ BODY: "the upstream body changed",
39
+ VALUE: "the upstream value changed",
40
+ NO_SOURCE: "upstream source is unavailable, only the signature is locked",
41
+ UNLOCKED: f"not in the lock; run '{_CHECK_NAME_PREFIX} lock'",
42
+ STALE: f"in the lock but no longer declared; run '{_CHECK_NAME_PREFIX} lock' to prune",
43
+ REVIEW: "past its review_by date",
44
+ }
45
+ _UNKNOWN_DESCRIPTION = "drifted"
46
+
47
+
48
+ def repo_root(root: Path) -> Path | None:
49
+ """The working tree `root` sits in, found by the `.git` a checkout always has.
50
+
51
+ A worktree and a submodule carry `.git` as a file rather than a directory, so
52
+ existence, not `is_dir`, is the test.
53
+ """
54
+ for candidate in (root, *root.parents):
55
+ if (candidate / _GIT).exists():
56
+ return candidate
57
+ return None
58
+
59
+
60
+ def _report_path(declared_in: str, *, root: Path, base: Path) -> str:
61
+ absolute = Path(declared_in)
62
+ if not absolute.is_absolute():
63
+ absolute = root / absolute
64
+ try:
65
+ return absolute.relative_to(base).as_posix()
66
+ except ValueError:
67
+ return Path(declared_in).as_posix()
68
+
69
+
70
+ def _line_in(text: str, target: str) -> int:
71
+ for number, line in enumerate(text.splitlines(), start=_FIRST_LINE):
72
+ if f'"{target}"' in line or f"'{target}'" in line:
73
+ return number
74
+ return _FIRST_LINE
75
+
76
+
77
+ def _declaration_line(path: Path, target: str, cache: dict[Path, str]) -> int:
78
+ if path not in cache:
79
+ try:
80
+ cache[path] = path.read_text(encoding="utf-8")
81
+ except (OSError, UnicodeDecodeError):
82
+ cache[path] = ""
83
+ return _line_in(cache[path], target)
84
+
85
+
86
+ def _description(result: CheckResult) -> str:
87
+ what = _DESCRIPTIONS.get(result.status, _UNKNOWN_DESCRIPTION)
88
+ note = f" ({result.note})" if result.note else ""
89
+ return f"{result.role} {result.target}: {what}{note}"
90
+
91
+
92
+ def _fingerprint(path: str, result: CheckResult) -> str:
93
+ identity = _FINGERPRINT_SEPARATOR.join((path, result.role, result.target))
94
+ return hashlib.sha256(identity.encode("utf-8")).hexdigest()
95
+
96
+
97
+ def code_quality_report(
98
+ results: Sequence[CheckResult], *, root: Path, base: Path | None = None
99
+ ) -> list[dict[str, Any]]:
100
+ """Render the non-OK results as GitLab Code Quality issues.
101
+
102
+ `location.path` is resolved against the repository root, not against `root`,
103
+ because GitLab reads it relative to the checkout: a `mpat check` run in
104
+ `services/api` must still report `services/api/src/patches.py`.
105
+ """
106
+ base = base or repo_root(root) or root
107
+ cache: dict[Path, str] = {}
108
+ report: list[dict[str, Any]] = []
109
+ for result in results:
110
+ if result.status == OK:
111
+ continue
112
+ path = _report_path(result.declared_in, root=root, base=base)
113
+ report.append(
114
+ {
115
+ "description": _description(result),
116
+ "check_name": f"{_CHECK_NAME_PREFIX}/{result.status}",
117
+ "fingerprint": _fingerprint(path, result),
118
+ "severity": _SEVERITIES.get(result.status, _DEFAULT_SEVERITY),
119
+ "location": {
120
+ "path": path,
121
+ "lines": {"begin": _declaration_line(base / path, result.target, cache)},
122
+ },
123
+ }
124
+ )
125
+ return report
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes