lantern-grammar 0.3.0__py3-none-any.whl
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.
- lantern_grammar/__init__.py +33 -0
- lantern_grammar/_exceptions.py +40 -0
- lantern_grammar/_grammar.py +483 -0
- lantern_grammar/_model/index.json +695 -0
- lantern_grammar/_model/manifest.json +20 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__arch.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__ch.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__ci.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__db.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__dc.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__dip.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__initiative.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__issue.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__question.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__spec.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__artifacts__td.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__gates__gt_030.json +13 -0
- lantern_grammar/_model/objects/Entity/lg__gates__gt_050.json +15 -0
- lantern_grammar/_model/objects/Entity/lg__gates__gt_060.json +15 -0
- lantern_grammar/_model/objects/Entity/lg__gates__gt_110.json +17 -0
- lantern_grammar/_model/objects/Entity/lg__gates__gt_115.json +19 -0
- lantern_grammar/_model/objects/Entity/lg__gates__gt_120.json +18 -0
- lantern_grammar/_model/objects/Entity/lg__gates__gt_130.json +18 -0
- lantern_grammar/_model/objects/Entity/lg__records__dec.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__records__ev.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__reltypes__decomposes_to.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__reltypes__requires_evidence.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__reltypes__requires_input.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__reltypes__requires_status.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__addressed.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__approved.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__candidate.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__concluded_initiative.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__draft.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__draft_initiative.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__in_progress_initiative.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__proposed.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__proposed_initiative.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__ready.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__ready_initiative.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__rejected.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__selected.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__superseded.json +7 -0
- lantern_grammar/_model/objects/Entity/lg__statuses__verified.json +7 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_evidence.dec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_evidence.ev.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_input.dip.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_030.requires_status.draft.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_evidence.dec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_evidence.ev.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_input.arch.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_input.dip.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_status.approved.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_050.requires_status.draft.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_evidence.dec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_evidence.ev.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_input.dip.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_input.spec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_status.approved.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_060.requires_status.draft.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_evidence.dec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_evidence.ev.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.arch.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.ch.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.spec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_input.td.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_status.approved.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_110.requires_status.proposed.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_evidence.dec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_evidence.ev.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.arch.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.ch.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.dc.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.spec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_input.td.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_status.approved.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_status.candidate.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_115.requires_status.ready.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_evidence.dec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_evidence.ev.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.ch.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.ci.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.db.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_input.td.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_status.approved.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_status.candidate.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_120.requires_status.ready.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_evidence.dec.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_evidence.ev.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.ch.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.ci.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.db.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_input.td.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_status.approved.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_status.ready.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__gt_130.requires_status.selected.json +10 -0
- lantern_grammar/_model/objects/Relation/lg__rel__initiative.decomposes_to.ch.json +10 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_arch.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_ch.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_ci.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_db.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_dc.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_dec.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_dip.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_ev.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_gate.json +18 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_initiative.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_issue.json +11 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_question.json +11 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_spec.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_addressed.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_approved.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_draft.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_proposed.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_ready.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_rejected.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_selected.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_superseded.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_status_verified.json +12 -0
- lantern_grammar/_model/objects/Term/lg__vocab__term_td.json +12 -0
- lantern_grammar/py.typed +0 -0
- lantern_grammar-0.3.0.dist-info/METADATA +243 -0
- lantern_grammar-0.3.0.dist-info/RECORD +126 -0
- lantern_grammar-0.3.0.dist-info/WHEEL +5 -0
- lantern_grammar-0.3.0.dist-info/licenses/LICENSE +201 -0
- lantern_grammar-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2025 Lantern Authors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""lantern-grammar — authoritative semantic model for Lantern governed workflow.
|
|
16
|
+
|
|
17
|
+
Public stable exports
|
|
18
|
+
---------------------
|
|
19
|
+
* :class:`Grammar` — read-only projection of the Lantern Grammar model.
|
|
20
|
+
* :exc:`LanternGrammarLoadError` — raised when the model cannot be loaded.
|
|
21
|
+
|
|
22
|
+
Canonical import pattern::
|
|
23
|
+
|
|
24
|
+
from lantern_grammar import Grammar, LanternGrammarLoadError
|
|
25
|
+
|
|
26
|
+
See the project README and decision note DN-LGR-PROP-004 for the full
|
|
27
|
+
compatibility-governed API contract.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from ._exceptions import LanternGrammarLoadError
|
|
31
|
+
from ._grammar import Grammar
|
|
32
|
+
|
|
33
|
+
__all__ = ["Grammar", "LanternGrammarLoadError"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright 2025 Lantern Authors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Stable exception types for the lantern-grammar package.
|
|
16
|
+
|
|
17
|
+
These exceptions are part of the compatibility-governed public contract.
|
|
18
|
+
See DN-LGR-PROP-004 for the full exception contract.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LanternGrammarLoadError(Exception):
|
|
23
|
+
"""Raised when the Lantern Grammar model cannot be loaded.
|
|
24
|
+
|
|
25
|
+
Returned by construction methods (``Grammar.load()`` and
|
|
26
|
+
``Grammar.from_directory()``) when model data is absent, unloadable, or
|
|
27
|
+
structurally invalid.
|
|
28
|
+
|
|
29
|
+
Subclasses may be introduced in later minor versions for more specific
|
|
30
|
+
failure modes; they are always catchable as ``LanternGrammarLoadError``.
|
|
31
|
+
|
|
32
|
+
Example::
|
|
33
|
+
|
|
34
|
+
from lantern_grammar import Grammar, LanternGrammarLoadError
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
grammar = Grammar.load()
|
|
38
|
+
except LanternGrammarLoadError as exc:
|
|
39
|
+
raise RuntimeError("Could not load Lantern Grammar") from exc
|
|
40
|
+
"""
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
# Copyright 2025 Lantern Authors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Core Grammar class — the read-only projection of Lantern Grammar model data.
|
|
16
|
+
|
|
17
|
+
This module is an implementation detail. Consumers should import only from
|
|
18
|
+
``lantern_grammar``::
|
|
19
|
+
|
|
20
|
+
from lantern_grammar import Grammar, LanternGrammarLoadError
|
|
21
|
+
|
|
22
|
+
Thread safety
|
|
23
|
+
-------------
|
|
24
|
+
A ``Grammar`` instance is immutable after construction and is safe for
|
|
25
|
+
concurrent read-only access across threads. See DN-LGR-PROP-004.
|
|
26
|
+
|
|
27
|
+
Authority boundary
|
|
28
|
+
------------------
|
|
29
|
+
The packaged model data is the semantic source of truth. This class is a
|
|
30
|
+
projection and query surface over that data; it does not invent or reinterpret
|
|
31
|
+
model meaning. Workflow policy, workbench IDs, and runtime posture remain
|
|
32
|
+
outside this package.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from types import MappingProxyType
|
|
40
|
+
from typing import Iterator, Optional
|
|
41
|
+
|
|
42
|
+
from ._exceptions import LanternGrammarLoadError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Location of bundled model data
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Resolution order for Grammar.load():
|
|
49
|
+
#
|
|
50
|
+
# 1. <package_dir>/_model/
|
|
51
|
+
# For installed (non-editable) packages: setuptools copies model/ into
|
|
52
|
+
# _model/ inside the wheel. For editable installs with a _model symlink
|
|
53
|
+
# manually created at src/lantern_grammar/_model -> ../../model, same path.
|
|
54
|
+
#
|
|
55
|
+
# 2. <project_root>/model/
|
|
56
|
+
# Fallback for editable installs in a src/ layout when no _model/ symlink
|
|
57
|
+
# exists. Detected by walking up from the package file to find model/.
|
|
58
|
+
#
|
|
59
|
+
def _find_model_bundle_dir() -> Path:
|
|
60
|
+
pkg_dir = Path(__file__).parent
|
|
61
|
+
# --- Primary: _model/ inside the installed package directory ---
|
|
62
|
+
bundled = pkg_dir / "_model"
|
|
63
|
+
if bundled.is_dir():
|
|
64
|
+
return bundled
|
|
65
|
+
# --- Fallback: project root model/ for src-layout editable installs ---
|
|
66
|
+
# src/lantern_grammar/ -> src/ -> project_root/ -> model/
|
|
67
|
+
candidate = pkg_dir.parent.parent / "model"
|
|
68
|
+
if candidate.is_dir():
|
|
69
|
+
return candidate
|
|
70
|
+
# Return primary path so load() surfaces a clear error.
|
|
71
|
+
return bundled
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_MODEL_BUNDLE_DIR: Path = _find_model_bundle_dir()
|
|
75
|
+
|
|
76
|
+
# Canonical relation-type IDs used for gate-dependency categorisation.
|
|
77
|
+
_REQUIRES_INPUT = "lg:reltypes/requires_input"
|
|
78
|
+
_REQUIRES_EVIDENCE = "lg:reltypes/requires_evidence"
|
|
79
|
+
_REQUIRES_STATUS = "lg:reltypes/requires_status"
|
|
80
|
+
_GATE_PREFIX = "lg:gates/"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Grammar
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Grammar:
|
|
89
|
+
"""Read-only projection of the released Lantern Grammar model.
|
|
90
|
+
|
|
91
|
+
Do not instantiate directly. Use one of the class-method constructors:
|
|
92
|
+
|
|
93
|
+
* ``Grammar.load()`` — load the model bundled with the distribution.
|
|
94
|
+
* ``Grammar.from_directory(path)`` — load from an explicit directory.
|
|
95
|
+
|
|
96
|
+
All public methods are read-only and thread-safe.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
# --- Internal construction (not public API) ---
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
manifest_data: dict,
|
|
104
|
+
entities: dict,
|
|
105
|
+
relations: dict,
|
|
106
|
+
terms: dict,
|
|
107
|
+
package_version: str,
|
|
108
|
+
) -> None:
|
|
109
|
+
# All collections are built once and never mutated after this point,
|
|
110
|
+
# giving the thread-safety guarantee in the contract.
|
|
111
|
+
self._manifest: MappingProxyType = MappingProxyType(dict(manifest_data))
|
|
112
|
+
self._entities: dict[str, MappingProxyType] = {k: MappingProxyType(v) for k, v in entities.items()}
|
|
113
|
+
self._relations: dict[str, MappingProxyType] = {k: MappingProxyType(v) for k, v in relations.items()}
|
|
114
|
+
self._terms: dict[str, MappingProxyType] = {k: MappingProxyType(v) for k, v in terms.items()}
|
|
115
|
+
self._package_version = package_version
|
|
116
|
+
|
|
117
|
+
# --- Stable construction contract ---
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def load(cls) -> "Grammar":
|
|
121
|
+
"""Load the Lantern Grammar model bundled with the installed distribution.
|
|
122
|
+
|
|
123
|
+
Returns a ``Grammar`` instance ready for read-only queries.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
LanternGrammarLoadError: if packaged model data is absent,
|
|
127
|
+
unloadable, or structurally invalid.
|
|
128
|
+
"""
|
|
129
|
+
if not _MODEL_BUNDLE_DIR.is_dir():
|
|
130
|
+
raise LanternGrammarLoadError(
|
|
131
|
+
f"Bundled model data not found at {_MODEL_BUNDLE_DIR}. "
|
|
132
|
+
"The lantern-grammar package may not have been installed correctly."
|
|
133
|
+
)
|
|
134
|
+
return cls._load_from_path(_MODEL_BUNDLE_DIR)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_directory(cls, path) -> "Grammar":
|
|
138
|
+
"""Load a Lantern Grammar model from an explicit filesystem directory.
|
|
139
|
+
|
|
140
|
+
This constructor is stable because Lantern and package tests need a
|
|
141
|
+
deterministic way to load the grammar from local checkouts and to
|
|
142
|
+
validate against malformed test fixtures.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
path: path-like object or str pointing to the grammar model root
|
|
146
|
+
(the directory that contains ``manifest.json`` and ``index.json``).
|
|
147
|
+
|
|
148
|
+
Returns a ``Grammar`` instance ready for read-only queries.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
FileNotFoundError: if *path* does not exist.
|
|
152
|
+
LanternGrammarLoadError: if *path* exists but does not contain a
|
|
153
|
+
valid Lantern Grammar model structure.
|
|
154
|
+
"""
|
|
155
|
+
p = Path(path)
|
|
156
|
+
if not p.exists():
|
|
157
|
+
raise FileNotFoundError(f"Grammar model directory not found: {path}")
|
|
158
|
+
if not p.is_dir():
|
|
159
|
+
raise LanternGrammarLoadError(f"Grammar model path is not a directory: {path}")
|
|
160
|
+
return cls._load_from_path(p)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def _load_from_path(cls, model_dir: Path) -> "Grammar":
|
|
164
|
+
"""Internal loader shared by both public constructors."""
|
|
165
|
+
manifest_data = _load_json_file(model_dir / "manifest.json", "manifest.json")
|
|
166
|
+
_require_fields(manifest_data, ("model_id", "model_version"), "manifest.json")
|
|
167
|
+
|
|
168
|
+
index_data = _load_json_file(model_dir / "index.json", "index.json")
|
|
169
|
+
if "entries" not in index_data:
|
|
170
|
+
raise LanternGrammarLoadError("index.json missing required 'entries' field")
|
|
171
|
+
|
|
172
|
+
entities: dict = {}
|
|
173
|
+
relations: dict = {}
|
|
174
|
+
terms: dict = {}
|
|
175
|
+
|
|
176
|
+
for entry in index_data["entries"]:
|
|
177
|
+
entry_id: Optional[str] = entry.get("id")
|
|
178
|
+
entry_kind: Optional[str] = entry.get("kind")
|
|
179
|
+
locator: Optional[str] = entry.get("locator")
|
|
180
|
+
|
|
181
|
+
if not (entry_id and entry_kind and locator):
|
|
182
|
+
raise LanternGrammarLoadError(f"Index entry missing required fields (id/kind/locator): {entry}")
|
|
183
|
+
|
|
184
|
+
# Locators are of the form "model/objects/<Kind>/<file>.json",
|
|
185
|
+
# expressed relative to the repository root with "model/" as the
|
|
186
|
+
# base segment. We rebase them onto model_dir so they work both
|
|
187
|
+
# in the source tree (where _model/ is a symlink to model/) and in
|
|
188
|
+
# an installed distribution (where _model/ is a real copy).
|
|
189
|
+
relative = locator.removeprefix("model/")
|
|
190
|
+
obj_path = model_dir / relative
|
|
191
|
+
obj_data = _load_json_file(obj_path, f"object for {entry_id!r}")
|
|
192
|
+
|
|
193
|
+
if entry_kind == "Entity":
|
|
194
|
+
entities[entry_id] = obj_data
|
|
195
|
+
elif entry_kind == "Relation":
|
|
196
|
+
relations[entry_id] = obj_data
|
|
197
|
+
elif entry_kind == "Term":
|
|
198
|
+
terms[entry_id] = obj_data
|
|
199
|
+
# Unknown future kinds are silently skipped; this preserves
|
|
200
|
+
# forward-compatibility as the grammar evolves.
|
|
201
|
+
|
|
202
|
+
pkg_version = _get_package_version()
|
|
203
|
+
return cls(manifest_data, entities, relations, terms, pkg_version)
|
|
204
|
+
|
|
205
|
+
# --- Manifest and version metadata ---
|
|
206
|
+
|
|
207
|
+
def manifest(self) -> MappingProxyType:
|
|
208
|
+
"""Return the model manifest as a read-only mapping.
|
|
209
|
+
|
|
210
|
+
Stable minimum fields: ``model_id``, ``model_version``.
|
|
211
|
+
All other manifest fields are surfaced verbatim.
|
|
212
|
+
"""
|
|
213
|
+
return self._manifest
|
|
214
|
+
|
|
215
|
+
def package_version(self) -> str:
|
|
216
|
+
"""Return the installed Python distribution version string.
|
|
217
|
+
|
|
218
|
+
Distinct from ``manifest()["model_version"]``, which is the semantic
|
|
219
|
+
grammar version. Returns ``"unknown"`` when metadata is unavailable.
|
|
220
|
+
"""
|
|
221
|
+
return self._package_version
|
|
222
|
+
|
|
223
|
+
# --- Entity access ---
|
|
224
|
+
|
|
225
|
+
def get_entity(self, entity_id: str) -> Optional[MappingProxyType]:
|
|
226
|
+
"""Return the entity with *entity_id*, or ``None`` if not found.
|
|
227
|
+
|
|
228
|
+
Covers all entity kinds: artifacts, gates, statuses, relation types,
|
|
229
|
+
and record classes. Use the ``prefix`` filter on ``iter_entities()``
|
|
230
|
+
to iterate a specific family.
|
|
231
|
+
|
|
232
|
+
Stable minimum fields: ``id``, ``kind``, ``short_name``,
|
|
233
|
+
``definition``, ``status``.
|
|
234
|
+
"""
|
|
235
|
+
return self._entities.get(entity_id)
|
|
236
|
+
|
|
237
|
+
def iter_entities(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
prefix: Optional[str] = None,
|
|
241
|
+
status: Optional[str] = None,
|
|
242
|
+
) -> Iterator[MappingProxyType]:
|
|
243
|
+
"""Iterate over entity mappings in the loaded model.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
prefix: if given, yield only entities whose ``id`` starts with
|
|
247
|
+
this string (e.g. ``"lg:gates/"``).
|
|
248
|
+
status: if given, yield only entities with this ``status`` value
|
|
249
|
+
(e.g. ``"Released"``).
|
|
250
|
+
"""
|
|
251
|
+
for entity in self._entities.values():
|
|
252
|
+
if prefix is not None and not entity["id"].startswith(prefix):
|
|
253
|
+
continue
|
|
254
|
+
if status is not None and entity.get("status") != status:
|
|
255
|
+
continue
|
|
256
|
+
yield entity
|
|
257
|
+
|
|
258
|
+
# --- Relation access ---
|
|
259
|
+
|
|
260
|
+
def get_relation(self, relation_id: str) -> Optional[MappingProxyType]:
|
|
261
|
+
"""Return the relation with *relation_id*, or ``None`` if not found.
|
|
262
|
+
|
|
263
|
+
Stable minimum fields: ``id``, ``kind``, ``short_name``,
|
|
264
|
+
``definition``, ``status``, ``relation_type_id``,
|
|
265
|
+
``source_entity_id``, ``target_entity_id``.
|
|
266
|
+
"""
|
|
267
|
+
return self._relations.get(relation_id)
|
|
268
|
+
|
|
269
|
+
def find_relations(
|
|
270
|
+
self,
|
|
271
|
+
*,
|
|
272
|
+
relation_type_id: Optional[str] = None,
|
|
273
|
+
source_entity_id: Optional[str] = None,
|
|
274
|
+
target_entity_id: Optional[str] = None,
|
|
275
|
+
) -> Iterator[MappingProxyType]:
|
|
276
|
+
"""Iterate over relations matching one or more filter criteria.
|
|
277
|
+
|
|
278
|
+
At least one filter argument must be supplied.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
relation_type_id: match on ``relation_type_id`` field.
|
|
282
|
+
source_entity_id: match on ``source_entity_id`` field.
|
|
283
|
+
target_entity_id: match on ``target_entity_id`` field.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ValueError: if no filter arguments are supplied.
|
|
287
|
+
"""
|
|
288
|
+
if not any([relation_type_id, source_entity_id, target_entity_id]):
|
|
289
|
+
raise ValueError(
|
|
290
|
+
"find_relations() requires at least one filter argument: "
|
|
291
|
+
"relation_type_id, source_entity_id, or target_entity_id"
|
|
292
|
+
)
|
|
293
|
+
for rel in self._relations.values():
|
|
294
|
+
if relation_type_id is not None and rel.get("relation_type_id") != relation_type_id:
|
|
295
|
+
continue
|
|
296
|
+
if source_entity_id is not None and rel.get("source_entity_id") != source_entity_id:
|
|
297
|
+
continue
|
|
298
|
+
if target_entity_id is not None and rel.get("target_entity_id") != target_entity_id:
|
|
299
|
+
continue
|
|
300
|
+
yield rel
|
|
301
|
+
|
|
302
|
+
# --- Term lookup ---
|
|
303
|
+
|
|
304
|
+
def get_term(self, term_id: str) -> Optional[MappingProxyType]:
|
|
305
|
+
"""Return the vocabulary term with *term_id*, or ``None`` if not found.
|
|
306
|
+
|
|
307
|
+
Stable minimum fields: ``id``, ``kind``, ``short_name``,
|
|
308
|
+
``definition``, ``status``.
|
|
309
|
+
"""
|
|
310
|
+
return self._terms.get(term_id)
|
|
311
|
+
|
|
312
|
+
def find_terms(
|
|
313
|
+
self,
|
|
314
|
+
*,
|
|
315
|
+
prefix: Optional[str] = None,
|
|
316
|
+
short_name: Optional[str] = None,
|
|
317
|
+
) -> Iterator[MappingProxyType]:
|
|
318
|
+
"""Iterate over vocabulary term mappings.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
prefix: if given, yield only terms whose ``id`` starts with this
|
|
322
|
+
string.
|
|
323
|
+
short_name: if given, yield only terms with this exact
|
|
324
|
+
``short_name``.
|
|
325
|
+
"""
|
|
326
|
+
for term in self._terms.values():
|
|
327
|
+
if prefix is not None and not term["id"].startswith(prefix):
|
|
328
|
+
continue
|
|
329
|
+
if short_name is not None and term.get("short_name") != short_name:
|
|
330
|
+
continue
|
|
331
|
+
yield term
|
|
332
|
+
|
|
333
|
+
# --- Gate-dependency queries ---
|
|
334
|
+
|
|
335
|
+
def gate_dependencies(self, gate_id: str) -> MappingProxyType:
|
|
336
|
+
"""Return the semantic dependencies for *gate_id*.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
gate_id: canonical gate entity ID, e.g. ``"lg:gates/gt_115"``.
|
|
340
|
+
|
|
341
|
+
Returns a read-only mapping with stable keys:
|
|
342
|
+
|
|
343
|
+
* ``gate_id`` — the requested gate ID.
|
|
344
|
+
* ``requires_input`` — tuple of target entity IDs via
|
|
345
|
+
``requires_input`` relations.
|
|
346
|
+
* ``requires_evidence`` — tuple of target entity IDs via
|
|
347
|
+
``requires_evidence`` relations.
|
|
348
|
+
* ``requires_status`` — tuple of target entity IDs via
|
|
349
|
+
``requires_status`` relations.
|
|
350
|
+
* ``relation_ids`` — tuple of all supporting relation IDs.
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
KeyError: if *gate_id* does not exist or is not a gate entity.
|
|
354
|
+
"""
|
|
355
|
+
entity = self._entities.get(gate_id)
|
|
356
|
+
if entity is None or not gate_id.startswith(_GATE_PREFIX):
|
|
357
|
+
raise KeyError(f"Gate not found in model: {gate_id!r}. " "Gate IDs must start with 'lg:gates/'.")
|
|
358
|
+
|
|
359
|
+
requires_input: list[str] = []
|
|
360
|
+
requires_evidence: list[str] = []
|
|
361
|
+
requires_status: list[str] = []
|
|
362
|
+
relation_ids: list[str] = []
|
|
363
|
+
|
|
364
|
+
for rel_id in entity.get("relation_ids", []):
|
|
365
|
+
rel = self._relations.get(rel_id)
|
|
366
|
+
if rel is None:
|
|
367
|
+
continue
|
|
368
|
+
rtype = rel.get("relation_type_id")
|
|
369
|
+
target = rel.get("target_entity_id", "")
|
|
370
|
+
relation_ids.append(rel_id)
|
|
371
|
+
if rtype == _REQUIRES_INPUT:
|
|
372
|
+
requires_input.append(target)
|
|
373
|
+
elif rtype == _REQUIRES_EVIDENCE:
|
|
374
|
+
requires_evidence.append(target)
|
|
375
|
+
elif rtype == _REQUIRES_STATUS:
|
|
376
|
+
requires_status.append(target)
|
|
377
|
+
|
|
378
|
+
return MappingProxyType(
|
|
379
|
+
{
|
|
380
|
+
"gate_id": gate_id,
|
|
381
|
+
"requires_input": tuple(requires_input),
|
|
382
|
+
"requires_evidence": tuple(requires_evidence),
|
|
383
|
+
"requires_status": tuple(requires_status),
|
|
384
|
+
"relation_ids": tuple(relation_ids),
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# --- Integrity validation ---
|
|
389
|
+
|
|
390
|
+
def validate_integrity(self) -> MappingProxyType:
|
|
391
|
+
"""Validate the structural integrity of the loaded model.
|
|
392
|
+
|
|
393
|
+
Returns a read-only mapping with stable keys:
|
|
394
|
+
|
|
395
|
+
* ``ok`` — ``True`` when no errors were found.
|
|
396
|
+
* ``errors`` — tuple of human-readable error messages.
|
|
397
|
+
* ``warnings`` — tuple of non-fatal notices.
|
|
398
|
+
|
|
399
|
+
Coverage:
|
|
400
|
+
|
|
401
|
+
* manifest has required fields.
|
|
402
|
+
* Each relation's ``source_entity_id``, ``target_entity_id``, and
|
|
403
|
+
``relation_type_id`` exist in the entity index.
|
|
404
|
+
* Each gate entity's ``relation_ids`` list references existing
|
|
405
|
+
relations.
|
|
406
|
+
"""
|
|
407
|
+
errors: list[str] = []
|
|
408
|
+
warnings: list[str] = []
|
|
409
|
+
|
|
410
|
+
# --- Manifest ---
|
|
411
|
+
for field in ("model_id", "model_version"):
|
|
412
|
+
if field not in self._manifest:
|
|
413
|
+
errors.append(f"manifest missing required field: {field!r}")
|
|
414
|
+
|
|
415
|
+
# --- Relation cross-references ---
|
|
416
|
+
for rel_id, rel in self._relations.items():
|
|
417
|
+
src = rel.get("source_entity_id")
|
|
418
|
+
tgt = rel.get("target_entity_id")
|
|
419
|
+
rtype = rel.get("relation_type_id")
|
|
420
|
+
if src and src not in self._entities:
|
|
421
|
+
errors.append(f"Relation {rel_id!r}: source_entity_id {src!r} " "not found in entity index")
|
|
422
|
+
if tgt and tgt not in self._entities:
|
|
423
|
+
errors.append(f"Relation {rel_id!r}: target_entity_id {tgt!r} " "not found in entity index")
|
|
424
|
+
if rtype and rtype not in self._entities:
|
|
425
|
+
errors.append(f"Relation {rel_id!r}: relation_type_id {rtype!r} " "not found in entity index")
|
|
426
|
+
|
|
427
|
+
# --- Gate relation_ids cross-references ---
|
|
428
|
+
for entity_id, entity in self._entities.items():
|
|
429
|
+
if entity_id.startswith(_GATE_PREFIX):
|
|
430
|
+
for rel_id in entity.get("relation_ids", []):
|
|
431
|
+
if rel_id not in self._relations:
|
|
432
|
+
errors.append(f"Gate {entity_id!r}: relation_id {rel_id!r} " "not found in relation index")
|
|
433
|
+
|
|
434
|
+
return MappingProxyType(
|
|
435
|
+
{
|
|
436
|
+
"ok": len(errors) == 0,
|
|
437
|
+
"errors": tuple(errors),
|
|
438
|
+
"warnings": tuple(warnings),
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def __repr__(self) -> str:
|
|
443
|
+
model_id = self._manifest.get("model_id", "?")
|
|
444
|
+
version = self._manifest.get("model_version", "?")
|
|
445
|
+
return (
|
|
446
|
+
f"Grammar(model_id={model_id!r}, model_version={version!r}, "
|
|
447
|
+
f"entities={len(self._entities)}, relations={len(self._relations)}, "
|
|
448
|
+
f"terms={len(self._terms)})"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# Private helpers
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _load_json_file(path: Path, label: str) -> dict:
|
|
458
|
+
"""Load a JSON file, raising LanternGrammarLoadError on failure."""
|
|
459
|
+
if not path.exists():
|
|
460
|
+
raise LanternGrammarLoadError(f"{label} not found at {path}")
|
|
461
|
+
try:
|
|
462
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
463
|
+
except json.JSONDecodeError as exc:
|
|
464
|
+
raise LanternGrammarLoadError(f"Failed to parse {label}: {exc}") from exc
|
|
465
|
+
except OSError as exc:
|
|
466
|
+
raise LanternGrammarLoadError(f"Failed to read {label}: {exc}") from exc
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _require_fields(data: dict, fields: tuple, source: str) -> None:
|
|
470
|
+
"""Raise LanternGrammarLoadError if any of *fields* is absent from *data*."""
|
|
471
|
+
for field in fields:
|
|
472
|
+
if field not in data:
|
|
473
|
+
raise LanternGrammarLoadError(f"{source} missing required field: {field!r}")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _get_package_version() -> str:
|
|
477
|
+
"""Return installed package version, or 'unknown' if unavailable."""
|
|
478
|
+
try:
|
|
479
|
+
from importlib.metadata import version
|
|
480
|
+
|
|
481
|
+
return version("lantern-grammar")
|
|
482
|
+
except Exception:
|
|
483
|
+
return "unknown"
|