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.
Files changed (29) hide show
  1. heritage_cli-1.0.3/LICENSE +21 -0
  2. heritage_cli-1.0.3/PKG-INFO +149 -0
  3. heritage_cli-1.0.3/README.md +116 -0
  4. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/pyproject.toml +20 -3
  5. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/__init__.py +1 -1
  6. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/main.py +103 -0
  7. heritage_cli-1.0.3/src/heritage_cli/open_context.py +231 -0
  8. heritage_cli-1.0.3/src/heritage_cli/vocab_generator.py +333 -0
  9. heritage_cli-1.0.3/src/heritage_cli.egg-info/PKG-INFO +149 -0
  10. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/SOURCES.txt +4 -0
  11. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/requires.txt +6 -0
  12. heritage_cli-1.0.3/tests/test_open_context.py +157 -0
  13. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/tests/test_smoke.py +1 -0
  14. heritage_cli-1.0.2/PKG-INFO +0 -20
  15. heritage_cli-1.0.2/README.md +0 -141
  16. heritage_cli-1.0.2/src/heritage_cli.egg-info/PKG-INFO +0 -20
  17. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/setup.cfg +0 -0
  18. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/__init__.py +0 -0
  19. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/dibble.py +0 -0
  20. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/hoard.py +0 -0
  21. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/libby.py +0 -0
  22. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/stratigraph.py +0 -0
  23. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/commands/trowel.py +0 -0
  24. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli/orchestrator.py +0 -0
  25. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/dependency_links.txt +0 -0
  26. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/entry_points.txt +0 -0
  27. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/src/heritage_cli.egg-info/top_level.txt +0 -0
  28. {heritage_cli-1.0.2 → heritage_cli-1.0.3}/tests/test_integration.py +0 -0
  29. {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
+ [![License: MIT](https://img.shields.io/badge/license-MIT-2ea44f.svg)](#licence)
43
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-3776ab?logo=python)](pyproject.toml)
44
+ [![GitHub](https://img.shields.io/badge/GitHub-dig--tools%2Fheritage--cli-181717?logo=github)](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
+ [![License: MIT](https://img.shields.io/badge/license-MIT-2ea44f.svg)](#licence)
10
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-3776ab?logo=python)](pyproject.toml)
11
+ [![GitHub](https://img.shields.io/badge/GitHub-dig--tools%2Fheritage--cli-181717?logo=github)](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.2"
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 = { text = "MIT" }
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.
@@ -23,4 +23,4 @@ from importlib.metadata import PackageNotFoundError, version
23
23
  try:
24
24
  __version__ = version("heritage-cli")
25
25
  except PackageNotFoundError:
26
- __version__ = "1.0.0"
26
+ __version__ = "1.0.3"
@@ -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
+ )