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.
- pygeodata_browser-0.1.0/.github/workflows/release.yml +62 -0
- pygeodata_browser-0.1.0/.gitignore +10 -0
- pygeodata_browser-0.1.0/LICENSE +21 -0
- pygeodata_browser-0.1.0/MANIFEST.in +2 -0
- pygeodata_browser-0.1.0/PKG-INFO +80 -0
- pygeodata_browser-0.1.0/README.md +60 -0
- pygeodata_browser-0.1.0/api/__init__.py +10 -0
- pygeodata_browser-0.1.0/api/__main__.py +27 -0
- pygeodata_browser-0.1.0/api/code_service.py +289 -0
- pygeodata_browser-0.1.0/api/entry_store.py +313 -0
- pygeodata_browser-0.1.0/api/export_service.py +74 -0
- pygeodata_browser-0.1.0/api/logging.py +13 -0
- pygeodata_browser-0.1.0/api/path_actions.py +58 -0
- pygeodata_browser-0.1.0/api/payloads.py +831 -0
- pygeodata_browser-0.1.0/api/popups.py +82 -0
- pygeodata_browser-0.1.0/api/serve.py +28 -0
- pygeodata_browser-0.1.0/api/state.py +260 -0
- pygeodata_browser-0.1.0/api/static/app/assets/index-CTs2mLe1.js +9 -0
- pygeodata_browser-0.1.0/api/static/app/assets/index-Drk1VZv4.css +1 -0
- pygeodata_browser-0.1.0/api/static/app/index.html +13 -0
- pygeodata_browser-0.1.0/api/web.py +716 -0
- pygeodata_browser-0.1.0/pygeodata_browser.egg-info/PKG-INFO +80 -0
- pygeodata_browser-0.1.0/pygeodata_browser.egg-info/SOURCES.txt +40 -0
- pygeodata_browser-0.1.0/pygeodata_browser.egg-info/dependency_links.txt +1 -0
- pygeodata_browser-0.1.0/pygeodata_browser.egg-info/requires.txt +6 -0
- pygeodata_browser-0.1.0/pygeodata_browser.egg-info/scm_file_list.json +97 -0
- pygeodata_browser-0.1.0/pygeodata_browser.egg-info/scm_version.json +8 -0
- pygeodata_browser-0.1.0/pygeodata_browser.egg-info/top_level.txt +1 -0
- pygeodata_browser-0.1.0/pyproject.toml +54 -0
- pygeodata_browser-0.1.0/ruff.toml +73 -0
- pygeodata_browser-0.1.0/scripts/check_dist.py +48 -0
- pygeodata_browser-0.1.0/setup.cfg +4 -0
- pygeodata_browser-0.1.0/tests/conftest.py +14 -0
- pygeodata_browser-0.1.0/tests/test_code_characterization.py +431 -0
- pygeodata_browser-0.1.0/tests/test_code_diff_helpers.py +82 -0
- pygeodata_browser-0.1.0/tests/test_code_service.py +166 -0
- pygeodata_browser-0.1.0/tests/test_export_service.py +199 -0
- pygeodata_browser-0.1.0/tests/test_models.py +132 -0
- pygeodata_browser-0.1.0/tests/test_payloads.py +950 -0
- pygeodata_browser-0.1.0/tests/test_popups.py +51 -0
- pygeodata_browser-0.1.0/tests/test_state.py +172 -0
- 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,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,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
|
+
}
|