heritage-cli 1.0.2__tar.gz → 1.0.3__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.
- heritage_cli-1.0.3/LICENSE +21 -0
- heritage_cli-1.0.3/PKG-INFO +149 -0
- heritage_cli-1.0.3/README.md +116 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/pyproject.toml +20 -3
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/__init__.py +1 -1
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/main.py +103 -0
- heritage_cli-1.0.3/src/heritage_cli/open_context.py +231 -0
- heritage_cli-1.0.3/src/heritage_cli/vocab_generator.py +333 -0
- heritage_cli-1.0.3/src/heritage_cli.egg-info/PKG-INFO +149 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/SOURCES.txt +4 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/requires.txt +6 -0
- heritage_cli-1.0.3/tests/test_open_context.py +157 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/tests/test_smoke.py +1 -0
- heritage_cli-1.0.2/PKG-INFO +0 -20
- heritage_cli-1.0.2/README.md +0 -141
- heritage_cli-1.0.2/src/heritage_cli.egg-info/PKG-INFO +0 -20
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/setup.cfg +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/__init__.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/dibble.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/hoard.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/libby.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/stratigraph.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/trowel.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/orchestrator.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/dependency_links.txt +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/entry_points.txt +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/top_level.txt +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/tests/test_integration.py +0 -0
- {heritage_cli-1.0.2 → heritage_cli-1.0.3}/tests/test_orchestrator.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Bouck
|
|
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,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: heritage-cli
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Unified CLI for the heritage science open-source ecosystem — orchestrates HOARD, StratiGraph, Trowel, Libby, Dibble, and other tools
|
|
5
|
+
Author-email: Mark Bouck <markbouck@duck.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dig-tools/heritage-cli
|
|
8
|
+
Project-URL: Documentation, https://github.com/dig-tools/heritage-cli#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/dig-tools/heritage-cli/issues
|
|
10
|
+
Project-URL: Repository, https://github.com/dig-tools/heritage-cli.git
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Requires-Dist: rich>=13
|
|
16
|
+
Requires-Dist: pyyaml>=6
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
19
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
22
|
+
Provides-Extra: hoard
|
|
23
|
+
Requires-Dist: hoard; extra == "hoard"
|
|
24
|
+
Provides-Extra: libby
|
|
25
|
+
Requires-Dist: libby; extra == "libby"
|
|
26
|
+
Provides-Extra: dibble
|
|
27
|
+
Requires-Dist: dibble; extra == "dibble"
|
|
28
|
+
Provides-Extra: trowel
|
|
29
|
+
Requires-Dist: trowel; extra == "trowel"
|
|
30
|
+
Provides-Extra: stratigraph
|
|
31
|
+
Requires-Dist: stratigraph; extra == "stratigraph"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<img src="docs/brand/project-lockup.svg" alt="Dig:Tools" width="720">
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
# heritage-cli
|
|
39
|
+
|
|
40
|
+
> One command-line front door for repeatable Dig:Tools heritage workflows.
|
|
41
|
+
|
|
42
|
+
[](#licence)
|
|
43
|
+
[](pyproject.toml)
|
|
44
|
+
[](https://github.com/dig-tools/heritage-cli)
|
|
45
|
+
|
|
46
|
+
`heritage-cli` orchestrates existing applications; it does not reimplement their
|
|
47
|
+
analysis. It can run HOARD phases, invoke compatible radiocarbon, lithics,
|
|
48
|
+
matrix, review, and publishing commands, and execute declarative YAML pipelines
|
|
49
|
+
with resumable state and explicit human-review gates.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m pip install heritage-cli
|
|
55
|
+
heritage --version
|
|
56
|
+
heritage --help
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Install the tools used by your workflow separately. Compatibility extras are
|
|
60
|
+
available for the historical command packages:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python -m pip install "heritage-cli[hoard,libby,stratigraph,dibble,trowel]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Common commands
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Inspect discoverable tools
|
|
70
|
+
heritage tools
|
|
71
|
+
|
|
72
|
+
# Discover open archaeological data with provenance retained
|
|
73
|
+
heritage open-context "amphora" --details
|
|
74
|
+
heritage open-context "amphora" --details --json > comparanda.json
|
|
75
|
+
|
|
76
|
+
# Run a single HOARD phase or the full pipeline
|
|
77
|
+
heritage run --project my_site --phase 0
|
|
78
|
+
heritage run --project my_site --auto
|
|
79
|
+
|
|
80
|
+
# Run a declarative multi-tool workflow
|
|
81
|
+
heritage run --project my_site \
|
|
82
|
+
--pipeline pipeline.example.yaml \
|
|
83
|
+
--workspace ./erd_workspace
|
|
84
|
+
|
|
85
|
+
# Specialist hand-offs
|
|
86
|
+
heritage calibrate --project my_site
|
|
87
|
+
heritage lithics --project my_site --input ./scans
|
|
88
|
+
heritage review --project my_site
|
|
89
|
+
heritage matrix --project my_site
|
|
90
|
+
heritage publish --project my_site --format docx,pdf
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Run `heritage COMMAND --help` before scripting a command; the installed version’s
|
|
94
|
+
help is authoritative.
|
|
95
|
+
|
|
96
|
+
### Open Context discovery
|
|
97
|
+
|
|
98
|
+
`heritage open-context` is a bounded, read-only connector to Open Context's
|
|
99
|
+
public JSON-LD API. It does not mirror data or automatically paginate. Geometry
|
|
100
|
+
is omitted by default; add `--include-geometry` only when published coordinates
|
|
101
|
+
are necessary and ethically appropriate. `--details` retrieves each record's
|
|
102
|
+
exact Creative Commons licence, creators, project, and persistent identifier at
|
|
103
|
+
a service-friendly rate below three requests per second.
|
|
104
|
+
|
|
105
|
+
Open Context's terms, record-level licences, contributor attribution, and
|
|
106
|
+
archaeological ethics continue to apply to downloaded results. Human-remains-
|
|
107
|
+
related records are flagged and produce an ethical-use notice.
|
|
108
|
+
|
|
109
|
+
## Pipelines and state
|
|
110
|
+
|
|
111
|
+
Pipeline YAML describes ordered application steps and review gates. Progress is
|
|
112
|
+
stored beneath the selected workspace, allowing an interrupted run to resume
|
|
113
|
+
without silently repeating completed work. [`pipeline.example.yaml`](pipeline.example.yaml)
|
|
114
|
+
is a minimal working template.
|
|
115
|
+
|
|
116
|
+
Human gates are intentional. `--auto` may skip them in pipeline mode and should
|
|
117
|
+
only be used when the inputs and outputs are independently reviewed elsewhere.
|
|
118
|
+
|
|
119
|
+
## Dig:Tools relationships
|
|
120
|
+
|
|
121
|
+
| Repository | Role |
|
|
122
|
+
|---|---|
|
|
123
|
+
| [Dig:Codex](https://github.com/dig-tools/codex) | Report generation and research/archive tools |
|
|
124
|
+
| [Dig:Stratum](https://github.com/dig-tools/stratum) | Field recording and Harris Matrices |
|
|
125
|
+
| [Dig:Crucible](https://github.com/dig-tools/crucible) | Lithics, dating, and laboratory analysis |
|
|
126
|
+
| [Dig:Folio](https://github.com/dig-tools/folio) | Collections management and vocabulary generation |
|
|
127
|
+
| [heritage-types](https://github.com/dig-tools/heritage-types) | Canonical cross-tool schemas |
|
|
128
|
+
|
|
129
|
+
Some command adapters retain historical executable names (`hoard`, `libby`,
|
|
130
|
+
`dibble`, `trowel`, `stratigraph`) for compatibility after consolidation.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
git clone https://github.com/dig-tools/heritage-cli.git
|
|
136
|
+
cd heritage-cli
|
|
137
|
+
python -m venv .venv
|
|
138
|
+
source .venv/bin/activate
|
|
139
|
+
python -m pip install -e ".[dev]"
|
|
140
|
+
python -m pytest tests -m "not integration"
|
|
141
|
+
mypy src
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
See [USER_GUIDE.md](USER_GUIDE.md) for pipeline setup, review gates, state, and
|
|
145
|
+
troubleshooting.
|
|
146
|
+
|
|
147
|
+
## Licence
|
|
148
|
+
|
|
149
|
+
MIT.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/brand/project-lockup.svg" alt="Dig:Tools" width="720">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# heritage-cli
|
|
6
|
+
|
|
7
|
+
> One command-line front door for repeatable Dig:Tools heritage workflows.
|
|
8
|
+
|
|
9
|
+
[](#licence)
|
|
10
|
+
[](pyproject.toml)
|
|
11
|
+
[](https://github.com/dig-tools/heritage-cli)
|
|
12
|
+
|
|
13
|
+
`heritage-cli` orchestrates existing applications; it does not reimplement their
|
|
14
|
+
analysis. It can run HOARD phases, invoke compatible radiocarbon, lithics,
|
|
15
|
+
matrix, review, and publishing commands, and execute declarative YAML pipelines
|
|
16
|
+
with resumable state and explicit human-review gates.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m pip install heritage-cli
|
|
22
|
+
heritage --version
|
|
23
|
+
heritage --help
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Install the tools used by your workflow separately. Compatibility extras are
|
|
27
|
+
available for the historical command packages:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m pip install "heritage-cli[hoard,libby,stratigraph,dibble,trowel]"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Common commands
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Inspect discoverable tools
|
|
37
|
+
heritage tools
|
|
38
|
+
|
|
39
|
+
# Discover open archaeological data with provenance retained
|
|
40
|
+
heritage open-context "amphora" --details
|
|
41
|
+
heritage open-context "amphora" --details --json > comparanda.json
|
|
42
|
+
|
|
43
|
+
# Run a single HOARD phase or the full pipeline
|
|
44
|
+
heritage run --project my_site --phase 0
|
|
45
|
+
heritage run --project my_site --auto
|
|
46
|
+
|
|
47
|
+
# Run a declarative multi-tool workflow
|
|
48
|
+
heritage run --project my_site \
|
|
49
|
+
--pipeline pipeline.example.yaml \
|
|
50
|
+
--workspace ./erd_workspace
|
|
51
|
+
|
|
52
|
+
# Specialist hand-offs
|
|
53
|
+
heritage calibrate --project my_site
|
|
54
|
+
heritage lithics --project my_site --input ./scans
|
|
55
|
+
heritage review --project my_site
|
|
56
|
+
heritage matrix --project my_site
|
|
57
|
+
heritage publish --project my_site --format docx,pdf
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run `heritage COMMAND --help` before scripting a command; the installed version’s
|
|
61
|
+
help is authoritative.
|
|
62
|
+
|
|
63
|
+
### Open Context discovery
|
|
64
|
+
|
|
65
|
+
`heritage open-context` is a bounded, read-only connector to Open Context's
|
|
66
|
+
public JSON-LD API. It does not mirror data or automatically paginate. Geometry
|
|
67
|
+
is omitted by default; add `--include-geometry` only when published coordinates
|
|
68
|
+
are necessary and ethically appropriate. `--details` retrieves each record's
|
|
69
|
+
exact Creative Commons licence, creators, project, and persistent identifier at
|
|
70
|
+
a service-friendly rate below three requests per second.
|
|
71
|
+
|
|
72
|
+
Open Context's terms, record-level licences, contributor attribution, and
|
|
73
|
+
archaeological ethics continue to apply to downloaded results. Human-remains-
|
|
74
|
+
related records are flagged and produce an ethical-use notice.
|
|
75
|
+
|
|
76
|
+
## Pipelines and state
|
|
77
|
+
|
|
78
|
+
Pipeline YAML describes ordered application steps and review gates. Progress is
|
|
79
|
+
stored beneath the selected workspace, allowing an interrupted run to resume
|
|
80
|
+
without silently repeating completed work. [`pipeline.example.yaml`](pipeline.example.yaml)
|
|
81
|
+
is a minimal working template.
|
|
82
|
+
|
|
83
|
+
Human gates are intentional. `--auto` may skip them in pipeline mode and should
|
|
84
|
+
only be used when the inputs and outputs are independently reviewed elsewhere.
|
|
85
|
+
|
|
86
|
+
## Dig:Tools relationships
|
|
87
|
+
|
|
88
|
+
| Repository | Role |
|
|
89
|
+
|---|---|
|
|
90
|
+
| [Dig:Codex](https://github.com/dig-tools/codex) | Report generation and research/archive tools |
|
|
91
|
+
| [Dig:Stratum](https://github.com/dig-tools/stratum) | Field recording and Harris Matrices |
|
|
92
|
+
| [Dig:Crucible](https://github.com/dig-tools/crucible) | Lithics, dating, and laboratory analysis |
|
|
93
|
+
| [Dig:Folio](https://github.com/dig-tools/folio) | Collections management and vocabulary generation |
|
|
94
|
+
| [heritage-types](https://github.com/dig-tools/heritage-types) | Canonical cross-tool schemas |
|
|
95
|
+
|
|
96
|
+
Some command adapters retain historical executable names (`hoard`, `libby`,
|
|
97
|
+
`dibble`, `trowel`, `stratigraph`) for compatibility after consolidation.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone https://github.com/dig-tools/heritage-cli.git
|
|
103
|
+
cd heritage-cli
|
|
104
|
+
python -m venv .venv
|
|
105
|
+
source .venv/bin/activate
|
|
106
|
+
python -m pip install -e ".[dev]"
|
|
107
|
+
python -m pytest tests -m "not integration"
|
|
108
|
+
mypy src
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See [USER_GUIDE.md](USER_GUIDE.md) for pipeline setup, review gates, state, and
|
|
112
|
+
troubleshooting.
|
|
113
|
+
|
|
114
|
+
## Licence
|
|
115
|
+
|
|
116
|
+
MIT.
|
|
@@ -4,12 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "heritage-cli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.3"
|
|
8
8
|
description = "Unified CLI for the heritage science open-source ecosystem — orchestrates HOARD, StratiGraph, Trowel, Libby, Dibble, and other tools"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
|
-
license =
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
readme = "README.md"
|
|
11
13
|
authors = [
|
|
12
|
-
{ name = "Mark Bouck" },
|
|
14
|
+
{ name = "Mark Bouck", email = "markbouck@duck.com" },
|
|
13
15
|
]
|
|
14
16
|
dependencies = [
|
|
15
17
|
"typer>=0.12",
|
|
@@ -17,7 +19,19 @@ dependencies = [
|
|
|
17
19
|
"pyyaml>=6",
|
|
18
20
|
]
|
|
19
21
|
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/dig-tools/heritage-cli"
|
|
24
|
+
Documentation = "https://github.com/dig-tools/heritage-cli#readme"
|
|
25
|
+
Issues = "https://github.com/dig-tools/heritage-cli/issues"
|
|
26
|
+
Repository = "https://github.com/dig-tools/heritage-cli.git"
|
|
27
|
+
|
|
20
28
|
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"build>=1.2",
|
|
31
|
+
"mypy>=1.10",
|
|
32
|
+
"pytest>=8",
|
|
33
|
+
"ruff>=0.4",
|
|
34
|
+
]
|
|
21
35
|
hoard = ["hoard"]
|
|
22
36
|
libby = ["libby"]
|
|
23
37
|
dibble = ["dibble"]
|
|
@@ -47,3 +61,6 @@ ignore_missing_imports = true
|
|
|
47
61
|
markers = [
|
|
48
62
|
"integration: tests that require sibling ecosystem packages to be installed",
|
|
49
63
|
]
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.per-file-ignores]
|
|
66
|
+
"tests/test_smoke.py" = ["F401"] # Importability is the assertion in this smoke test.
|
|
@@ -18,12 +18,14 @@ Usage:
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
from pathlib import Path
|
|
21
|
+
from typing import Annotated, Optional
|
|
21
22
|
|
|
22
23
|
import typer
|
|
23
24
|
from rich.console import Console
|
|
24
25
|
from rich.table import Table
|
|
25
26
|
|
|
26
27
|
from heritage_cli import __version__
|
|
28
|
+
from heritage_cli.vocab_generator import generate as _generate_vocab
|
|
27
29
|
|
|
28
30
|
app = typer.Typer(
|
|
29
31
|
name="heritage",
|
|
@@ -459,6 +461,107 @@ def pipeline_status(
|
|
|
459
461
|
raise typer.Exit(1)
|
|
460
462
|
|
|
461
463
|
|
|
464
|
+
# ── Code generation sub-commands ─────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@app.command(name="open-context")
|
|
468
|
+
def open_context(
|
|
469
|
+
query: Annotated[str, typer.Argument(help="Text to search for")],
|
|
470
|
+
limit: int = typer.Option(
|
|
471
|
+
10, "--limit", "-n", min=1, max=20, help="Results (1–20)"
|
|
472
|
+
),
|
|
473
|
+
start: int = typer.Option(
|
|
474
|
+
0, "--start", min=0, help="Result offset; no automatic paging"
|
|
475
|
+
),
|
|
476
|
+
details: bool = typer.Option(
|
|
477
|
+
False, "--details", help="Fetch exact licence and creator metadata"
|
|
478
|
+
),
|
|
479
|
+
include_geometry: bool = typer.Option(
|
|
480
|
+
False,
|
|
481
|
+
"--include-geometry",
|
|
482
|
+
help="Include published coordinates in JSON output (off by default)",
|
|
483
|
+
),
|
|
484
|
+
json_output: bool = typer.Option(
|
|
485
|
+
False, "--json", help="Emit reproducible JSON instead of a table"
|
|
486
|
+
),
|
|
487
|
+
) -> None:
|
|
488
|
+
"""Search Open Context's public archaeological-data catalogue.
|
|
489
|
+
|
|
490
|
+
Results retain source and citation URIs. Geometry is deliberately omitted
|
|
491
|
+
unless explicitly requested. Open Context's terms and each record's licence
|
|
492
|
+
continue to apply.
|
|
493
|
+
"""
|
|
494
|
+
import json
|
|
495
|
+
|
|
496
|
+
from heritage_cli.open_context import OpenContextClient, OpenContextError
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
result = OpenContextClient().search(
|
|
500
|
+
query,
|
|
501
|
+
rows=limit,
|
|
502
|
+
start=start,
|
|
503
|
+
details=details,
|
|
504
|
+
include_geometry=include_geometry,
|
|
505
|
+
)
|
|
506
|
+
except (OpenContextError, ValueError) as exc:
|
|
507
|
+
console.print(f"[red]✗[/] {exc}")
|
|
508
|
+
raise typer.Exit(1) from exc
|
|
509
|
+
|
|
510
|
+
if json_output:
|
|
511
|
+
typer.echo(json.dumps(result.to_dict(), indent=2, ensure_ascii=False))
|
|
512
|
+
else:
|
|
513
|
+
table = Table(title=f"Open Context — {result.total_results:,} matches")
|
|
514
|
+
table.add_column("Record", style="cyan", overflow="fold")
|
|
515
|
+
table.add_column("Category")
|
|
516
|
+
table.add_column("Project", overflow="fold")
|
|
517
|
+
table.add_column("Licence / citation", overflow="fold")
|
|
518
|
+
for record in result.records:
|
|
519
|
+
attribution = record.license_label or "Run with --details for exact licence"
|
|
520
|
+
citation = record.citation_uri or record.uri
|
|
521
|
+
if record.human_remains_related:
|
|
522
|
+
attribution = f"⚠ human-remains-related\n{attribution}"
|
|
523
|
+
table.add_row(
|
|
524
|
+
f"{record.label}\n{record.uri}",
|
|
525
|
+
record.item_category,
|
|
526
|
+
record.project_label,
|
|
527
|
+
f"{attribution}\n{citation}",
|
|
528
|
+
)
|
|
529
|
+
console.print(table)
|
|
530
|
+
console.print(f"[dim]Source: {result.source}[/]")
|
|
531
|
+
|
|
532
|
+
if any(record.human_remains_related for record in result.records):
|
|
533
|
+
console.print(
|
|
534
|
+
"[yellow]Ethical-use notice:[/] One or more records relate to human remains. "
|
|
535
|
+
"Follow Open Context's terms, relevant community protocols, and professional ethics."
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ── Code generation sub-commands ─────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@app.command(name="generate-vocab")
|
|
543
|
+
def generate_vocab(
|
|
544
|
+
input_file: Annotated[Path, typer.Argument(help="Vocabulary JSON file")] = Path(
|
|
545
|
+
"vocabulary.json"
|
|
546
|
+
),
|
|
547
|
+
ts_output: Annotated[
|
|
548
|
+
Optional[Path], typer.Option("--ts", help="TypeScript output path")
|
|
549
|
+
] = None,
|
|
550
|
+
rust_output: Annotated[
|
|
551
|
+
Optional[Path], typer.Option("--rust", help="Rust output path")
|
|
552
|
+
] = None,
|
|
553
|
+
) -> None:
|
|
554
|
+
"""Generate TypeScript and/or Rust source from a vocabulary JSON file."""
|
|
555
|
+
try:
|
|
556
|
+
_generate_vocab(input_file, ts_output, rust_output)
|
|
557
|
+
except SystemExit as exc:
|
|
558
|
+
code = exc.code if isinstance(exc.code, int) else 1
|
|
559
|
+
raise typer.Exit(code=code or 1)
|
|
560
|
+
except Exception as exc:
|
|
561
|
+
console.print(f"[red]✗[/] {exc}")
|
|
562
|
+
raise typer.Exit(1)
|
|
563
|
+
|
|
564
|
+
|
|
462
565
|
# ── Tools sub-command ────────────────────────────────────────────────────────
|
|
463
566
|
|
|
464
567
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Read-only, provenance-preserving client for the Open Context API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
from urllib.error import HTTPError, URLError
|
|
11
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://opencontext.org"
|
|
15
|
+
USER_AGENT = "heritage-cli/1.0 (+https://github.com/dig-tools/heritage-cli)"
|
|
16
|
+
_MARKUP = re.compile(r"<[^>]+>")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenContextError(RuntimeError):
|
|
20
|
+
"""A safe, user-facing Open Context request or response error."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class OpenContextRecord:
|
|
25
|
+
"""A discovery record with its reusable provenance fields."""
|
|
26
|
+
|
|
27
|
+
label: str
|
|
28
|
+
uri: str
|
|
29
|
+
citation_uri: str = ""
|
|
30
|
+
project_label: str = ""
|
|
31
|
+
project_uri: str = ""
|
|
32
|
+
context_label: str = ""
|
|
33
|
+
item_category: str = ""
|
|
34
|
+
snippet: str = ""
|
|
35
|
+
human_remains_related: bool = False
|
|
36
|
+
license_uri: str = ""
|
|
37
|
+
license_label: str = ""
|
|
38
|
+
creators: list[str] = field(default_factory=list)
|
|
39
|
+
creator_uris: list[str] = field(default_factory=list)
|
|
40
|
+
identifiers: list[str] = field(default_factory=list)
|
|
41
|
+
geometry: dict[str, Any] | None = None
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
"""Return a JSON-serialisable representation."""
|
|
45
|
+
return asdict(self)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class OpenContextSearchResult:
|
|
50
|
+
"""One bounded page of Open Context search results."""
|
|
51
|
+
|
|
52
|
+
query: str
|
|
53
|
+
total_results: int
|
|
54
|
+
records: list[OpenContextRecord]
|
|
55
|
+
source: str
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
"""Return a JSON-serialisable representation."""
|
|
59
|
+
return {
|
|
60
|
+
"query": self.query,
|
|
61
|
+
"total_results": self.total_results,
|
|
62
|
+
"source": self.source,
|
|
63
|
+
"records": [record.to_dict() for record in self.records],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OpenContextClient:
|
|
68
|
+
"""Small synchronous client that respects Open Context service limits."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
timeout: float = 20.0,
|
|
74
|
+
max_response_bytes: int = 10_000_000,
|
|
75
|
+
request_interval: float = 0.35,
|
|
76
|
+
opener: Callable[..., Any] = urlopen,
|
|
77
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
78
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.timeout = timeout
|
|
81
|
+
self.max_response_bytes = max_response_bytes
|
|
82
|
+
self.request_interval = request_interval
|
|
83
|
+
self._opener = opener
|
|
84
|
+
self._sleep = sleep
|
|
85
|
+
self._monotonic = monotonic
|
|
86
|
+
self._last_request: float | None = None
|
|
87
|
+
|
|
88
|
+
def search(
|
|
89
|
+
self,
|
|
90
|
+
query: str,
|
|
91
|
+
*,
|
|
92
|
+
rows: int = 10,
|
|
93
|
+
start: int = 0,
|
|
94
|
+
details: bool = False,
|
|
95
|
+
include_geometry: bool = False,
|
|
96
|
+
) -> OpenContextSearchResult:
|
|
97
|
+
"""Search subject records without automatic pagination or caching."""
|
|
98
|
+
query = query.strip()
|
|
99
|
+
if not query:
|
|
100
|
+
raise ValueError("query must not be empty")
|
|
101
|
+
if not 1 <= rows <= 20:
|
|
102
|
+
raise ValueError("rows must be between 1 and 20")
|
|
103
|
+
if start < 0:
|
|
104
|
+
raise ValueError("start must not be negative")
|
|
105
|
+
|
|
106
|
+
params = urlencode(
|
|
107
|
+
{"q": query, "type": "subjects", "rows": rows, "start": start}
|
|
108
|
+
)
|
|
109
|
+
source = f"{BASE_URL}/query/.json?{params}"
|
|
110
|
+
payload = self._request_json(source)
|
|
111
|
+
# Full GeoJSON-LD searches use ``features``. Reduced response modes can
|
|
112
|
+
# expose dictionary records under ``oc-api:has-results`` instead.
|
|
113
|
+
features = payload.get("features") or payload.get("oc-api:has-results", [])
|
|
114
|
+
records = [
|
|
115
|
+
self._parse_feature(feature, include_geometry=include_geometry)
|
|
116
|
+
for feature in features
|
|
117
|
+
if isinstance(feature, dict)
|
|
118
|
+
and feature.get("category") == "oc-api:geo-record"
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
if details:
|
|
122
|
+
for record in records:
|
|
123
|
+
self._enrich_record(record)
|
|
124
|
+
|
|
125
|
+
return OpenContextSearchResult(
|
|
126
|
+
query=query,
|
|
127
|
+
total_results=int(payload.get("totalResults", len(records))),
|
|
128
|
+
records=records,
|
|
129
|
+
source=source,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _request_json(self, url: str) -> dict[str, Any]:
|
|
133
|
+
self._throttle()
|
|
134
|
+
request = Request(
|
|
135
|
+
url, headers={"Accept": "application/ld+json", "User-Agent": USER_AGENT}
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
with self._opener(request, timeout=self.timeout) as response:
|
|
139
|
+
raw = response.read(self.max_response_bytes + 1)
|
|
140
|
+
except HTTPError as exc:
|
|
141
|
+
raise OpenContextError(f"Open Context returned HTTP {exc.code}") from exc
|
|
142
|
+
except (URLError, OSError, TimeoutError) as exc:
|
|
143
|
+
raise OpenContextError(f"Open Context request failed: {exc}") from exc
|
|
144
|
+
if len(raw) > self.max_response_bytes:
|
|
145
|
+
raise OpenContextError("Open Context response too large")
|
|
146
|
+
try:
|
|
147
|
+
payload = json.loads(raw)
|
|
148
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
149
|
+
raise OpenContextError("Open Context returned invalid JSON") from exc
|
|
150
|
+
if not isinstance(payload, dict):
|
|
151
|
+
raise OpenContextError("Open Context returned an unexpected JSON document")
|
|
152
|
+
return payload
|
|
153
|
+
|
|
154
|
+
def _throttle(self) -> None:
|
|
155
|
+
now = self._monotonic()
|
|
156
|
+
if self._last_request is not None:
|
|
157
|
+
remaining = self.request_interval - (now - self._last_request)
|
|
158
|
+
if remaining > 0:
|
|
159
|
+
self._sleep(remaining)
|
|
160
|
+
now = self._monotonic()
|
|
161
|
+
self._last_request = now
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _parse_feature(
|
|
165
|
+
feature: dict[str, Any], *, include_geometry: bool
|
|
166
|
+
) -> OpenContextRecord:
|
|
167
|
+
properties = feature.get("properties") or {}
|
|
168
|
+
uri = (
|
|
169
|
+
properties.get("href")
|
|
170
|
+
or properties.get("uri")
|
|
171
|
+
or feature.get("rdfs:isDefinedBy", "")
|
|
172
|
+
)
|
|
173
|
+
return OpenContextRecord(
|
|
174
|
+
label=str(
|
|
175
|
+
feature.get("label") or properties.get("label") or "Untitled record"
|
|
176
|
+
),
|
|
177
|
+
uri=OpenContextClient._https_uri(str(uri)),
|
|
178
|
+
citation_uri=str(properties.get("citation uri", "")),
|
|
179
|
+
project_label=str(properties.get("project label", "")),
|
|
180
|
+
project_uri=OpenContextClient._https_uri(
|
|
181
|
+
str(properties.get("project href", ""))
|
|
182
|
+
),
|
|
183
|
+
context_label=str(properties.get("context label", "")),
|
|
184
|
+
item_category=str(properties.get("item category", "")),
|
|
185
|
+
snippet=_MARKUP.sub("", str(properties.get("snippet", ""))),
|
|
186
|
+
human_remains_related=bool(
|
|
187
|
+
feature.get("oc-api:human-remains-related", False)
|
|
188
|
+
),
|
|
189
|
+
geometry=feature.get("geometry") if include_geometry else None,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _enrich_record(self, record: OpenContextRecord) -> None:
|
|
193
|
+
if not record.uri:
|
|
194
|
+
return
|
|
195
|
+
if urlsplit(record.uri).hostname != "opencontext.org":
|
|
196
|
+
raise OpenContextError("Open Context returned an untrusted record URI")
|
|
197
|
+
payload = self._request_json(f"{record.uri.rstrip('/')}.json")
|
|
198
|
+
licences = payload.get("dc-terms:license") or []
|
|
199
|
+
creators = payload.get("dc-terms:creator") or []
|
|
200
|
+
identifiers = payload.get("dc-terms:identifier") or []
|
|
201
|
+
projects = payload.get("dc-terms:isPartOf") or []
|
|
202
|
+
|
|
203
|
+
if licences and isinstance(licences[0], dict):
|
|
204
|
+
record.license_uri = str(licences[0].get("id", ""))
|
|
205
|
+
record.license_label = str(licences[0].get("label", ""))
|
|
206
|
+
record.creators = [
|
|
207
|
+
str(item.get("label", "")) for item in creators if isinstance(item, dict)
|
|
208
|
+
]
|
|
209
|
+
record.creator_uris = [
|
|
210
|
+
str(item.get("id", "")) for item in creators if isinstance(item, dict)
|
|
211
|
+
]
|
|
212
|
+
record.identifiers = [str(item) for item in identifiers]
|
|
213
|
+
if not record.citation_uri:
|
|
214
|
+
record.citation_uri = next(
|
|
215
|
+
(item for item in record.identifiers if "ark:" in item), ""
|
|
216
|
+
)
|
|
217
|
+
if projects and isinstance(projects[0], dict):
|
|
218
|
+
record.project_label = record.project_label or str(
|
|
219
|
+
projects[0].get("label", "")
|
|
220
|
+
)
|
|
221
|
+
record.project_uri = record.project_uri or str(projects[0].get("id", ""))
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _https_uri(uri: str) -> str:
|
|
225
|
+
if not uri:
|
|
226
|
+
return ""
|
|
227
|
+
parts = urlsplit(uri if "://" in uri else f"https://{uri.lstrip('/')}")
|
|
228
|
+
scheme = "https" if parts.hostname == "opencontext.org" else parts.scheme
|
|
229
|
+
return urlunsplit(
|
|
230
|
+
(scheme, parts.netloc, parts.path, parts.query, parts.fragment)
|
|
231
|
+
)
|