evoontology-explorer 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 (57) hide show
  1. evoontology_explorer-0.1.0/.gitignore +15 -0
  2. evoontology_explorer-0.1.0/LICENSE +21 -0
  3. evoontology_explorer-0.1.0/NOTICE +11 -0
  4. evoontology_explorer-0.1.0/PKG-INFO +128 -0
  5. evoontology_explorer-0.1.0/README.md +80 -0
  6. evoontology_explorer-0.1.0/pyproject.toml +45 -0
  7. evoontology_explorer-0.1.0/src/evoontology_explorer/__init__.py +14 -0
  8. evoontology_explorer-0.1.0/src/evoontology_explorer/__main__.py +35 -0
  9. evoontology_explorer-0.1.0/src/evoontology_explorer/api.py +100 -0
  10. evoontology_explorer-0.1.0/src/evoontology_explorer/cache.py +130 -0
  11. evoontology_explorer-0.1.0/src/evoontology_explorer/diff.py +87 -0
  12. evoontology_explorer-0.1.0/src/evoontology_explorer/experience.py +103 -0
  13. evoontology_explorer-0.1.0/src/evoontology_explorer/graph.py +384 -0
  14. evoontology_explorer-0.1.0/src/evoontology_explorer/model.py +195 -0
  15. evoontology_explorer-0.1.0/src/evoontology_explorer/schema.py +52 -0
  16. evoontology_explorer-0.1.0/src/evoontology_explorer/static/app.js +2861 -0
  17. evoontology_explorer-0.1.0/src/evoontology_explorer/static/cytoscape.min.js +32 -0
  18. evoontology_explorer-0.1.0/src/evoontology_explorer/static/index.html +654 -0
  19. evoontology_explorer-0.1.0/src/evoontology_explorer/tools.py +85 -0
  20. evoontology_explorer-0.1.0/src/evoontology_explorer/workspace.py +239 -0
  21. evoontology_explorer-0.1.0/tests/conftest.py +20 -0
  22. evoontology_explorer-0.1.0/tests/fixtures/golden_0.2.0.json +1432 -0
  23. evoontology_explorer-0.1.0/tests/fixtures/golden_experience_0.2.0.json +70 -0
  24. evoontology_explorer-0.1.0/tests/fixtures/workspace/active.json +3 -0
  25. evoontology_explorer-0.1.0/tests/fixtures/workspace/evolution/run_1/evaluations/round1.json +11 -0
  26. evoontology_explorer-0.1.0/tests/fixtures/workspace/evolution/run_1/run.json +12 -0
  27. evoontology_explorer-0.1.0/tests/fixtures/workspace/project.json +8 -0
  28. evoontology_explorer-0.1.0/tests/fixtures/workspace/reports/0.2.0.json +35 -0
  29. evoontology_explorer-0.1.0/tests/fixtures/workspace/state.json +9 -0
  30. evoontology_explorer-0.1.0/tests/fixtures/workspace/sync.json +3 -0
  31. evoontology_explorer-0.1.0/tests/fixtures/workspace/trajectories/task_a.json +25 -0
  32. evoontology_explorer-0.1.0/tests/fixtures/workspace/trajectories/task_b.json +12 -0
  33. evoontology_explorer-0.1.0/tests/fixtures/workspace/trajectories/task_c.json +14 -0
  34. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/constraints.json +17 -0
  35. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/evidence.json +42 -0
  36. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/mappings.json +37 -0
  37. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/relations.json +20 -0
  38. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/terms.json +41 -0
  39. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/constraints.json +32 -0
  40. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/evidence.json +42 -0
  41. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/mappings.json +53 -0
  42. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/relations.json +32 -0
  43. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/terms.json +55 -0
  44. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/constraints.json +32 -0
  45. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/evidence.json +42 -0
  46. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/mappings.json +53 -0
  47. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/relations.json +32 -0
  48. evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/terms.json +55 -0
  49. evoontology_explorer-0.1.0/tests/fixtures/workspace/workload/questions.json +28 -0
  50. evoontology_explorer-0.1.0/tests/make_fixture.py +140 -0
  51. evoontology_explorer-0.1.0/tests/test_api.py +65 -0
  52. evoontology_explorer-0.1.0/tests/test_cache.py +83 -0
  53. evoontology_explorer-0.1.0/tests/test_diff.py +35 -0
  54. evoontology_explorer-0.1.0/tests/test_experience.py +54 -0
  55. evoontology_explorer-0.1.0/tests/test_graph.py +90 -0
  56. evoontology_explorer-0.1.0/tests/test_no_private_data.py +50 -0
  57. evoontology_explorer-0.1.0/tests/test_workspace.py +136 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .venv/
