Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## 0.3.2

- `mpat check` lists only the targets that are not `ok` and ends with an
`N ok` count, so a CI log shows the drift instead of burying it in a table
of every watched symbol. `--all` restores the full table. `--json`,
`--gitlab` and the exit code are unchanged.
- A target deleted upstream is reported as `missing`. Before, `patch()` and
`watch()` resolved their target while `mpat check` was collecting, so the
deletion aborted the run with exit 2, no table and an empty GitLab report.
Outside collection a missing target still raises `TargetNotFound`.
- Rows with a changed signature, value, location or kind list the change under
them, e.g. `value_repr: 16 -> 2`, and the GitLab description carries the
same text. `--json` gains a `changes` list.
- GitLab Code Quality findings point at the line of the `patch()` or `watch()`
call instead of the first line quoting the target, which could be an
unrelated string. `--json` gains `declared_line`.
- A change confined to docstrings reports the new `docstring` status instead of
`body`, with its own footer line and `minor` GitLab severity. It still exits 1.
The lock gains a `code_hash` field for this; the first `mpat lock` after the
upgrade lists every function and class as changed (`~`) while it records it,
and until then a docstring change keeps reporting `body`.
- `mpat diff` and `mpat check --diff` show how a drifted target's upstream
source changed, as a diff in the style of a `ty` diagnostic, with the locked
and installed line numbers side by side. The lock holds hashes, not source,
so the locked side comes from the release archive on the package index
(`UV_DEFAULT_INDEX`, `UV_INDEX_URL`, `PIP_INDEX_URL`, else PyPI), cached in
`MPAT_CACHE_DIR`. Index credentials never follow a redirect to another
host. A fetch failure prints why and leaves the exit code alone. `mpat diff`
without a target covers every drifted target, and now imports the
configured modules like `mpat check`.
- A target used by several declarations, such as a shared `depends_on`, lists
the others under its row and in `--json` (`used_by`), and GitLab gets one
finding per declaration, so every patch that depends on the drift is marked.

## 0.3.1

- A watch can name a file shipped inside an installed package:
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,14 @@ next to it. `mpat.toml` wins when both exist.

```
mpat lock # fingerprint everything, write mpat.lock, commit it
mpat check # exit 1 unless every target is ok
mpat check # exit 1 unless every target is ok; lists only the ones that are not
mpat check --all # list ok targets too
mpat check --diff # show each drifted target's upstream source change
mpat check --json
mpat check --gitlab # GitLab Code Quality report
mpat show somelib.client.Client.request
mpat diff somelib.client.Client.request
mpat diff # every drifted target
```

`mpat lock` and `mpat check` import the modules listed in `[tool.mpat] modules`
Expand Down Expand Up @@ -181,14 +184,16 @@ no_source = false
An entry whose `role` or `parent` no longer matches the declaration is reported
`unlocked`: the audit trail changed meaning, so it has to be re-locked.

`mpat check` reports one row per target with one of these statuses:
`mpat check` reports one row per target with one of these statuses, and
counts the `ok` ones instead of listing them unless given `--all`:

| Status | Meaning |
| --- | --- |
| `ok` | Installed upstream matches the lock. |
| `moved` | The target now resolves elsewhere, or its source file changed. |
| `signature` | The signature changed, or the target switched between sync and async. |
| `body` | The source hash changed. |
| `docstring` | The source hash changed, but only in docstrings. |
| `value` | A watched constant's value changed. |
| `no_source` | The locked source is no longer readable, so only the signature is still comparable. |
| `missing` | The target no longer exists upstream. |
Expand Down
98 changes: 83 additions & 15 deletions docs/site/pages/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,32 @@ mpat: somelib._speedups.encode: no source available, only the signature is locke
## `mpat check`

Compares `mpat.lock` against the installed upstream and prints one row per
target. This is the command for CI and for dependency-bump merge requests.
target that is not `ok`. This is the command for CI and for dependency-bump
merge requests.

```
$ mpat check
# somelib
body patch somelib.client.Client.request myapp/patches.py 2.3.1 -> 2.5.0 Works around somelib#123. Delete when fixed.
ok depends_on somelib.client.Client._send myapp/patches.py 2.3.1 -> 2.5.0 Works around somelib#123. Delete when fixed.
value watch somelib.settings.MAX_RETRIES myapp/patches.py 2.3.1 -> 2.5.0 see somelib#98

2 drifted: read the upstream change, then fix or delete the patch and run 'mpat lock'
1 ok (--all lists them)
```

`ok` targets are only counted, so a clean run prints a single line. `--all`
lists them in the table too.

A target used by more than one declaration lists the others under its row, as
`used by: myapp/retry.py:40 (patch somelib.retry.backoff)`, so a shared
dependency that drifted points at every patch that needs a look.

