pygeodata-browser 0.1.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.
Files changed (42) hide show
  1. pygeodata_browser-0.1.0/.github/workflows/release.yml +62 -0
  2. pygeodata_browser-0.1.0/.gitignore +10 -0
  3. pygeodata_browser-0.1.0/LICENSE +21 -0
  4. pygeodata_browser-0.1.0/MANIFEST.in +2 -0
  5. pygeodata_browser-0.1.0/PKG-INFO +80 -0
  6. pygeodata_browser-0.1.0/README.md +60 -0
  7. pygeodata_browser-0.1.0/api/__init__.py +10 -0
  8. pygeodata_browser-0.1.0/api/__main__.py +27 -0
  9. pygeodata_browser-0.1.0/api/code_service.py +289 -0
  10. pygeodata_browser-0.1.0/api/entry_store.py +313 -0
  11. pygeodata_browser-0.1.0/api/export_service.py +74 -0
  12. pygeodata_browser-0.1.0/api/logging.py +13 -0
  13. pygeodata_browser-0.1.0/api/path_actions.py +58 -0
  14. pygeodata_browser-0.1.0/api/payloads.py +831 -0
  15. pygeodata_browser-0.1.0/api/popups.py +82 -0
  16. pygeodata_browser-0.1.0/api/serve.py +28 -0
  17. pygeodata_browser-0.1.0/api/state.py +260 -0
  18. pygeodata_browser-0.1.0/api/static/app/assets/index-CTs2mLe1.js +9 -0
  19. pygeodata_browser-0.1.0/api/static/app/assets/index-Drk1VZv4.css +1 -0
  20. pygeodata_browser-0.1.0/api/static/app/index.html +13 -0
  21. pygeodata_browser-0.1.0/api/web.py +716 -0
  22. pygeodata_browser-0.1.0/pygeodata_browser.egg-info/PKG-INFO +80 -0
  23. pygeodata_browser-0.1.0/pygeodata_browser.egg-info/SOURCES.txt +40 -0
  24. pygeodata_browser-0.1.0/pygeodata_browser.egg-info/dependency_links.txt +1 -0
  25. pygeodata_browser-0.1.0/pygeodata_browser.egg-info/requires.txt +6 -0
  26. pygeodata_browser-0.1.0/pygeodata_browser.egg-info/scm_file_list.json +97 -0
  27. pygeodata_browser-0.1.0/pygeodata_browser.egg-info/scm_version.json +8 -0
  28. pygeodata_browser-0.1.0/pygeodata_browser.egg-info/top_level.txt +1 -0
  29. pygeodata_browser-0.1.0/pyproject.toml +54 -0
  30. pygeodata_browser-0.1.0/ruff.toml +73 -0
  31. pygeodata_browser-0.1.0/scripts/check_dist.py +48 -0
  32. pygeodata_browser-0.1.0/setup.cfg +4 -0
  33. pygeodata_browser-0.1.0/tests/conftest.py +14 -0
  34. pygeodata_browser-0.1.0/tests/test_code_characterization.py +431 -0
  35. pygeodata_browser-0.1.0/tests/test_code_diff_helpers.py +82 -0
  36. pygeodata_browser-0.1.0/tests/test_code_service.py +166 -0
  37. pygeodata_browser-0.1.0/tests/test_export_service.py +199 -0
  38. pygeodata_browser-0.1.0/tests/test_models.py +132 -0
  39. pygeodata_browser-0.1.0/tests/test_payloads.py +950 -0
  40. pygeodata_browser-0.1.0/tests/test_popups.py +51 -0
  41. pygeodata_browser-0.1.0/tests/test_state.py +172 -0
  42. pygeodata_browser-0.1.0/tests/test_web.py +745 -0
