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.
- evoontology_explorer-0.1.0/.gitignore +15 -0
- evoontology_explorer-0.1.0/LICENSE +21 -0
- evoontology_explorer-0.1.0/NOTICE +11 -0
- evoontology_explorer-0.1.0/PKG-INFO +128 -0
- evoontology_explorer-0.1.0/README.md +80 -0
- evoontology_explorer-0.1.0/pyproject.toml +45 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/__init__.py +14 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/__main__.py +35 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/api.py +100 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/cache.py +130 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/diff.py +87 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/experience.py +103 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/graph.py +384 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/model.py +195 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/schema.py +52 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/static/app.js +2861 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/static/cytoscape.min.js +32 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/static/index.html +654 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/tools.py +85 -0
- evoontology_explorer-0.1.0/src/evoontology_explorer/workspace.py +239 -0
- evoontology_explorer-0.1.0/tests/conftest.py +20 -0
- evoontology_explorer-0.1.0/tests/fixtures/golden_0.2.0.json +1432 -0
- evoontology_explorer-0.1.0/tests/fixtures/golden_experience_0.2.0.json +70 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/active.json +3 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/evolution/run_1/evaluations/round1.json +11 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/evolution/run_1/run.json +12 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/project.json +8 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/reports/0.2.0.json +35 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/state.json +9 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/sync.json +3 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/trajectories/task_a.json +25 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/trajectories/task_b.json +12 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/trajectories/task_c.json +14 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/constraints.json +17 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/evidence.json +42 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/mappings.json +37 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/relations.json +20 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.1.0/terms.json +41 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/constraints.json +32 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/evidence.json +42 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/mappings.json +53 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/relations.json +32 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.0/terms.json +55 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/constraints.json +32 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/evidence.json +42 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/mappings.json +53 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/relations.json +32 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/versions/0.2.1-c1/terms.json +55 -0
- evoontology_explorer-0.1.0/tests/fixtures/workspace/workload/questions.json +28 -0
- evoontology_explorer-0.1.0/tests/make_fixture.py +140 -0
- evoontology_explorer-0.1.0/tests/test_api.py +65 -0
- evoontology_explorer-0.1.0/tests/test_cache.py +83 -0
- evoontology_explorer-0.1.0/tests/test_diff.py +35 -0
- evoontology_explorer-0.1.0/tests/test_experience.py +54 -0
- evoontology_explorer-0.1.0/tests/test_graph.py +90 -0
- evoontology_explorer-0.1.0/tests/test_no_private_data.py +50 -0
- evoontology_explorer-0.1.0/tests/test_workspace.py +136 -0
|
@@ -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
|