gramson 0.1.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.
- gramson/__init__.py +1 -0
- gramson/__main__.py +3 -0
- gramson/cli.py +195 -0
- gramson/generation.py +366 -0
- gramson/schemas/__init__.py +1 -0
- gramson/schemas/core/arithmetic.schema.yaml +121 -0
- gramson/schemas/core/collection.schema.yaml +146 -0
- gramson/schemas/core/comparison.schema.yaml +131 -0
- gramson/schemas/core/data.schema.yaml +65 -0
- gramson/schemas/core/debugging.schema.yaml +24 -0
- gramson/schemas/core/logic.schema.yaml +129 -0
- gramson/schemas/core/string.schema.yaml +38 -0
- gramson/schemas/expression.schema.yaml +48 -0
- gramson/schemas/shared.schema.yaml +9 -0
- gramson/specs/__init__.py +1 -0
- gramson/specs/core/arithmetic.yaml +52 -0
- gramson/specs/core/collection.yaml +70 -0
- gramson/specs/core/comparison.yaml +64 -0
- gramson/specs/core/data.yaml +29 -0
- gramson/specs/core/debugging.yaml +10 -0
- gramson/specs/core/logic.yaml +58 -0
- gramson/specs/core/string.yaml +16 -0
- gramson/specs/extensions/bitwise.yaml +67 -0
- gramson/specs/language.yaml +2 -0
- gramson-0.1.0.dist-info/METADATA +180 -0
- gramson-0.1.0.dist-info/RECORD +32 -0
- gramson-0.1.0.dist-info/WHEEL +5 -0
- gramson-0.1.0.dist-info/entry_points.txt +2 -0
- gramson-0.1.0.dist-info/licenses/LICENSE +21 -0
- gramson-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +15 -0
- gramson-0.1.0.dist-info/licenses/tests/vendor/jsonlogic/LICENSE +22 -0
- gramson-0.1.0.dist-info/top_level.txt +1 -0
gramson/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Gramson: declaratively defined JSON expression languages."""
|
gramson/__main__.py
ADDED
gramson/cli.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import sys
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
from urllib.request import url2pathname
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from jsonschema import Draft202012Validator
|
|
13
|
+
from jsonschema.exceptions import SchemaError, best_match
|
|
14
|
+
from referencing import Registry, Resource
|
|
15
|
+
from referencing.exceptions import NoSuchResource, Unresolvable
|
|
16
|
+
from referencing.jsonschema import DRAFT202012
|
|
17
|
+
|
|
18
|
+
from .generation import (
|
|
19
|
+
generate_schemas,
|
|
20
|
+
load_core,
|
|
21
|
+
load_language,
|
|
22
|
+
read_yaml,
|
|
23
|
+
write_schemas,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SCHEMA_BASE = "https://gramson.invalid/schema/"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unique_object(pairs):
|
|
30
|
+
result = {}
|
|
31
|
+
for key, value in pairs:
|
|
32
|
+
if not isinstance(key, str):
|
|
33
|
+
raise ValueError("Object keys must be strings.")
|
|
34
|
+
if key in result:
|
|
35
|
+
raise ValueError(f"Duplicate object key: {key!r}.")
|
|
36
|
+
result[key] = value
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExpressionLoader(yaml.SafeLoader):
|
|
41
|
+
def construct_mapping(self, node, deep=False):
|
|
42
|
+
self.flatten_mapping(node)
|
|
43
|
+
return unique_object(self.construct_pairs(node, deep=deep))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_json_value(value, ancestors=None):
|
|
47
|
+
"""Reject YAML values that cannot be represented as finite JSON data."""
|
|
48
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
49
|
+
return
|
|
50
|
+
if isinstance(value, float):
|
|
51
|
+
if not math.isfinite(value):
|
|
52
|
+
raise ValueError("Numbers must be finite JSON numbers.")
|
|
53
|
+
return
|
|
54
|
+
if not isinstance(value, (list, dict)):
|
|
55
|
+
raise ValueError(f"Unsupported JSON value type: {type(value).__name__}.")
|
|
56
|
+
ancestors = set() if ancestors is None else ancestors
|
|
57
|
+
if id(value) in ancestors:
|
|
58
|
+
raise ValueError("Cyclic YAML aliases cannot represent JSON values.")
|
|
59
|
+
ancestors.add(id(value))
|
|
60
|
+
if isinstance(value, dict):
|
|
61
|
+
if any(not isinstance(key, str) for key in value):
|
|
62
|
+
raise ValueError("Object keys must be strings.")
|
|
63
|
+
children = value.values()
|
|
64
|
+
else:
|
|
65
|
+
children = value
|
|
66
|
+
for child in children:
|
|
67
|
+
check_json_value(child, ancestors)
|
|
68
|
+
ancestors.remove(id(value))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def expression_validator(schema_path=None):
|
|
72
|
+
"""Load bundled or generated local schemas with no remote retrieval."""
|
|
73
|
+
schemas = {}
|
|
74
|
+
|
|
75
|
+
def load_schema(source):
|
|
76
|
+
if source.name.endswith(".json"):
|
|
77
|
+
schema = json.loads(source.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
|
|
78
|
+
else:
|
|
79
|
+
schema = read_yaml(source)
|
|
80
|
+
Draft202012Validator.check_schema(schema)
|
|
81
|
+
return schema
|
|
82
|
+
|
|
83
|
+
if schema_path is not None:
|
|
84
|
+
schema_path = Path(schema_path).resolve()
|
|
85
|
+
root = schema_path.parent
|
|
86
|
+
|
|
87
|
+
@lru_cache(maxsize=None)
|
|
88
|
+
def retrieve_local(uri):
|
|
89
|
+
parsed = urlsplit(uri)
|
|
90
|
+
if parsed.scheme != "file" or parsed.netloc:
|
|
91
|
+
raise NoSuchResource(ref=uri)
|
|
92
|
+
source = Path(url2pathname(parsed.path)).resolve()
|
|
93
|
+
if not source.is_relative_to(root):
|
|
94
|
+
raise NoSuchResource(ref=uri)
|
|
95
|
+
return Resource.from_contents(load_schema(source), default_specification=DRAFT202012)
|
|
96
|
+
|
|
97
|
+
# Resolve only referenced files, without scanning unrelated directories
|
|
98
|
+
# or falling back to package resources or network retrieval.
|
|
99
|
+
registry = Registry(retrieve=retrieve_local).with_resource(
|
|
100
|
+
schema_path.as_uri(),
|
|
101
|
+
Resource.from_contents(load_schema(schema_path), default_specification=DRAFT202012),
|
|
102
|
+
)
|
|
103
|
+
return Draft202012Validator({"$ref": schema_path.as_uri()}, registry=registry)
|
|
104
|
+
|
|
105
|
+
def read_directory(directory, prefix=""):
|
|
106
|
+
for item in directory.iterdir():
|
|
107
|
+
relative = prefix + item.name
|
|
108
|
+
if item.is_dir():
|
|
109
|
+
read_directory(item, relative + "/")
|
|
110
|
+
elif item.name.endswith((".schema.yaml", ".schema.yml", ".schema.json")):
|
|
111
|
+
schemas[relative] = load_schema(item)
|
|
112
|
+
|
|
113
|
+
read_directory(files("gramson.schemas"))
|
|
114
|
+
registry = Registry().with_resources(
|
|
115
|
+
(SCHEMA_BASE + quote(name, safe="/"), Resource.from_contents(schema, default_specification=DRAFT202012))
|
|
116
|
+
for name, schema in schemas.items()
|
|
117
|
+
)
|
|
118
|
+
return Draft202012Validator({
|
|
119
|
+
"$ref": SCHEMA_BASE + "expression.schema.yaml",
|
|
120
|
+
}, registry=registry)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def error_detail(error):
|
|
124
|
+
"""Select the matching operator's failure from recursive oneOf branches."""
|
|
125
|
+
while error.context:
|
|
126
|
+
candidates = list(error.context)
|
|
127
|
+
if isinstance(error.instance, dict):
|
|
128
|
+
if len(error.instance) != 1:
|
|
129
|
+
return error.json_path, "Expected exactly one registered operator key."
|
|
130
|
+
operator = next(iter(error.instance))
|
|
131
|
+
matching = [child for child in candidates if child.path and child.path[0] == operator]
|
|
132
|
+
if not matching:
|
|
133
|
+
return error.json_path, f"Unknown operator {operator!r}."
|
|
134
|
+
candidates = matching
|
|
135
|
+
elif isinstance(error.instance, list):
|
|
136
|
+
relevant = [child for child in candidates if child.validator not in ("type", "not")]
|
|
137
|
+
if relevant:
|
|
138
|
+
candidates = relevant
|
|
139
|
+
error = best_match(candidates)
|
|
140
|
+
return error.json_path, error.message
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv=None):
|
|
144
|
+
parser = argparse.ArgumentParser(prog="gramson", description="Generate grammars and validate Gramson expression syntax offline.")
|
|
145
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
146
|
+
generate = commands.add_parser("generate", help="Generate a self-contained expression grammar.")
|
|
147
|
+
generate.add_argument("--manifest", type=Path, help="Language manifest; defaults to the bundled core language.")
|
|
148
|
+
generate.add_argument("--spec-path", type=Path, action="append", default=[], help="Search root for external namespace:group includes (repeatable).")
|
|
149
|
+
generate.add_argument("--output-directory", type=Path, default=Path("schema"), help="Output directory (default: schema).")
|
|
150
|
+
validate = commands.add_parser("validate", help="Validate one JSON or YAML expression.")
|
|
151
|
+
validate.add_argument("expression", help="Expression file, or - to read stdin.")
|
|
152
|
+
validate.add_argument("--format", choices=("json", "yaml"), help="Override file format; stdin defaults to JSON.")
|
|
153
|
+
validate.add_argument("--schema", type=Path, help="Generated schema entry point; defaults to the bundled grammar.")
|
|
154
|
+
options = parser.parse_args(argv)
|
|
155
|
+
if options.command == "generate":
|
|
156
|
+
try:
|
|
157
|
+
if options.spec_path and options.manifest is None:
|
|
158
|
+
raise ValueError("--spec-path requires --manifest.")
|
|
159
|
+
groups = load_core() if options.manifest is None else load_language(options.manifest, spec_paths=options.spec_path)
|
|
160
|
+
schemas = generate_schemas(groups)
|
|
161
|
+
write_schemas(schemas, options.output_directory)
|
|
162
|
+
except (OSError, UnicodeError, ValueError, yaml.YAMLError, RecursionError) as error:
|
|
163
|
+
print(f"gramson generate: {error}", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
print(f"Generated {len(schemas)} schemas in {options.output_directory}")
|
|
166
|
+
return 0
|
|
167
|
+
label = "stdin" if options.expression == "-" else options.expression
|
|
168
|
+
input_format = options.format or (
|
|
169
|
+
"yaml" if Path(options.expression).suffix.lower() in (".yaml", ".yml") else "json"
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
source = sys.stdin.read() if options.expression == "-" else Path(options.expression).read_text(encoding="utf-8")
|
|
173
|
+
if not source.strip():
|
|
174
|
+
raise ValueError("Expected one expression; input is empty.")
|
|
175
|
+
expression = (
|
|
176
|
+
yaml.load(source, Loader=ExpressionLoader)
|
|
177
|
+
if input_format == "yaml"
|
|
178
|
+
else json.loads(source, object_pairs_hook=unique_object)
|
|
179
|
+
)
|
|
180
|
+
check_json_value(expression)
|
|
181
|
+
except (OSError, UnicodeError, ValueError, yaml.YAMLError, RecursionError) as error:
|
|
182
|
+
print(f"{label}: input error: {error}", file=sys.stderr)
|
|
183
|
+
return 2
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
error = next(expression_validator(options.schema).iter_errors(expression), None)
|
|
187
|
+
except (OSError, UnicodeError, ValueError, yaml.YAMLError, SchemaError, Unresolvable, RecursionError) as error:
|
|
188
|
+
print(f"{label}: schema error: {error}", file=sys.stderr)
|
|
189
|
+
return 2
|
|
190
|
+
if error is not None:
|
|
191
|
+
location, message = error_detail(error)
|
|
192
|
+
print(f"{label}: invalid expression at {location}: {message}", file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
print(f"{label}: valid expression")
|
|
195
|
+
return 0
|
gramson/generation.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import posixpath
|
|
2
|
+
import re
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
BUNDLED_INCLUDES = {
|
|
10
|
+
"gramson:core": Path("core"),
|
|
11
|
+
"gramson:bitwise": Path("extensions/bitwise.yaml"),
|
|
12
|
+
}
|
|
13
|
+
INCLUDE_NAME = re.compile(r"[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_schema(operators):
|
|
17
|
+
if not isinstance(operators, dict) or not operators:
|
|
18
|
+
raise ValueError("The registry must be a nonempty object.")
|
|
19
|
+
|
|
20
|
+
alternatives = [
|
|
21
|
+
{"$ref": "#/$defs/scalarLiteral"},
|
|
22
|
+
{"$ref": "#/$defs/arrayExpression"},
|
|
23
|
+
]
|
|
24
|
+
definitions = {
|
|
25
|
+
"expression": {"oneOf": alternatives},
|
|
26
|
+
"scalarLiteral": {"type": ["null", "boolean", "number", "string"]},
|
|
27
|
+
"arrayExpression": {
|
|
28
|
+
"type": "array", "items": {"$ref": "#/$defs/expression"}
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
for name, definition in operators.items():
|
|
32
|
+
if not isinstance(name, str) or not name:
|
|
33
|
+
raise ValueError("Operator names must be nonempty strings.")
|
|
34
|
+
if not isinstance(definition, dict):
|
|
35
|
+
raise ValueError(f"{name}: the definition must be an object.")
|
|
36
|
+
allowed = {
|
|
37
|
+
"argumentKind", "minArgs", "maxArgs", "shorthand", "schemaName",
|
|
38
|
+
"title", "summary", "description",
|
|
39
|
+
}
|
|
40
|
+
if definition.keys() - allowed:
|
|
41
|
+
raise ValueError(f"{name}: unsupported definition fields.")
|
|
42
|
+
for field in ("title", "summary", "description"):
|
|
43
|
+
if field in definition and (
|
|
44
|
+
not isinstance(definition[field], str) or not definition[field].strip()
|
|
45
|
+
):
|
|
46
|
+
raise ValueError(f"{name}: {field} must be a nonempty string.")
|
|
47
|
+
|
|
48
|
+
schema_name = definition.get("schemaName", f"operator_{name}")
|
|
49
|
+
if not isinstance(schema_name, str) or not schema_name:
|
|
50
|
+
raise ValueError(f"{name}: schemaName must be a nonempty string.")
|
|
51
|
+
if schema_name in definitions:
|
|
52
|
+
raise ValueError(f"{name}: duplicate or reserved schemaName {schema_name!r}.")
|
|
53
|
+
|
|
54
|
+
kind = definition.get("argumentKind")
|
|
55
|
+
minimum = definition.get("minArgs")
|
|
56
|
+
maximum = definition.get("maxArgs")
|
|
57
|
+
shorthand = definition.get("shorthand", False)
|
|
58
|
+
if kind not in ("string", "expression"):
|
|
59
|
+
raise ValueError(f"{name}: argumentKind must be string or expression.")
|
|
60
|
+
if type(minimum) is not int or minimum < 0:
|
|
61
|
+
raise ValueError(f"{name}: minArgs must be a nonnegative integer.")
|
|
62
|
+
if "maxArgs" in definition and (
|
|
63
|
+
type(maximum) is not int or maximum < minimum
|
|
64
|
+
):
|
|
65
|
+
raise ValueError(f"{name}: maxArgs must be an integer >= minArgs.")
|
|
66
|
+
if type(shorthand) is not bool:
|
|
67
|
+
raise ValueError(f"{name}: shorthand must be a boolean.")
|
|
68
|
+
if shorthand and (minimum > 1 or maximum == 0):
|
|
69
|
+
raise ValueError(f"{name}: shorthand requires an allowed arity of one.")
|
|
70
|
+
|
|
71
|
+
argument = (
|
|
72
|
+
{"type": "string"}
|
|
73
|
+
if kind == "string"
|
|
74
|
+
else {"$ref": "#/$defs/expression"}
|
|
75
|
+
)
|
|
76
|
+
arguments = {"type": "array", "items": argument, "minItems": minimum}
|
|
77
|
+
if maximum is not None:
|
|
78
|
+
arguments["maxItems"] = maximum
|
|
79
|
+
unwrapped = {**argument, "not": {"type": "array"}}
|
|
80
|
+
value = {"oneOf": [unwrapped, arguments]} if shorthand else arguments
|
|
81
|
+
definitions[schema_name] = {
|
|
82
|
+
"title": definition.get("title", f"{name} expression"),
|
|
83
|
+
"type": "object",
|
|
84
|
+
"required": [name],
|
|
85
|
+
"additionalProperties": False,
|
|
86
|
+
"properties": {name: value},
|
|
87
|
+
}
|
|
88
|
+
documentation = [
|
|
89
|
+
definition[field] for field in ("summary", "description")
|
|
90
|
+
if field in definition
|
|
91
|
+
]
|
|
92
|
+
if documentation:
|
|
93
|
+
definitions[schema_name]["description"] = "\n\n".join(documentation)
|
|
94
|
+
pointer_token = schema_name.replace("~", "~0").replace("/", "~1")
|
|
95
|
+
alternatives.append({"$ref": f"#/$defs/{quote(pointer_token, safe='')}"})
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
99
|
+
"title": "Gramson expression grammar",
|
|
100
|
+
"description": "Generated structural grammar; no evaluation or semantic typing.",
|
|
101
|
+
"$ref": "#/$defs/expression",
|
|
102
|
+
"$defs": definitions,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class UniqueKeyLoader(yaml.SafeLoader):
|
|
106
|
+
def construct_mapping(self, node, deep=False):
|
|
107
|
+
self.flatten_mapping(node)
|
|
108
|
+
mapping = {}
|
|
109
|
+
for key_node, value_node in node.value:
|
|
110
|
+
key = self.construct_object(key_node, deep=deep)
|
|
111
|
+
if key in mapping:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{self.name}:{key_node.start_mark.line + 1}: duplicate YAML key {key!r}"
|
|
114
|
+
)
|
|
115
|
+
mapping[key] = self.construct_object(value_node, deep=deep)
|
|
116
|
+
return mapping
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_yaml(path):
|
|
120
|
+
with path.open(encoding="utf-8") as stream:
|
|
121
|
+
return yaml.load(stream, Loader=UniqueKeyLoader)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _load_bundled(name):
|
|
125
|
+
"""Load installed declarations with portable output paths."""
|
|
126
|
+
groups = {}
|
|
127
|
+
|
|
128
|
+
def read_directory(directory, relative):
|
|
129
|
+
for source in sorted(directory.iterdir(), key=lambda item: item.name):
|
|
130
|
+
name = relative / source.name
|
|
131
|
+
if source.is_dir():
|
|
132
|
+
read_directory(source, name)
|
|
133
|
+
elif source.name.endswith((".yaml", ".yml")):
|
|
134
|
+
groups[name] = read_yaml(source)
|
|
135
|
+
|
|
136
|
+
relative = BUNDLED_INCLUDES[name]
|
|
137
|
+
resource = files("gramson.specs").joinpath(*relative.parts)
|
|
138
|
+
if resource.is_dir():
|
|
139
|
+
read_directory(resource, relative)
|
|
140
|
+
else:
|
|
141
|
+
groups[relative] = read_yaml(resource)
|
|
142
|
+
groups = dict(sorted(groups.items()))
|
|
143
|
+
merge_operators(groups)
|
|
144
|
+
return groups
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_core():
|
|
148
|
+
"""Load the default bundled core language."""
|
|
149
|
+
return _load_bundled("gramson:core")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _load_external(name, spec_paths):
|
|
153
|
+
namespace, group_name = name.split(":")
|
|
154
|
+
matches = []
|
|
155
|
+
for root in spec_paths:
|
|
156
|
+
candidate = root / namespace / group_name
|
|
157
|
+
if candidate.is_dir():
|
|
158
|
+
resolved = candidate.resolve()
|
|
159
|
+
if not resolved.is_relative_to(root):
|
|
160
|
+
raise ValueError(f"Include {name!r} escapes its spec search path: {candidate}.")
|
|
161
|
+
matches.append(resolved)
|
|
162
|
+
if not matches:
|
|
163
|
+
searched = ", ".join(str(root / namespace / group_name) for root in spec_paths)
|
|
164
|
+
hint = f"searched {searched}" if searched else "provide --spec-path to search for external specifications"
|
|
165
|
+
raise ValueError(f"Unknown include {name!r}; {hint}.")
|
|
166
|
+
if len(matches) != 1:
|
|
167
|
+
raise ValueError(f"Ambiguous include {name!r}: {', '.join(str(path) for path in matches)}.")
|
|
168
|
+
directory = matches[0]
|
|
169
|
+
sources = sorted(
|
|
170
|
+
source for source in directory.rglob("*")
|
|
171
|
+
if source.is_file() and source.suffix in (".yaml", ".yml")
|
|
172
|
+
)
|
|
173
|
+
if not sources:
|
|
174
|
+
raise ValueError(f"Include {name!r}: no YAML operator files in {directory}.")
|
|
175
|
+
groups = {}
|
|
176
|
+
for source in sources:
|
|
177
|
+
if not source.resolve().is_relative_to(directory):
|
|
178
|
+
raise ValueError(f"{source}: operator files must stay inside include {name!r}.")
|
|
179
|
+
relative = Path(namespace) / group_name / source.relative_to(directory)
|
|
180
|
+
groups[relative] = read_yaml(source)
|
|
181
|
+
return groups
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def merge_operators(groups):
|
|
185
|
+
operators = {}
|
|
186
|
+
operator_sources = {}
|
|
187
|
+
definition_sources = {}
|
|
188
|
+
for source, definitions in groups.items():
|
|
189
|
+
try:
|
|
190
|
+
generate_schema(definitions)
|
|
191
|
+
except ValueError as error:
|
|
192
|
+
raise ValueError(f"{source}: {error}") from error
|
|
193
|
+
for name, definition in definitions.items():
|
|
194
|
+
if name in operator_sources:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Duplicate operator {name!r} in {operator_sources[name]} and {source}"
|
|
197
|
+
)
|
|
198
|
+
schema_name = definition.get("schemaName", f"operator_{name}")
|
|
199
|
+
if schema_name in definition_sources:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
f"Duplicate schemaName {schema_name!r} in "
|
|
202
|
+
f"{definition_sources[schema_name]} and {source}"
|
|
203
|
+
)
|
|
204
|
+
operators[name] = definition
|
|
205
|
+
operator_sources[name] = source
|
|
206
|
+
definition_sources[schema_name] = source
|
|
207
|
+
if not operators:
|
|
208
|
+
raise ValueError("The language must contain at least one operator group.")
|
|
209
|
+
return operators
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_language(manifest_path, spec_paths=()):
|
|
213
|
+
manifest_path = Path(manifest_path).resolve()
|
|
214
|
+
manifest = read_yaml(manifest_path)
|
|
215
|
+
if not isinstance(manifest, dict) or set(manifest) - {"operatorDirectories", "include"}:
|
|
216
|
+
raise ValueError(f"{manifest_path}: expected include and/or operatorDirectories.")
|
|
217
|
+
includes = manifest.get("include", [])
|
|
218
|
+
if not isinstance(includes, list):
|
|
219
|
+
raise ValueError(f"{manifest_path}: include must be a list of namespace:group names.")
|
|
220
|
+
roots = list(dict.fromkeys(Path(path).resolve() for path in spec_paths))
|
|
221
|
+
for root in roots:
|
|
222
|
+
if not root.is_dir():
|
|
223
|
+
raise ValueError(f"{manifest_path}: spec search path must be an existing directory: {root}.")
|
|
224
|
+
groups = {}
|
|
225
|
+
targets = {}
|
|
226
|
+
|
|
227
|
+
def add_groups(additions, origin):
|
|
228
|
+
for relative, definitions in additions.items():
|
|
229
|
+
target = relative.with_suffix(".schema.yaml").as_posix().casefold()
|
|
230
|
+
if target in targets:
|
|
231
|
+
raise ValueError(f"Output collision between {targets[target]} and {origin} {relative}.")
|
|
232
|
+
targets[target] = f"{origin} {relative}"
|
|
233
|
+
groups[relative] = definitions
|
|
234
|
+
|
|
235
|
+
selected = set()
|
|
236
|
+
for name in includes:
|
|
237
|
+
if not isinstance(name, str) or not INCLUDE_NAME.fullmatch(name):
|
|
238
|
+
raise ValueError(f"{manifest_path}: invalid include {name!r}; expected lowercase namespace:group identifiers.")
|
|
239
|
+
if name in selected:
|
|
240
|
+
raise ValueError(f"{manifest_path}: repeated include {name!r}.")
|
|
241
|
+
selected.add(name)
|
|
242
|
+
if name.startswith("gramson:"):
|
|
243
|
+
if name not in BUNDLED_INCLUDES:
|
|
244
|
+
raise ValueError(f"{manifest_path}: unknown bundled include {name!r}; gramson: is reserved for this package.")
|
|
245
|
+
add_groups(_load_bundled(name), "bundled")
|
|
246
|
+
else:
|
|
247
|
+
try:
|
|
248
|
+
additions = _load_external(name, roots)
|
|
249
|
+
except ValueError as error:
|
|
250
|
+
raise ValueError(f"{manifest_path}: {error}") from error
|
|
251
|
+
add_groups(additions, f"include {name!r}")
|
|
252
|
+
directories = manifest.get("operatorDirectories", [])
|
|
253
|
+
if not isinstance(directories, list) or (not directories and not groups):
|
|
254
|
+
raise ValueError(f"{manifest_path}: operatorDirectories must be a nonempty list unless include selects definitions.")
|
|
255
|
+
source_root = manifest_path.parent
|
|
256
|
+
sources = set()
|
|
257
|
+
seen_directories = set()
|
|
258
|
+
for directory in directories:
|
|
259
|
+
if not isinstance(directory, str) or not directory.strip():
|
|
260
|
+
raise ValueError(f"{manifest_path}: directory names must be nonempty strings.")
|
|
261
|
+
relative = Path(directory)
|
|
262
|
+
if relative.is_absolute() or ".." in relative.parts or relative == Path("."):
|
|
263
|
+
raise ValueError(f"{manifest_path}: directories must be relative subdirectories.")
|
|
264
|
+
path = (source_root / relative).resolve()
|
|
265
|
+
if not path.is_relative_to(source_root) or not path.is_dir():
|
|
266
|
+
raise ValueError(f"{manifest_path}: missing or out-of-tree directory {directory!r}.")
|
|
267
|
+
if path in seen_directories:
|
|
268
|
+
raise ValueError(f"{manifest_path}: repeated directory {directory!r}.")
|
|
269
|
+
seen_directories.add(path)
|
|
270
|
+
operator_files = sorted(
|
|
271
|
+
source for source in path.rglob("*")
|
|
272
|
+
if source.is_file() and source.suffix in (".yaml", ".yml")
|
|
273
|
+
)
|
|
274
|
+
if not operator_files:
|
|
275
|
+
raise ValueError(f"{manifest_path}: no YAML operator files in {directory!r}.")
|
|
276
|
+
for source in operator_files:
|
|
277
|
+
if not source.resolve().is_relative_to(source_root):
|
|
278
|
+
raise ValueError(f"{source}: operator files must stay inside the specification.")
|
|
279
|
+
sources.add(source)
|
|
280
|
+
for source in sorted(sources):
|
|
281
|
+
relative = source.relative_to(source_root)
|
|
282
|
+
add_groups({relative: read_yaml(source)}, "local")
|
|
283
|
+
groups = dict(sorted(groups.items()))
|
|
284
|
+
merge_operators(groups)
|
|
285
|
+
return groups
|
|
286
|
+
|
|
287
|
+
def definition_reference(name, document=""):
|
|
288
|
+
pointer_token = name.replace("~", "~0").replace("/", "~1")
|
|
289
|
+
return f"{document}#/$defs/{quote(pointer_token, safe='')}"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def generate_schemas(groups):
|
|
293
|
+
compiled = generate_schema(merge_operators(groups))
|
|
294
|
+
dialect = compiled["$schema"]
|
|
295
|
+
alternatives = [
|
|
296
|
+
{"$ref": "shared.schema.yaml#/$defs/scalarLiteral"},
|
|
297
|
+
{"$ref": "#/$defs/arrayExpression"},
|
|
298
|
+
]
|
|
299
|
+
schemas = {
|
|
300
|
+
Path("expression.schema.yaml"): {
|
|
301
|
+
"$schema": dialect,
|
|
302
|
+
"title": "Gramson expression grammar",
|
|
303
|
+
"description": compiled["description"],
|
|
304
|
+
"$ref": "#/$defs/expression",
|
|
305
|
+
"$defs": {
|
|
306
|
+
"expression": {"oneOf": alternatives},
|
|
307
|
+
"arrayExpression": compiled["$defs"]["arrayExpression"],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
Path("shared.schema.yaml"): {
|
|
311
|
+
"$schema": dialect,
|
|
312
|
+
"title": "Shared expression definitions",
|
|
313
|
+
"$defs": {"scalarLiteral": compiled["$defs"]["scalarLiteral"]},
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
def relocate(value, expression_reference):
|
|
318
|
+
if isinstance(value, list):
|
|
319
|
+
return [relocate(item, expression_reference) for item in value]
|
|
320
|
+
if isinstance(value, dict):
|
|
321
|
+
return {
|
|
322
|
+
key: (
|
|
323
|
+
expression_reference
|
|
324
|
+
if key == "$ref" and item == "#/$defs/expression"
|
|
325
|
+
else relocate(item, expression_reference)
|
|
326
|
+
)
|
|
327
|
+
for key, item in value.items()
|
|
328
|
+
}
|
|
329
|
+
return value
|
|
330
|
+
|
|
331
|
+
for source, operators in groups.items():
|
|
332
|
+
source = Path(source)
|
|
333
|
+
if source.is_absolute() or ".." in source.parts:
|
|
334
|
+
raise ValueError(f"{source}: expected a relative operator source path.")
|
|
335
|
+
target = source.with_suffix(".schema.yaml")
|
|
336
|
+
if target in schemas:
|
|
337
|
+
raise ValueError(f"{source}: generated schema path collision at {target}.")
|
|
338
|
+
expression_reference = quote(
|
|
339
|
+
posixpath.relpath("expression.schema.yaml", target.parent.as_posix()), safe="/"
|
|
340
|
+
) + "#/$defs/expression"
|
|
341
|
+
definitions = {}
|
|
342
|
+
for name, operator in operators.items():
|
|
343
|
+
schema_name = operator.get("schemaName", f"operator_{name}")
|
|
344
|
+
definitions[schema_name] = relocate(
|
|
345
|
+
compiled["$defs"][schema_name], expression_reference
|
|
346
|
+
)
|
|
347
|
+
alternatives.append({
|
|
348
|
+
"$ref": definition_reference(schema_name, quote(target.as_posix(), safe="/"))
|
|
349
|
+
})
|
|
350
|
+
schemas[target] = {
|
|
351
|
+
"$schema": dialect,
|
|
352
|
+
"title": f"{source.stem} operators",
|
|
353
|
+
"$defs": definitions,
|
|
354
|
+
}
|
|
355
|
+
return schemas
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def write_schemas(schemas, output_directory):
|
|
359
|
+
"""Write an already compiled grammar without modifying unrelated files."""
|
|
360
|
+
output_directory = Path(output_directory)
|
|
361
|
+
for relative, schema in schemas.items():
|
|
362
|
+
target = output_directory / relative
|
|
363
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
target.write_text(
|
|
365
|
+
yaml.safe_dump(schema, sort_keys=False, allow_unicode=True), encoding="utf-8"
|
|
366
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated grammars packaged as gramson.schemas for offline CLI validation."""
|