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 ADDED
@@ -0,0 +1 @@
1
+ """Gramson: declaratively defined JSON expression languages."""
gramson/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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."""