8
+ .env
9
+ .env.*
10
+ *.log
11
+ /workspace/
12
+ /workspace*/
13
+ visualizations/
14
+ .evoontology/
15
+ node_modules/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lukasz Wardzala
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,11 @@
1
+ evoontology-explorer
2
+
3
+ This project is derived from the visualization of EvoOntology
4
+ (https://github.com/ruc-datalab/EvoOntology), Copyright (c) 2026 Meiduo Chong,
5
+ MIT License: the explorer page (markup, styles, interaction and layout code),
6
+ the graph element builders and the experience report builder were ported from
7
+ it and modified to load data on demand from a server.
8
+
9
+ It bundles Cytoscape.js 3.30.2 (https://js.cytoscape.org),
10
+ Copyright (c) 2016-2024, The Cytoscape Consortium, MIT License, as
11
+ src/evoontology_explorer/static/cytoscape.min.js.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: evoontology-explorer
3
+ Version: 0.1.0
4
+ Summary: Server-backed explorer for EvoOntology workspaces: the ontology layer explorer page, loading versions, terms and evidence on demand.
5
+ Project-URL: Homepage, https://github.com/lwardzala/evoontology-explorer
6
+ Project-URL: Repository, https://github.com/lwardzala/evoontology-explorer
7
+ Project-URL: Issues, https://github.com/lwardzala/evoontology-explorer/issues
8
+ Author: Lukasz Wardzala
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Lukasz Wardzala
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ License-File: NOTICE
32
+ Keywords: cytoscape,evoontology,fastapi,ontology,visualization
33
+ Classifier: Framework :: FastAPI
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Scientific/Engineering :: Visualization
37
+ Requires-Python: >=3.11
38
+ Requires-Dist: fastapi<1,>=0.115
39
+ Requires-Dist: uvicorn>=0.30
40
+ Provides-Extra: databricks
41
+ Requires-Dist: databricks-sdk>=0.40; extra == 'databricks'
42
+ Provides-Extra: dev
43
+ Requires-Dist: httpx>=0.27; extra == 'dev'
44
+ Requires-Dist: pytest>=8; extra == 'dev'
45
+ Provides-Extra: s3
46
+ Requires-Dist: boto3>=1.34; extra == 's3'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # evoontology-explorer
50
+
51
+ A server-backed explorer for [EvoOntology](https://github.com/ruc-datalab/EvoOntology) workspaces. It serves the same *Ontology Layer Explorer* page as the EvoOntology plugin, with the same Cytoscape graph, panels and version comparison, but the browser loads only what it shows: the term layer of one version first, a term's mappings, constraints and evidence when that term is expanded, details on click, search and version diffs from the server. Switching versions releases the previous one. Workspaces with many versions or large graphs stay usable where the single-file page would not.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install evoontology-explorer # local directories
57
+ pip install "evoontology-explorer[databricks]" # Unity Catalog volumes
58
+ pip install "evoontology-explorer[s3]" # S3 prefixes
59
+ ```
60
+
61
+ ## Run
62
+
63
+ ```bash
64
+ evoontology-explorer --workspace ./workspace --port 8000
65
+ evoontology-explorer --workspace /Volumes/<catalog>/<schema>/<volume>/code
66
+ evoontology-explorer --workspace s3://my-bucket/ontology/code
67
+ ```
68
+
69
+ The workspace is the EvoOntology directory that holds `active.json` and `versions/<version>/` (usually a project's `.evoontology/`). Environment variables replace the flags: `EVOONTOLOGY_WORKSPACE`, `EVOONTOLOGY_TITLE` (the page title), `DATABRICKS_APP_PORT` or `PORT`.
70
+
71
+ Credentials come from the usual places: AWS SDK credentials for S3, Databricks SDK unified authentication for volumes (inside a Databricks App the app's own principal is used; it needs `READ VOLUME` on the volume and `USE SCHEMA` and `USE CATALOG` above it).
72
+
73
+ ## Deploy as a Databricks App
74
+
75
+ ```yaml
76
+ # app.yaml
77
+ command: ["python", "-m", "evoontology_explorer"]
78
+ env:
79
+ - name: EVOONTOLOGY_WORKSPACE
80
+ value: /Volumes/<catalog>/<schema>/<volume>/code
81
+ ```
82
+
83
+ ```text
84
+ # requirements.txt
85
+ evoontology-explorer[databricks]==0.1.0
86
+ ```
87
+
88
+ Databricks Apps sign viewers in and admit the principals with `CAN_USE` on the app.
89
+
90
+ ## API
91
+
92
+ All responses are JSON. Versions are addressed by name.
93
+
94
+ | Route | Returns |
95
+ |---|---|
96
+ | `GET /api/workspace` | title, active version, versions with their kind (`published` for `MAJOR.MINOR.PATCH`, otherwise `candidate`) |
97
+ | `GET /api/versions/{v}` | counts, warnings, term and satellite counts |
98
+ | `GET /api/versions/{v}/graph` | the term layer: term nodes and relation edges |
99
+ | `GET /api/versions/{v}/terms/{id}/satellites` | one term's mappings, constraints and evidence with their reference edges |
100
+ | `GET /api/versions/{v}/satellites` | every satellite of the version |
101
+ | `GET /api/versions/{v}/elements/{id}` | the detail rows of a node or edge, with links to the records it names |
102
+ | `GET /api/versions/{v}/search?q=` | nodes matching a substring |
103
+ | `GET /api/schema`, `GET /api/versions/{v}/tools`, `GET /api/versions/{v}/experience` | the Schema, Tool and Results views |
104
+ | `GET /api/compare?left=&right=` | added, removed and changed records between two versions |
105
+ | `GET /health` | `ok` |
106
+
107
+ Node ids are `<family>:<record id>`; relation edges are `rel:<record id>`.
108
+
109
+ ## Library use
110
+
111
+ ```python
112
+ from evoontology_explorer import create_app, open_workspace
113
+
114
+ app = create_app(open_workspace("./workspace"), title="My ontology")
115
+ ```
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ pip install -e ".[dev]"
121
+ pytest -q
122
+ ```
123
+
124
+ The tests run on a synthetic workspace under `tests/fixtures`. `tests/test_no_private_data.py` fails the build when the tree contains anything that looks like a private deployment (email addresses, account ids, bucket or volume paths, hostnames, tokens); keep examples to the placeholders above.
125
+
126
+ ## Licence
127
+
128
+ MIT. See `NOTICE` for the EvoOntology and Cytoscape.js attributions.
@@ -0,0 +1,80 @@
1
+ # evoontology-explorer
2
+
3
+ A server-backed explorer for [EvoOntology](https://github.com/ruc-datalab/EvoOntology) workspaces. It serves the same *Ontology Layer Explorer* page as the EvoOntology plugin, with the same Cytoscape graph, panels and version comparison, but the browser loads only what it shows: the term layer of one version first, a term's mappings, constraints and evidence when that term is expanded, details on click, search and version diffs from the server. Switching versions releases the previous one. Workspaces with many versions or large graphs stay usable where the single-file page would not.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install evoontology-explorer # local directories
9
+ pip install "evoontology-explorer[databricks]" # Unity Catalog volumes
10
+ pip install "evoontology-explorer[s3]" # S3 prefixes
11
+ ```
12
+
13
+ ## Run
14
+
15
+ ```bash
16
+ evoontology-explorer --workspace ./workspace --port 8000
17
+ evoontology-explorer --workspace /Volumes/<catalog>/<schema>/<volume>/code
18
+ evoontology-explorer --workspace s3://my-bucket/ontology/code
19
+ ```
20
+
21
+ The workspace is the EvoOntology directory that holds `active.json` and `versions/<version>/` (usually a project's `.evoontology/`). Environment variables replace the flags: `EVOONTOLOGY_WORKSPACE`, `EVOONTOLOGY_TITLE` (the page title), `DATABRICKS_APP_PORT` or `PORT`.
22
+
23
+ Credentials come from the usual places: AWS SDK credentials for S3, Databricks SDK unified authentication for volumes (inside a Databricks App the app's own principal is used; it needs `READ VOLUME` on the volume and `USE SCHEMA` and `USE CATALOG` above it).
24
+
25
+ ## Deploy as a Databricks App
26
+
27
+ ```yaml
28
+ # app.yaml
29
+ command: ["python", "-m", "evoontology_explorer"]
30
+ env:
31
+ - name: EVOONTOLOGY_WORKSPACE
32
+ value: /Volumes/<catalog>/<schema>/<volume>/code
33
+ ```
34
+
35
+ ```text
36
+ # requirements.txt
37
+ evoontology-explorer[databricks]==0.1.0
38
+ ```
39
+
40
+ Databricks Apps sign viewers in and admit the principals with `CAN_USE` on the app.
41
+
42
+ ## API
43
+
44
+ All responses are JSON. Versions are addressed by name.
45
+
46
+ | Route | Returns |
47
+ |---|---|
48
+ | `GET /api/workspace` | title, active version, versions with their kind (`published` for `MAJOR.MINOR.PATCH`, otherwise `candidate`) |
49
+ | `GET /api/versions/{v}` | counts, warnings, term and satellite counts |
50
+ | `GET /api/versions/{v}/graph` | the term layer: term nodes and relation edges |
51
+ | `GET /api/versions/{v}/terms/{id}/satellites` | one term's mappings, constraints and evidence with their reference edges |
52
+ | `GET /api/versions/{v}/satellites` | every satellite of the version |
53
+ | `GET /api/versions/{v}/elements/{id}` | the detail rows of a node or edge, with links to the records it names |
54
+ | `GET /api/versions/{v}/search?q=` | nodes matching a substring |
55
+ | `GET /api/schema`, `GET /api/versions/{v}/tools`, `GET /api/versions/{v}/experience` | the Schema, Tool and Results views |
56
+ | `GET /api/compare?left=&right=` | added, removed and changed records between two versions |
57
+ | `GET /health` | `ok` |
58
+
59
+ Node ids are `<family>:<record id>`; relation edges are `rel:<record id>`.
60
+
61
+ ## Library use
62
+
63
+ ```python
64
+ from evoontology_explorer import create_app, open_workspace
65
+
66
+ app = create_app(open_workspace("./workspace"), title="My ontology")
67
+ ```
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ pip install -e ".[dev]"
73
+ pytest -q
74
+ ```
75
+
76
+ The tests run on a synthetic workspace under `tests/fixtures`. `tests/test_no_private_data.py` fails the build when the tree contains anything that looks like a private deployment (email addresses, account ids, bucket or volume paths, hostnames, tokens); keep examples to the placeholders above.
77
+
78
+ ## Licence
79
+
80
+ MIT. See `NOTICE` for the EvoOntology and Cytoscape.js attributions.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "evoontology-explorer"
7
+ version = "0.1.0"
8
+ description = "Server-backed explorer for EvoOntology workspaces: the ontology layer explorer page, loading versions, terms and evidence on demand."
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "Lukasz Wardzala" }]
12
+ requires-python = ">=3.11"
13
+ keywords = ["ontology", "evoontology", "visualization", "cytoscape", "fastapi"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Framework :: FastAPI",
18
+ "Topic :: Scientific/Engineering :: Visualization",
19
+ ]
20
+ dependencies = [
21
+ "fastapi>=0.115,<1",
22
+ "uvicorn>=0.30",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ databricks = ["databricks-sdk>=0.40"]
27
+ s3 = ["boto3>=1.34"]
28
+ dev = ["pytest>=8", "httpx>=0.27"]
29
+
30
+ [project.scripts]
31
+ evoontology-explorer = "evoontology_explorer.__main__:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/lwardzala/evoontology-explorer"
35
+ Repository = "https://github.com/lwardzala/evoontology-explorer"
36
+ Issues = "https://github.com/lwardzala/evoontology-explorer/issues"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/evoontology_explorer"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["src", "tests", "README.md", "LICENSE", "NOTICE"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,14 @@
1
+ """Server-backed explorer for EvoOntology workspaces."""
2
+
3
+ from .api import create_app
4
+ from .workspace import DatabricksVolumeWorkspace, LocalWorkspace, S3Workspace, Workspace, open_workspace
5
+
6
+ __all__ = [
7
+ "create_app",
8
+ "open_workspace",
9
+ "Workspace",
10
+ "LocalWorkspace",
11
+ "DatabricksVolumeWorkspace",
12
+ "S3Workspace",
13
+ ]
14
+ __version__ = "0.1.0"
@@ -0,0 +1,35 @@
1
+ """Command line: serve a workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ from .api import create_app
10
+ from .workspace import open_workspace
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(prog="evoontology-explorer", description=__doc__)
15
+ parser.add_argument("--workspace", default=os.environ.get("EVOONTOLOGY_WORKSPACE"),
16
+ help="a directory, a /Volumes/... path or an s3:// URI holding active.json and versions/ "
17
+ "(default: $EVOONTOLOGY_WORKSPACE)")
18
+ parser.add_argument("--title", default=os.environ.get("EVOONTOLOGY_TITLE", "Ontology Layer Explorer"))
19
+ parser.add_argument("--host", default="0.0.0.0")
20
+ parser.add_argument("--port", type=int,
21
+ default=int(os.environ.get("DATABRICKS_APP_PORT") or os.environ.get("PORT") or 8000))
22
+ parser.add_argument("--max-graphs", type=int, default=4, help="versions kept built in memory")
23
+ args = parser.parse_args(argv)
24
+ if not args.workspace:
25
+ parser.error("--workspace or EVOONTOLOGY_WORKSPACE is required")
26
+ import uvicorn
27
+
28
+ app = create_app(open_workspace(args.workspace), title=args.title, max_graphs=args.max_graphs)
29
+ print(f"serving {args.workspace} on http://{args.host}:{args.port}", flush=True)
30
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
@@ -0,0 +1,100 @@
1
+ """The HTTP API and the page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI, HTTPException, Query
8
+ from fastapi.responses import FileResponse, PlainTextResponse
9
+ from fastapi.staticfiles import StaticFiles
10
+
11
+ from .cache import Catalog
12
+ from .diff import compare
13
+ from .schema import schema_view
14
+ from .tools import tool_view
15
+ from .workspace import Workspace, WorkspaceError
16
+
17
+ STATIC = Path(__file__).resolve().parent / "static"
18
+ NO_STORE = {"Cache-Control": "no-store"}
19
+
20
+
21
+ def create_app(workspace: Workspace, title: str = "Ontology Layer Explorer", max_graphs: int = 4,
22
+ warm: bool = True) -> FastAPI:
23
+ catalog = Catalog(workspace, title=title, max_graphs=max_graphs)
24
+ app = FastAPI(title=title, docs_url=None, redoc_url=None, openapi_url=None)
25
+ app.state.catalog = catalog
26
+ app.mount("/static", StaticFiles(directory=str(STATIC)), name="static")
27
+
28
+ def graph_of(version: str):
29
+ try:
30
+ return catalog.graph(version)
31
+ except KeyError:
32
+ raise HTTPException(404, f"unknown version {version!r}")
33
+ except WorkspaceError as exc:
34
+ raise HTTPException(503, str(exc))
35
+
36
+ @app.get("/health", response_class=PlainTextResponse)
37
+ def health():
38
+ return "ok"
39
+
40
+ @app.get("/")
41
+ def index():
42
+ return FileResponse(STATIC / "index.html", media_type="text/html; charset=utf-8", headers=NO_STORE)
43
+
44
+ @app.get("/api/workspace")
45
+ def workspace_view():
46
+ return catalog.workspace_view()
47
+
48
+ @app.get("/api/schema")
49
+ def schema():
50
+ return schema_view()
51
+
52
+ @app.get("/api/compare")
53
+ def compare_versions(left: str = Query(...), right: str = Query(...)):
54
+ return compare(graph_of(left), graph_of(right))
55
+
56
+ @app.get("/api/versions/{version}")
57
+ def version_summary(version: str):
58
+ return graph_of(version).summary(active=version == catalog.active())
59
+
60
+ @app.get("/api/versions/{version}/graph")
61
+ def term_layer(version: str):
62
+ return graph_of(version).term_layer()
63
+
64
+ @app.get("/api/versions/{version}/satellites")
65
+ def all_satellites(version: str):
66
+ return graph_of(version).all_satellites()
67
+
68
+ @app.get("/api/versions/{version}/terms/{term_id}/satellites")
69
+ def term_satellites(version: str, term_id: str):
70
+ try:
71
+ return graph_of(version).satellites_for(term_id)
72
+ except KeyError:
73
+ raise HTTPException(404, f"unknown term {term_id!r}")
74
+
75
+ @app.get("/api/versions/{version}/elements/{element_id:path}")
76
+ def element(version: str, element_id: str):
77
+ try:
78
+ return graph_of(version).element(element_id)
79
+ except KeyError:
80
+ raise HTTPException(404, f"unknown element {element_id!r}")
81
+
82
+ @app.get("/api/versions/{version}/search")
83
+ def search(version: str, q: str = Query(""), limit: int = Query(12, ge=1, le=50)):
84
+ return graph_of(version).search(q, limit)
85
+
86
+ @app.get("/api/versions/{version}/tools")
87
+ def tools(version: str):
88
+ return tool_view(graph_of(version))
89
+
90
+ @app.get("/api/versions/{version}/experience")
91
+ def experience(version: str):
92
+ graph_of(version)
93
+ try:
94
+ return catalog.experience(version)
95
+ except ValueError:
96
+ raise HTTPException(404, f"unknown version {version!r}")
97
+
98
+ if warm:
99
+ catalog.warm()
100
+ return app
@@ -0,0 +1,130 @@
1
+ """The versions of one workspace: a bounded cache of built graphs, and pointers re-read at most once a minute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import threading
7
+ import time
8
+ from collections import OrderedDict
9
+ from datetime import datetime, timezone
10
+
11
+ from .experience import ExperienceBuilder
12
+ from .graph import VersionGraph
13
+ from .workspace import Workspace, active_version, list_versions, version_records
14
+
15
+ POINTER_TTL = 60.0
16
+ SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-c(\d+))?$")
17
+
18
+
19
+ def version_kind(name: str) -> str:
20
+ match = SEMVER.match(name)
21
+ return "published" if match and match.group(4) is None else "candidate"
22
+
23
+
24
+ def version_key(name: str) -> tuple:
25
+ match = SEMVER.match(name)
26
+ if match:
27
+ major, minor, patch, candidate = match.groups()
28
+ return (0, -int(major), -int(minor), -int(patch), 1 if candidate else 0, -int(candidate or 0), name)
29
+ return (1, 0, 0, 0, 0, 0, name)
30
+
31
+
32
+ class Catalog:
33
+ def __init__(self, workspace: Workspace, title: str = "Ontology Layer Explorer", max_graphs: int = 4,
34
+ ttl: float = POINTER_TTL, clock=time.monotonic):
35
+ self.workspace, self.title, self.max_graphs, self.ttl, self.clock = workspace, title, max_graphs, ttl, clock
36
+ self.started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
37
+ self.experience_builder = ExperienceBuilder(workspace, clock)
38
+ self._lock = threading.Lock()
39
+ self._pointer_at: float | None = None
40
+ self._versions: list[str] = []
41
+ self._active: str | None = None
42
+ self._graphs: OrderedDict[str, VersionGraph] = OrderedDict()
43
+ self._loading: dict[str, threading.Lock] = {}
44
+ self._experience: dict[str, tuple[float, dict]] = {}
45
+
46
+ # ---- pointers ----
47
+
48
+ def _refresh(self, force: bool = False) -> None:
49
+ now = self.clock()
50
+ if not force and self._pointer_at is not None and now - self._pointer_at < self.ttl:
51
+ return
52
+ self._versions = sorted(list_versions(self.workspace), key=version_key)
53
+ self._active = active_version(self.workspace)
54
+ self._pointer_at = now
55
+
56
+ def versions(self) -> list[str]:
57
+ with self._lock:
58
+ self._refresh()
59
+ return list(self._versions)
60
+
61
+ def active(self) -> str | None:
62
+ with self._lock:
63
+ self._refresh()
64
+ return self._active
65
+
66
+ def workspace_view(self) -> dict:
67
+ with self._lock:
68
+ self._refresh()
69
+ versions, active = list(self._versions), self._active
70
+ return {"title": self.title, "active_version": active, "generated_at": self.started_at,
71
+ "versions": [{"name": v, "kind": version_kind(v), "active": v == active} for v in versions]}
72
+
73
+ def knows(self, version: str) -> bool:
74
+ with self._lock:
75
+ self._refresh()
76
+ if version in self._versions:
77
+ return True
78
+ self._refresh(force=True)
79
+ return version in self._versions
80
+
81
+ # ---- graphs ----
82
+
83
+ def graph(self, version: str) -> VersionGraph:
84
+ """The built graph of a version; loads once per version even under concurrent requests."""
85
+ with self._lock:
86
+ if version in self._graphs:
87
+ self._graphs.move_to_end(version)
88
+ return self._graphs[version]
89
+ lock = self._loading.setdefault(version, threading.Lock())
90
+ with lock:
91
+ with self._lock:
92
+ if version in self._graphs:
93
+ self._graphs.move_to_end(version)
94
+ return self._graphs[version]
95
+ if not self.knows(version):
96
+ raise KeyError(version)
97
+ graph = VersionGraph(version, version_records(self.workspace, version))
98
+ with self._lock:
99
+ self._graphs[version] = graph
100
+ self._graphs.move_to_end(version)
101
+ while len(self._graphs) > self.max_graphs:
102
+ self._graphs.popitem(last=False)
103
+ self._loading.pop(version, None)
104
+ return graph
105
+
106
+ def experience(self, version: str) -> dict:
107
+ now = self.clock()
108
+ with self._lock:
109
+ cached = self._experience.get(version)
110
+ if cached and now - cached[0] < self.ttl:
111
+ return cached[1]
112
+ report = self.experience_builder.build(version, self.graph(version))
113
+ with self._lock:
114
+ self._experience[version] = (now, report)
115
+ return report
116
+
117
+ def warm(self) -> threading.Thread:
118
+ """Load the active version and its report in the background so the first page is quick."""
119
+
120
+ def run() -> None:
121
+ try:
122
+ active = self.active()
123
+ if active:
124
+ self.experience(active)
125
+ except Exception: # a cold workspace is reported by the request that needs it
126
+ pass
127
+
128
+ thread = threading.Thread(target=run, name="evoontology-explorer-warm", daemon=True)
129
+ thread.start()
130
+ return thread
@@ -0,0 +1,87 @@
1
+ """Version comparison over the canonical records, as the single-file page computed it in the browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .graph import VersionGraph
9
+
10
+
11
+ def stable_normalize(value: Any) -> Any:
12
+ if isinstance(value, list):
13
+ return sorted((stable_normalize(v) for v in value), key=lambda v: json.dumps(v, ensure_ascii=False, sort_keys=True))
14
+ if isinstance(value, dict):
15
+ return {key: stable_normalize(value[key]) for key in sorted(value)}
16
+ return value
17
+
18
+
19
+ def stable_stringify(value: Any) -> str:
20
+ return json.dumps(stable_normalize(value), ensure_ascii=False, separators=(",", ":"))
21
+
22
+
23
+ def semantic_entries(graph: VersionGraph) -> dict[str, dict]:
24
+ """Nodes and relation edges by element id with a comparison signature; reference edges are derived, so skipped."""
25
+ entries: dict[str, dict] = {}
26
+ for node_id, data in graph.nodes.items():
27
+ entries[node_id] = {
28
+ "signature": stable_stringify(data["comparison"]), "fields": data["comparison"],
29
+ "family": data["family"], "title": data.get("label") or data["record_id"] or node_id,
30
+ "elementId": node_id, "owner_terms": [] if data["family"] == "term" else graph.owner_terms(node_id),
31
+ }
32
+ for edge_id, data in graph.edges.items():
33
+ if data["family"] != "relation":
34
+ continue
35
+ entries[edge_id] = {
36
+ "signature": stable_stringify(data["comparison"]), "fields": data["comparison"],
37
+ "family": "relation", "title": data.get("record_id") or data.get("label") or edge_id,
38
+ "elementId": edge_id, "owner_terms": [data["source"], data["target"]],
39
+ }
40
+ return entries
41
+
42
+
43
+ def field_changes(before: dict | None, after: dict | None) -> list[dict]:
44
+ keys = sorted(set(before or {}) | set(after or {}))
45
+ changes = []
46
+ for key in keys:
47
+ left = (before or {}).get(key)
48
+ right = (after or {}).get(key)
49
+ if stable_stringify(left) != stable_stringify(right):
50
+ changes.append({"field": key, "before": left, "after": right})
51
+ return changes
52
+
53
+
54
+ def _entry(status: str, key: str, item: dict) -> dict:
55
+ return {"key": key, "status": status, "family": item["family"], "title": item["title"],
56
+ "elementId": item["elementId"], "owner_terms": item["owner_terms"]}
57
+
58
+
59
+ def signature_diff(left: dict[str, dict], right: dict[str, dict]) -> dict:
60
+ left_status: dict[str, str] = {}
61
+ right_status: dict[str, str] = {}
62
+ counts = {"added": 0, "removed": 0, "changed": 0}
63
+ entries: dict[str, list] = {"added": [], "removed": [], "changed": []}
64
+ for key, item in left.items():
65
+ if key not in right:
66
+ left_status[key] = "removed"
67
+ counts["removed"] += 1
68
+ entries["removed"].append(_entry("removed", key, item))
69
+ elif item["signature"] != right[key]["signature"]:
70
+ left_status[key] = right_status[key] = "changed"
71
+ counts["changed"] += 1
72
+ entries["changed"].append({**_entry("changed", key, item),
73
+ "changes": field_changes(item["fields"], right[key]["fields"])})
74
+ else:
75
+ left_status[key] = right_status[key] = "unchanged"
76
+ for key, item in right.items():
77
+ if key not in left:
78
+ right_status[key] = "added"
79
+ counts["added"] += 1
80
+ entries["added"].append(_entry("added", key, item))
81
+ return {"left_status": left_status, "right_status": right_status, "counts": counts, "entries": entries}
82
+
83
+
84
+ def compare(left: VersionGraph, right: VersionGraph) -> dict:
85
+ result = signature_diff(semantic_entries(left), semantic_entries(right))
86
+ result["left_version"], result["right_version"] = left.version, right.version
87
+ return result