`--diff` adds the [`mpat diff`](#mpat-diff) block under each drifted row. It
downloads the locked release, so it is opt-in; add it to the CI job to get the
upstream change in the log:

```yaml
- uv run mpat check --diff
```

The columns are status, role, target, the file the declaration came from, the
Expand All @@ -78,6 +94,7 @@ With no declarations at all it prints `no declarations found` and exits 0.
| `moved` | The target now resolves elsewhere, or its source file changed. |
| `signature` | The signature changed, or the target switched between sync and async. |
| `body` | The source hash changed. |
| `docstring` | The source hash changed, but only in docstrings. |
| `content` | A watched file's bytes changed. |
| `value` | A watched constant's value changed. |
| `no_source` | The locked source is no longer readable, so only the signature is still comparable. |
Expand All @@ -88,8 +105,19 @@ With no declarations at all it prints `no declarations found` and exits 0.

Drift beats `review`: a target that both drifted and is overdue reports the drift.

A row with a changed signature, value, location or kind lists the change under
it, so the table says what moved without a separate `mpat diff`:

```
signature patch somelib.client.Client.request myapp/patches.py 2.3.1 -> 2.5.0
signature: (self, method, url, **kwargs) -> (self, method, url, *, timeout, **kwargs)
```

Hash changes are not listed; `body` and `content` already say that much, and
`mpat diff TARGET` shows the hashes.

A row whose target currently has no readable source is marked, whatever its
status:
status, including `ok` rows listed with `--all`:

```
ok [signature-only] watch somelib._speedups.encode myapp/patches.py
Expand All @@ -106,13 +134,17 @@ lines with a non-zero count are printed.
```
1 unlocked: run 'mpat lock'
2 drifted: read the upstream change, then fix or delete the patch and run 'mpat lock'
1 docstring-only: skim the upstream docs change, then run 'mpat lock'
1 stale: run 'mpat lock' to prune
1 due for review
3 ok (--all lists them)
```

`unlocked` and `stale` mean the lockfile is out of date with your code, so the
answer is `mpat lock`. `drifted` means upstream changed under you, so the answer
is to read the upstream change first.
is to read the upstream change first. `docstring` still fails the check, because
a docstring edit can announce a behaviour change, but it is counted apart from
the drift that needs a code review.

### `--json`

Expand All @@ -128,7 +160,10 @@ $ mpat check --json
"locked_version": "2.3.1",
"current_version": "2.5.0",
"no_source": false,
"dist": "somelib"
"dist": "somelib",
"declared_line": 12,
"changes": [],
"used_by": []
}
]
```
Expand Down Expand Up @@ -159,7 +194,12 @@ $ mpat check --gitlab
```

`missing` and `moved` are `blocker`, the fingerprint mismatches and `unlocked`
are `major`, `no_source` and `review` are `minor`, `stale` is `info`. The
are `major`, `no_source`, `docstring` and `review` are `minor`, `stale` is
`info`. The description ends with the same changed fields the table lists.
Every other declaration that uses a drifted target gets its own issue at its own
line, so the merge request marks each patch that depends on it.
`lines.begin` is the line of the `patch()` or `watch()` call, or for a
configuration table and a `stale` entry the first line quoting the target. The
fingerprint is a hash of the path, role and target, so the same drift keeps the
same identity between runs and GitLab does not report it twice.

Expand Down Expand Up @@ -217,22 +257,50 @@ or against the version the lockfile recorded.

## `mpat diff`

Prints the drift status of one locked target, then every fingerprint field whose
locked value differs from the installed one. It reads `mpat.lock` and resolves
the target; it does not import `[tool.mpat] modules`.
Prints the drift status of one locked target, every fingerprint field whose
locked value differs from the installed one, and then how its upstream source
changed.

```
$ mpat diff somelib.client.Client.request
status: signature
signature: (self, method, url, **kwargs) -> (self, method, url, *, timeout=None, **kwargs)
status: body
source_hash: sha256:0684635… -> sha256:9b1c7ee…
dist_version: 2.3.1 -> 2.5.0

body[patch] somelib.client.Client.request: the upstream body changed (2.3.1 -> 2.5.0)
--> somelib/client.py:92
|
88 90 | def request(self, method, url, **kwargs):
89 91 | self.log(method)
90 | - return self._send(method, url, **kwargs)
92 | + return self._send(method, url, timeout=self.timeout, **kwargs)
|
info: declared here
--> myapp/patches.py:12
info: also used by myapp/retry.py:40 (patch somelib.retry.backoff)
```

`ok` prints only the status line. A target that no longer exists prints
`status: missing` and every locked field against `(missing)`. The lock records
hashes, not source, so a `body` change shows the two hashes; read the upstream
diff between the two `dist_version` values for the actual change.
The two gutter columns are the line numbers in the locked and the installed
file. `mpat diff` with no target prints the block for every drifted target.

The lock records hashes, not source, so the locked side is downloaded: the
archive of the locked `dist_version` of the target's distribution, a wheel if
there is one and the sdist otherwise. The index is the first of
`UV_DEFAULT_INDEX`, `UV_INDEX_URL` and `PIP_INDEX_URL` that is set, else PyPI.
Credentials in the index URL, or in `~/.netrc` for its host, are sent to that
host only, never to a host a download redirects to. Archives are cached in
`MPAT_CACHE_DIR`, default `~/.cache/mpat`; point it at a CI cache path to keep
them across jobs. The downloaded definition is hashed again, and a line warns
when it does not match the lock, which happens when a release was rebuilt or
ships per-platform code.

Only `body`, `content`, `docstring`, `signature`, `moved` and `missing` get a
block; `missing` and `moved` show the locked definition as removed. When the
archive cannot be fetched the block says `info: upstream diff unavailable` and
why, and the exit code does not change.

`mpat diff` imports `[tool.mpat] modules` like `mpat check`, so the block knows
where the target is declared and what else uses it.

Exits 0 on `ok`, 1 on any other status, 2 when the target is not in `mpat.lock`.

Expand Down
1 change: 1 addition & 0 deletions docs/site/pages/lockfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ no_source = false
| `dist` | The distribution that installs the target's top-level package |
| `dist_version` | The installed version of that distribution |
| `no_source` | True when the target should have had a source hash and did not |
| `code_hash` | `sha256:` of the same AST node with every docstring removed, so a docstring-only change is told apart from a code change |

Fields with a `None` value are omitted from the table. Entries for constants
carry `value_repr` instead of `source_hash` and `source_file`; entries for
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "mpat"
version = "0.3.1"
version = "0.3.2"
description = "Lockfile-based upstream drift tracking for Python monkeypatches"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/mpat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)
from mpat._until import Probe, Until, Version

__version__ = "0.3.1"
__version__ = "0.3.2"

__all__ = [
"AlreadyPatched",
Expand Down
27 changes: 20 additions & 7 deletions src/mpat/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,13 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: ...


F = TypeVar("F", bound=Replacement)
T = TypeVar("T")
log = logging.getLogger("mpat")


def _caller_file() -> str:
def _caller() -> tuple[str, int]:
frame = inspect.stack()[_DECLARATION_FRAME_DEPTH]
return os.path.abspath(frame.filename)
return os.path.abspath(frame.filename), frame.lineno


def _allow() -> tuple[str, ...]:
Expand Down Expand Up @@ -211,6 +212,16 @@ def _check_kind(fn: Replacement, resolved: Resolved) -> None:
)


def _resolve_declared(resolver: Callable[[str], T], target: str) -> T | None:
"""While collecting, a target deleted upstream is left for check() to report as missing."""
try:
return resolver(target)
except TargetNotFound:
if collect_mode():
return None
raise


def _resolve_patchable(canonical: str) -> Resolved:
resolved = resolve_attribute(canonical)
check_supported(resolved)
Expand Down Expand Up @@ -257,8 +268,8 @@ def patch(
raise ValueError(f"on_drift must be one of {ON_DRIFT_VALUES}, got {on_drift!r}")
canonical = canonical_target(target)
_check_declaration_forbidden(canonical, depends_on)
resolved = None if when_imported else _resolve_patchable(canonical)
declared_in = _caller_file()
resolved = None if when_imported else _resolve_declared(_resolve_patchable, canonical)
declared_in, declared_line = _caller()

def decorator(fn: F) -> F:
if resolved is not None:
Expand All @@ -272,6 +283,7 @@ def decorator(fn: F) -> F:
review_by=review_by,
note=note,
declared_in=declared_in,
declared_line=declared_line,
identity=(declared_in, fn.__qualname__),
)
register(decl)
Expand Down Expand Up @@ -331,8 +343,8 @@ def watch(
) -> None:
canonical = canonical_target(target)
_check_declaration_forbidden(canonical, depends_on)
resolved = resolve(canonical)
declared_in = _caller_file()
resolved = _resolve_declared(resolve, canonical)
declared_in, declared_line = _caller()
decl = Declaration(
target=canonical,
role=ROLE_WATCH,
Expand All @@ -342,11 +354,12 @@ def watch(
review_by=review_by,
note=note,
declared_in=declared_in,
declared_line=declared_line,
identity=(declared_in, f"watch:{canonical}"),
track_value=track_value,
)
register(decl)
if collect_mode():
if resolved is None or collect_mode():
return
_handle_drift(decl, resolved)
decl.status = STATUS_WATCHED
Loading
Loading