@@ -0,0 +1,62 @@
1
+ # Push a version tag (e.g. `git tag v0.1.0 && git push --tags`) to publish to PyPI;
2
+ # the tag is the version (setuptools-scm).
3
+ # The frontend is compiled here and shipped prebuilt; users never need Node.
4
+ # PyPI authenticates this workflow via trusted publishing (no API token): add it
5
+ # as a trusted publisher on PyPI (workflow `release.yml`, environment `pypi`).
6
+ name: Release
7
+
8
+ on:
9
+ push:
10
+ tags: ['v*']
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ with:
18
+ fetch-depth: 0 # setuptools-scm reads the version from git tags
19
+
20
+ - uses: actions/setup-node@v7
21
+ with:
22
+ node-version: 22
23
+ cache: npm
24
+ cache-dependency-path: app/package-lock.json
25
+
26
+ - name: Build the frontend
27
+ working-directory: app
28
+ run: |
29
+ npm ci
30
+ npm run check
31
+ npm run build
32
+
33
+ - uses: actions/setup-python@v7
34
+ with:
35
+ python-version: '3.12'
36
+
37
+ - name: Build distributions
38
+ run: |
39
+ python -m pip install build
40
+ python -m build
41
+
42
+ - name: Check the distributions ship the built frontend
43
+ run: python scripts/check_dist.py dist
44
+
45
+ - uses: actions/upload-artifact@v7
46
+ with:
47
+ name: dist
48
+ path: dist/
49
+
50
+ publish:
51
+ needs: build
52
+ runs-on: ubuntu-latest
53
+ environment: pypi
54
+ permissions:
55
+ id-token: write
56
+ steps:
57
+ - uses: actions/download-artifact@v8
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+
62
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ build/
7
+ dist/
8
+ .DS_Store
9
+ app/node_modules/
10
+ api/static/app/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jasper Roebroek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ # The source archive ships the prebuilt frontend (api/static/app), not its Svelte source.
2
+ prune app
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygeodata-browser
3
+ Version: 0.1.0
4
+ Summary: Registry browser (QC and code-review dashboard) for pygeodata
5
+ Author-email: Jasper Roebroek <roebroek.jasper@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/jasperroebroek/pygeodata-browser
8
+ Project-URL: Issues, https://github.com/jasperroebroek/pygeodata-browser/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pygeodata<0.4,>=0.3.0
15
+ Requires-Dist: flask
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Requires-Dist: pytest-mock; extra == "test"
19
+ Dynamic: license-file
20
+
21
+ # pygeodata-browser
22
+
23
+ QC and code-review dashboard for [pygeodata](../pygeodata) projects. Serves the
24
+ registry browser: class column, entries table, entry detail popups, code
25
+ versioning view, export view, and bounds map.
26
+
27
+ The CLI stays in core — `pygeodata browse` delegates to this package. To run
28
+ standalone:
29
+
30
+ ```
31
+ python -m pygeodata_browser
32
+ ```
33
+
34
+ It reads a pygeodata project's on-disk state: the `.source/` registries, the
35
+ cache roots and `.source/catalog.db`.
36
+
37
+ ## Install
38
+
39
+ ```
40
+ pip install pygeodata-browser
41
+ # or, from the core package:
42
+ pip install pygeodata[browser]
43
+ ```
44
+
45
+ ## Development
46
+
47
+ ```
48
+ ruff format . && ruff check .
49
+ python -m pytest -q
50
+ ```
51
+
52
+ ### Frontend
53
+
54
+ The UI is a Svelte app in `app/`. Its build output, `api/static/app/`, is what
55
+ the server serves at `/`; it is not committed.
56
+
57
+ ```
58
+ cd app
59
+ npm ci
60
+ npx vite --port 5173 # dev server with hot reload; proxies /api to the browser on :8780
61
+ npm run check # type check
62
+ npm run build # writes api/static/app/ (needed for `pygeodata browse` from a checkout)
63
+ ```
64
+
65
+ `app/e2e/` holds Playwright scripts (screenshots, toggle and detail checks, timings); see its README.
66
+
67
+ ## Release
68
+
69
+ The version comes from the git tag (setuptools-scm); there is no version string
70
+ to bump. Pushing a version tag publishes to PyPI:
71
+
72
+ ```
73
+ git tag v0.1.0 && git push --tags
74
+ ```
75
+
76
+ `.github/workflows/release.yml` builds the frontend, builds the wheel and sdist
77
+ (both ship the prebuilt frontend, so installing never needs Node), checks them
78
+ with `scripts/check_dist.py`, and publishes through PyPI trusted publishing.
79
+
80
+ Requires pygeodata 0.3.x; release a compatible pygeodata first when both change.
@@ -0,0 +1,60 @@
1
+ # pygeodata-browser
2
+
3
+ QC and code-review dashboard for [pygeodata](../pygeodata) projects. Serves the
4
+ registry browser: class column, entries table, entry detail popups, code
5
+ versioning view, export view, and bounds map.
6
+
7
+ The CLI stays in core — `pygeodata browse` delegates to this package. To run
8
+ standalone:
9
+
10
+ ```
11
+ python -m pygeodata_browser
12
+ ```
13
+
14
+ It reads a pygeodata project's on-disk state: the `.source/` registries, the
15
+ cache roots and `.source/catalog.db`.
16
+
17
+ ## Install
18
+
19
+ ```
20
+ pip install pygeodata-browser
21
+ # or, from the core package:
22
+ pip install pygeodata[browser]
23
+ ```
24
+
25
+ ## Development
26
+
27
+ ```
28
+ ruff format . && ruff check .
29
+ python -m pytest -q
30
+ ```
31
+
32
+ ### Frontend
33
+
34
+ The UI is a Svelte app in `app/`. Its build output, `api/static/app/`, is what
35
+ the server serves at `/`; it is not committed.
36
+
37
+ ```
38
+ cd app
39
+ npm ci
40
+ npx vite --port 5173 # dev server with hot reload; proxies /api to the browser on :8780
41
+ npm run check # type check
42
+ npm run build # writes api/static/app/ (needed for `pygeodata browse` from a checkout)
43
+ ```
44
+
45
+ `app/e2e/` holds Playwright scripts (screenshots, toggle and detail checks, timings); see its README.
46
+
47
+ ## Release
48
+
49
+ The version comes from the git tag (setuptools-scm); there is no version string
50
+ to bump. Pushing a version tag publishes to PyPI:
51
+
52
+ ```
53
+ git tag v0.1.0 && git push --tags
54
+ ```
55
+
56
+ `.github/workflows/release.yml` builds the frontend, builds the wheel and sdist
57
+ (both ship the prebuilt frontend, so installing never needs Node), checks them
58
+ with `scripts/check_dist.py`, and publishes through PyPI trusted publishing.
59
+
60
+ Requires pygeodata 0.3.x; release a compatible pygeodata first when both change.
@@ -0,0 +1,10 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from pygeodata_browser.serve import open_registry_browser
4
+
5
+ try:
6
+ __version__ = version('pygeodata-browser')
7
+ except PackageNotFoundError: # imported from a source tree that is not installed
8
+ __version__ = '0+unknown'
9
+
10
+ __all__ = ['open_registry_browser', '__version__']
@@ -0,0 +1,27 @@
1
+ import click
2
+
3
+ from pygeodata_browser.serve import open_registry_browser
4
+
5
+
6
+ @click.command()
7
+ @click.option('--port', default=0, show_default=True, help='Port to listen on (0 = random free port).')
8
+ @click.option(
9
+ '--fast/--no-fast',
10
+ 'fast',
11
+ default=False,
12
+ help='Serve the UI immediately from the catalog bootstrap while the full state builds.',
13
+ )
14
+ @click.option(
15
+ '--reconcile-interval',
16
+ default=0.0,
17
+ show_default=True,
18
+ type=float,
19
+ help='Minutes between automatic catalog reconciles (0 disables).',
20
+ )
21
+ def main(port: int, fast: bool, reconcile_interval: float) -> None:
22
+ """Serve the pygeodata registry browser."""
23
+ open_registry_browser(port, fast=fast, reconcile_interval=reconcile_interval * 60, reimport_on_reconcile=False)
24
+
25
+
26
+ if __name__ == '__main__':
27
+ main()
@@ -0,0 +1,289 @@
1
+ """Domain logic for the /api/code/* routes.
2
+
3
+ Routes in web.py parse request args, call these functions, and jsonify the
4
+ result. No Flask imports here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from difflib import SequenceMatcher, unified_diff
10
+
11
+ from pygeodata.ast import get_source_code
12
+ from pygeodata.catalog.types import CodeClassState
13
+ from pygeodata.hash import calculate_cls_source_hash
14
+ from pygeodata.paths import CodeRegistryPathConstructor
15
+ from pygeodata.registries.versioning import VersionRegistry
16
+ from pygeodata.tracked_object import TrackedObject
17
+
18
+
19
+ def _word_segments(text_old: str, text_new: str) -> tuple[list[dict], list[dict]]:
20
+ """Compute word-level diff segments for a paired del/add line.
21
+
22
+ Returns (segments_old, segments_new) where each segment is
23
+ {'type': 'eq'|'del'|'ins', 'text': str}.
24
+ """
25
+
26
+ def tokenise(s: str) -> list[str]:
27
+ import re
28
+
29
+ return re.findall(r'\w+|\W', s)
30
+
31
+ tokens_old = tokenise(text_old)
32
+ tokens_new = tokenise(text_new)
33
+ matcher = SequenceMatcher(None, tokens_old, tokens_new, autojunk=False)
34
+ segs_old: list[dict] = []
35
+ segs_new: list[dict] = []
36
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
37
+ old_text = ''.join(tokens_old[i1:i2])
38
+ new_text = ''.join(tokens_new[j1:j2])
39
+ if tag == 'equal':
40
+ segs_old.append({'type': 'eq', 'text': old_text})
41
+ segs_new.append({'type': 'eq', 'text': new_text})
42
+ elif tag == 'replace':
43
+ segs_old.append({'type': 'del', 'text': old_text})
44
+ segs_new.append({'type': 'ins', 'text': new_text})
45
+ elif tag == 'delete':
46
+ segs_old.append({'type': 'del', 'text': old_text})
47
+ elif tag == 'insert':
48
+ segs_new.append({'type': 'ins', 'text': new_text})
49
+ return segs_old, segs_new
50
+
51
+
52
+ def _build_structured_hunks(text_old: str, text_new: str) -> list[dict]:
53
+ """Parse a unified diff into structured hunks with line numbers and word segments.
54
+
55
+ Each hunk: {header, start_old, start_new, lines: [...]}.
56
+ Each line: {type, text, line_old, line_new, segments?}.
57
+ Paired del/add lines (a del immediately followed by an add) get word segments.
58
+ """
59
+ raw_diff = ''.join(
60
+ unified_diff(
61
+ text_old.splitlines(keepends=True),
62
+ text_new.splitlines(keepends=True),
63
+ fromfile='old',
64
+ tofile='new',
65
+ ),
66
+ )
67
+
68
+ hunks: list[dict] = []
69
+ current_hunk: dict | None = None
70
+ line_old = 0
71
+ line_new = 0
72
+
73
+ for raw in raw_diff.splitlines():
74
+ if raw.startswith('--- ') or raw.startswith('+++ '):
75
+ continue
76
+ if raw.startswith('@@'):
77
+ import re
78
+
79
+ m = re.match(r'@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@', raw)
80
+ line_old = int(m.group(1)) if m else 1
81
+ line_new = int(m.group(2)) if m else 1
82
+ current_hunk = {'header': raw, 'start_old': line_old, 'start_new': line_new, 'lines': []}
83
+ hunks.append(current_hunk)
84
+ elif current_hunk is not None:
85
+ if raw.startswith('+'):
86
+ current_hunk['lines'].append({'type': 'add', 'text': raw[1:], 'line_old': None, 'line_new': line_new})
87
+ line_new += 1
88
+ elif raw.startswith('-'):
89
+ current_hunk['lines'].append({'type': 'del', 'text': raw[1:], 'line_old': line_old, 'line_new': None})
90
+ line_old += 1
91
+ elif raw != '\':
92
+ current_hunk['lines'].append(
93
+ {'type': 'ctx', 'text': raw[1:], 'line_old': line_old, 'line_new': line_new},
94
+ )
95
+ line_old += 1
96
+ line_new += 1
97
+
98
+ # Pair consecutive del+add lines and add word segments
99
+ for hunk in hunks:
100
+ lines = hunk['lines']
101
+ i = 0
102
+ while i < len(lines):
103
+ if lines[i]['type'] == 'del':
104
+ # Collect run of dels then run of adds
105
+ j = i
106
+ while j < len(lines) and lines[j]['type'] == 'del':
107
+ j += 1
108
+ k = j
109
+ while k < len(lines) and lines[k]['type'] == 'add':
110
+ k += 1
111
+ dels = lines[i:j]
112
+ adds = lines[j:k]
113
+ pairs = min(len(dels), len(adds))
114
+ for p in range(pairs):
115
+ segs_old, segs_new = _word_segments(dels[p]['text'], adds[p]['text'])
116
+ dels[p]['segments'] = segs_old
117
+ adds[p]['segments'] = segs_new
118
+ i = k
119
+ else:
120
+ i += 1
121
+
122
+ return hunks
123
+
124
+
125
+ def version_classes(version_id: str, vreg: VersionRegistry) -> list[CodeClassState]:
126
+ """Return per-class CodeState at the given version, annotated with live staleness.
127
+
128
+ Pass a Version.version_id to select that group, or '' for the newest group.
129
+ """
130
+ src = vreg.source_registry
131
+ vi = vreg.version_by_id(version_id) if version_id else (vreg.versions[0] if vreg.versions else None)
132
+ if vi is None:
133
+ return []
134
+
135
+ snap = vreg.class_snapshot_at_version(vi)
136
+ result = []
137
+ for class_name in sorted(src.class_names):
138
+ source_hash = snap.get(class_name)
139
+ if source_hash is None:
140
+ continue
141
+ best = next((s for s in src.get_states(class_name) if s.source_hash == source_hash), None)
142
+ if best is None:
143
+ continue
144
+ cls = TrackedObject.find_object_class(class_name)
145
+ is_loaded = cls is not None
146
+ is_stale = is_loaded and calculate_cls_source_hash(cls) != source_hash
147
+ result.append(
148
+ CodeClassState(
149
+ class_name=class_name,
150
+ object_type=best.object_type,
151
+ source_hash=source_hash,
152
+ is_loaded=is_loaded,
153
+ is_stale=is_stale,
154
+ ),
155
+ )
156
+ return result
157
+
158
+
159
+ def _source_text(source_hash: str, vreg: VersionRegistry) -> tuple[str, str | None] | None:
160
+ """(source, class_name) for a hash: the stored snapshot, else a loaded class whose code has that hash.
161
+
162
+ pygeodata stores a class's source only when it runs, so the live code of an
163
+ edited-but-not-yet-run class (the 'Live' version) has no stored snapshot.
164
+ """
165
+ src = vreg.source_registry
166
+ text = src.get_source(source_hash)
167
+ if text is not None:
168
+ return text, src.get_class_name_from_hash(source_hash)
169
+ cls = next((c for c in TrackedObject.get_registered_objects() if _live_source_hash(c) == source_hash), None)
170
+ return (get_source_code(cls), cls.get_class_name()) if cls is not None else None
171
+
172
+
173
+ def _live_source_hash(cls: type[TrackedObject]) -> str | None:
174
+ try:
175
+ return calculate_cls_source_hash(cls)
176
+ except (OSError, TypeError): # source unavailable (e.g. defined interactively)
177
+ return None
178
+
179
+
180
+ def snapshot_html(source_hash: str, vreg: VersionRegistry) -> dict | None:
181
+ """Return {'class_name': ..., 'lines': [...]} for the given source hash.
182
+
183
+ Returns None if the snapshot is not found (caller should abort(404)).
184
+ """
185
+ src = vreg.source_registry
186
+ found = _source_text(source_hash, vreg)
187
+ if found is None:
188
+ return None
189
+
190
+ source_text, class_name = found
191
+ known_classes = frozenset(TrackedObject._registry.keys()) | frozenset(src.class_names)
192
+ lines = [
193
+ {'text': line, 'cls_link': line.strip() if line.strip() in known_classes else None}
194
+ for line in source_text.splitlines()
195
+ ]
196
+ return {'class_name': class_name, 'lines': lines}
197
+
198
+
199
+ def diff_hashes(hash_old: str, hash_new: str, full: bool, vreg: VersionRegistry) -> dict | None:
200
+ """Return structured diff payload or None if either file is missing.
201
+
202
+ Returns {'hunks': [...], 'full_old'?: str, 'full_new'?: str}.
203
+ """
204
+ old = _source_text(hash_old, vreg)
205
+ new = _source_text(hash_new, vreg)
206
+ if old is None or new is None:
207
+ return None
208
+ text_old, text_new = old[0], new[0]
209
+
210
+ result: dict = {'hunks': _build_structured_hunks(text_old, text_new)}
211
+ if full:
212
+ result['full_old'] = text_old
213
+ result['full_new'] = text_new
214
+ return result
215
+
216
+
217
+ def unified_diff_payload(
218
+ hash_old: str,
219
+ hash_new: str,
220
+ full: bool,
221
+ assert_allowed_path: callable,
222
+ vreg: VersionRegistry,
223
+ ) -> dict | None:
224
+ """HTTP-boundary wrapper around diff_hashes that enforces the path guard.
225
+
226
+ ``assert_allowed_path`` is the guard from web.py; calling it here keeps
227
+ the security check provably on the HTTP boundary for user-facing diffs.
228
+ Returns None when either source file does not exist (caller should abort(404)).
229
+ """
230
+ assert_allowed_path(str(CodeRegistryPathConstructor.from_source_hash(hash_old).source_path))
231
+ assert_allowed_path(str(CodeRegistryPathConstructor.from_source_hash(hash_new).source_path))
232
+ return diff_hashes(hash_old, hash_new, full, vreg)
233
+
234
+
235
+ def version_diff(
236
+ vreg: VersionRegistry,
237
+ *,
238
+ record_id: str | None = None,
239
+ entries: dict | None = None,
240
+ base_version_id: str | None = None,
241
+ target_version_id: str = 'live',
242
+ ) -> dict:
243
+ """Compare two version snapshots and return a complete diff payload.
244
+
245
+ base_version_id is resolved in order:
246
+ 1. Explicit base_version_id argument.
247
+ 2. The version the entry (record_id) was produced at.
248
+ target_version_id defaults to 'live' (in-memory/disk current state).
249
+ Either base or target may be 'live'.
250
+
251
+ Returns {changes, base_version_id, has_live_stale} or an error dict.
252
+ Never raises — errors are encoded as {'error': ..., 'message': ...}.
253
+ """
254
+
255
+ def _resolve(version_id: str) -> dict[str, str] | None:
256
+ if version_id == 'live':
257
+ return vreg.live_snapshot()
258
+ if version_id == 'none':
259
+ return {}
260
+ return vreg.snapshot_for_version(version_id)
261
+
262
+ # Resolve base
263
+ resolved_base_id = base_version_id
264
+ if resolved_base_id is None:
265
+ if record_id is None or entries is None:
266
+ return {'error': 'bad_request', 'message': 'base_version_id or record_id required'}
267
+ entry = entries.get(record_id)
268
+ if entry is None:
269
+ return {'__not_found__': True}
270
+ dep_hash = entry.dep_hash
271
+ if not dep_hash:
272
+ return {'error': 'no_snapshot', 'message': 'Snapshot not available for this entry'}
273
+ version = vreg.dep_hash_to_version.get(dep_hash)
274
+ resolved_base_id = version.version_id if version else None
275
+
276
+ base = _resolve(resolved_base_id) if resolved_base_id else None
277
+ target = _resolve(target_version_id)
278
+
279
+ if base is None:
280
+ return {'error': 'no_snapshot', 'message': 'Base version not found'}
281
+ if target is None:
282
+ return {'error': 'no_snapshot', 'message': 'Target version not found'}
283
+
284
+ changes = [e.to_dict() for e in vreg.compare_versions(base, target)]
285
+ return {
286
+ 'changes': changes,
287
+ 'base_version_id': resolved_base_id,
288
+ 'has_live_stale': vreg.has_live_stale(),
289
+ }