knobly 1.1.0__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 (96) hide show
  1. knobly-1.1.0/.gitignore +7 -0
  2. knobly-1.1.0/.python-version +1 -0
  3. knobly-1.1.0/PKG-INFO +8 -0
  4. knobly-1.1.0/README.md +169 -0
  5. knobly-1.1.0/conformance/adapter.py +483 -0
  6. knobly-1.1.0/examples/addresses.py +26 -0
  7. knobly-1.1.0/examples/custom_source.py +38 -0
  8. knobly-1.1.0/examples/diagnostics.py +15 -0
  9. knobly-1.1.0/examples/diff_reload.py +31 -0
  10. knobly-1.1.0/examples/edit_save.py +30 -0
  11. knobly-1.1.0/examples/edit_statements.py +35 -0
  12. knobly-1.1.0/examples/errors.py +26 -0
  13. knobly-1.1.0/examples/hello.py +12 -0
  14. knobly-1.1.0/examples/include_read.py +25 -0
  15. knobly-1.1.0/examples/load_render.py +23 -0
  16. knobly-1.1.0/examples/lost_update.py +33 -0
  17. knobly-1.1.0/examples/overrides.py +21 -0
  18. knobly-1.1.0/examples/quickstart.py +24 -0
  19. knobly-1.1.0/examples/read_values.py +24 -0
  20. knobly-1.1.0/examples/sink.py +25 -0
  21. knobly-1.1.0/examples/snippets.py +25 -0
  22. knobly-1.1.0/examples/syntax_tree.py +29 -0
  23. knobly-1.1.0/examples/variables.py +29 -0
  24. knobly-1.1.0/hatch_build.py +47 -0
  25. knobly-1.1.0/pyproject.toml +60 -0
  26. knobly-1.1.0/src/knobly/__init__.py +82 -0
  27. knobly-1.1.0/src/knobly/_anatomy.py +65 -0
  28. knobly-1.1.0/src/knobly/_api/.gitignore +2 -0
  29. knobly-1.1.0/src/knobly/_api/__init__.py +1 -0
  30. knobly-1.1.0/src/knobly/_api/codes.py +294 -0
  31. knobly-1.1.0/src/knobly/_api/docs.py +29 -0
  32. knobly-1.1.0/src/knobly/_api/errors.py +88 -0
  33. knobly-1.1.0/src/knobly/_api/io.py +134 -0
  34. knobly-1.1.0/src/knobly/_api/kinds.py +171 -0
  35. knobly-1.1.0/src/knobly/_api/limits.py +37 -0
  36. knobly-1.1.0/src/knobly/_api/record.py +17 -0
  37. knobly-1.1.0/src/knobly/_api/rendered.py +276 -0
  38. knobly-1.1.0/src/knobly/_api/shared.py +349 -0
  39. knobly-1.1.0/src/knobly/_api/syntax.py +117 -0
  40. knobly-1.1.0/src/knobly/_api/written.py +2189 -0
  41. knobly-1.1.0/src/knobly/_block.py +79 -0
  42. knobly-1.1.0/src/knobly/_diagnostics.py +46 -0
  43. knobly-1.1.0/src/knobly/_docs.py +70 -0
  44. knobly-1.1.0/src/knobly/_gen/Knobly.interp +75 -0
  45. knobly-1.1.0/src/knobly/_gen/Knobly.tokens +30 -0
  46. knobly-1.1.0/src/knobly/_gen/KnoblyLexer.interp +85 -0
  47. knobly-1.1.0/src/knobly/_gen/KnoblyLexer.py +165 -0
  48. knobly-1.1.0/src/knobly/_gen/KnoblyLexer.tokens +30 -0
  49. knobly-1.1.0/src/knobly/_gen/KnoblyParser.py +1531 -0
  50. knobly-1.1.0/src/knobly/_gen/__init__.py +0 -0
  51. knobly-1.1.0/src/knobly/_glob.py +18 -0
  52. knobly-1.1.0/src/knobly/_origin.py +59 -0
  53. knobly-1.1.0/src/knobly/_report.py +104 -0
  54. knobly-1.1.0/src/knobly/_source.py +88 -0
  55. knobly-1.1.0/src/knobly/_text.py +49 -0
  56. knobly-1.1.0/src/knobly/_values.py +87 -0
  57. knobly-1.1.0/src/knobly/files.py +98 -0
  58. knobly-1.1.0/src/knobly/include.py +42 -0
  59. knobly-1.1.0/src/knobly/py.typed +0 -0
  60. knobly-1.1.0/src/knobly/rendered/__init__.py +32 -0
  61. knobly-1.1.0/src/knobly/rendered/_render.py +138 -0
  62. knobly-1.1.0/src/knobly/rendered/_tree.py +145 -0
  63. knobly-1.1.0/src/knobly/syntax/__init__.py +27 -0
  64. knobly-1.1.0/src/knobly/syntax/_cst.py +42 -0
  65. knobly-1.1.0/src/knobly/syntax/_parse.py +137 -0
  66. knobly-1.1.0/src/knobly/syntax/_recovery.py +307 -0
  67. knobly-1.1.0/src/knobly/syntax/_walk.py +138 -0
  68. knobly-1.1.0/src/knobly/written/__init__.py +126 -0
  69. knobly-1.1.0/src/knobly/written/_bind.py +985 -0
  70. knobly-1.1.0/src/knobly/written/_document.py +996 -0
  71. knobly-1.1.0/src/knobly/written/_expand.py +68 -0
  72. knobly-1.1.0/src/knobly/written/_format.py +32 -0
  73. knobly-1.1.0/src/knobly/written/_graph.py +143 -0
  74. knobly-1.1.0/src/knobly/written/_model.py +971 -0
  75. knobly-1.1.0/src/knobly/written/_names.py +29 -0
  76. knobly-1.1.0/src/knobly/written/_processor.py +39 -0
  77. knobly-1.1.0/src/knobly/written/_read.py +86 -0
  78. knobly-1.1.0/src/knobly/written/_shape.py +122 -0
  79. knobly-1.1.0/src/knobly/written/_value.py +324 -0
  80. knobly-1.1.0/tests/conftest.py +11 -0
  81. knobly-1.1.0/tests/test_api_names.py +222 -0
  82. knobly-1.1.0/tests/test_build.py +102 -0
  83. knobly-1.1.0/tests/test_examples.py +36 -0
  84. knobly-1.1.0/tests/test_files.py +122 -0
  85. knobly-1.1.0/tests/test_kind_lint.py +28 -0
  86. knobly-1.1.0/tests/test_layers.py +203 -0
  87. knobly-1.1.0/tests/test_load.py +954 -0
  88. knobly-1.1.0/tests/test_parse.py +163 -0
  89. knobly-1.1.0/tests/test_rasl.py +92 -0
  90. knobly-1.1.0/tests/test_readers.py +194 -0
  91. knobly-1.1.0/tests/test_recovery.py +261 -0
  92. knobly-1.1.0/tests/test_report.py +148 -0
  93. knobly-1.1.0/tests/test_tokens.py +18 -0
  94. knobly-1.1.0/tests/test_tracer.py +135 -0
  95. knobly-1.1.0/tests/test_written.py +962 -0
  96. knobly-1.1.0/uv.lock +394 -0
@@ -0,0 +1,7 @@
1
+ # Generated by tools/gen.sh py during build and test; never committed (owner, 2026-10-23).
2
+ src/knobly/_gen/
3
+ src/knobly/_api/
4
+ dist/
5
+ .venv/
6
+ conformance/shapes.py
7
+ tests/api_contract.py
@@ -0,0 +1 @@
1
+ 3.13
knobly-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.5
2
+ Name: knobly
3
+ Version: 1.1.0
4
+ Summary: A configuration language defined by one formal grammar, with a lossless syntax tree
5
+ Project-URL: Repository, https://github.com/mcurcio/knobly
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: antlr4-python3-runtime==4.13.2
knobly-1.1.0/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # knobly (Python)
2
+
3
+ The Python package for the knobly configuration language. Its parser is generated by ANTLR4
4
+ from [`grammar/Knobly.g4`](../grammar/Knobly.g4), the language's one specification. It
5
+ needs Python 3.11 or later and has one dependency, `antlr4-python3-runtime` 4.13.2 (the same
6
+ version as the generator).
7
+
8
+ It is not on PyPI. Releases attach the wheel and sdist to the GitHub Release (see the
9
+ [root README](../README.md#releases)).
10
+
11
+ ## What it has
12
+
13
+ It has the same parts as the TypeScript package ([`ts/README.md`](../ts/README.md)), in Python's
14
+ own style: `Processor` for `knobly()`, `walk()` as a generator, `match` on one class per kind,
15
+ `snake_case` names. The core does no input or output. The concepts and their names come from
16
+ [`spec/`](../spec/README.md); each binding only changes the casing.
17
+
18
+ Every public signature is generated from [`spec/schema/api.json`](../spec/schema/api.json) into
19
+ `src/knobly/_api/` (spec/codegen.md): the records (`SourceSpan`, `Token`, `Copy`, `Expansion`,
20
+ `SliceValue`, `Added` ... as frozen dataclasses), the kind lists (`StrEnum`s), the diagnostic codes, the error
21
+ tree, the rules module (`MAX_NESTING`, `RESERVED`, `VARIABLE_NAME` ...) and a `Protocol` for every handle, which
22
+ each hand-written class here subclasses, so mypy checks each member against the schema. Every generated annotation
23
+ names the public classes (`knobly.LocatedDiagnostic`, `rendered.Directive`, `written.Statement` ...), so a
24
+ program annotates with what it imports: `def key(d: rendered.Directive) -> str | None` is a `rendered.KeyRule`.
25
+ `_api` is private; nothing outside `src/` imports it but the generated contract's type checker's block. Each
26
+ module's `__all__` equals the generated `PUBLIC_NAMES` and each public union holds the generated `UNIONS` variant
27
+ classes (tests/test_api_names.py); mypy checks each module against its generated `<Module>Functions` Protocol, each
28
+ constructed class against its `<Class>Constructor` and each hand class against its generated Protocol, all from the
29
+ generated `tests/api_contract.py`. Every public doc is the schema's: each public module gives its hand-written
30
+ classes, members and functions their generated docstrings at import (spec/codegen.md section 3). Inside the
31
+ package a `kind`, `reason` or `code` is compared with an enum member, never a string (tests/test_kind_lint.py runs `tools/lint/kind_lint.py`).
32
+
33
+ One namespace per layer (binding guide rule 9):
34
+
35
+ | Import | What it has |
36
+ |---|---|
37
+ | `knobly` | The shared and io layers: `ConfigText`, `quote`, `text`, positions, places, origins and steps, `Diagnostic`, `LocatedDiagnostic`, `Diagnostics`, `DiagnosticCode`, `Severity`, the error classes, `PROGRAM_CODE`, the document source and writer ports, `memory_source`, `memory_writer`, `check_lost_updates` |
38
+ | `knobly.syntax` | The syntax tree: `parse`, `Syntax`, `Token`, `Node`, `TokenRange`, `span_of`, `text_of`, `TokenKind`, `NodeKind`, `MAX_NESTING` |
39
+ | `knobly.written` | The low level: `Processor`, `DocumentGraph`, the written types, the expansion and its expanded values (`ExpandedWord` ... `ExpandedIndex`), the extension port (`Extension`, `ExtensionUse`), its kind lists and limits, `format_diagnostic`, and the written values `Word`, `String`, `List`, `Slice`, `Index` and `Reference` (import the module, `from knobly import written`, so `written.List` never replaces `typing.List`). It never imports the rendered layer |
40
+ | `knobly.rendered` | The rendered layer: `render`, `Rendered`, `RenderedBlock`, `Directive`, addresses, `KeyRule` and the change classes. It reaches the low level only through `knobly.written`'s public names |
41
+ | `knobly.include` | `include()`: the include extension |
42
+ | `knobly.files` | `file_source()` and `file_writer()`: files on disk |
43
+
44
+ Every block has one shape (spec 2.11): a written block (statements), an expansion block (directive expansions) and
45
+ a rendered block (directives) are each a `Sequence` (iterate, `len`, `block[i]`, `IndexError` out of range) with
46
+ `directives(name)` and `walk(descend=...)`, a generator over every item at any depth.
47
+
48
+ - **The syntax tree.** `parse(text)` returns a `Syntax` with `tokens`, `root` and `diagnostics`. It never
49
+ raises. The tree is lossless: `text_of(syntax, syntax.root)` gives the text back.
50
+ - **The low level: the files as written.** `Processor(source=..., extensions=[...])` is made once, and loading
51
+ is all it does: `processor.load(path, overrides=...)` reads the root file and every file it includes and
52
+ returns a `DocumentGraph`: the documents, their statements and values (one class per kind, each with a `kind`
53
+ from its generated `StrEnum` and `__match_args__`, for `match`: `case SetLine(variable, values):`,
54
+ `case Slice(Word("leds"), start, end):`), the include copies, the variables (`SetVariable`, `Parameter`,
55
+ `OverrideVariable`) and what each `$name` reads at each place.
56
+ A statement headed by a word is a directive whatever the word (`use epoll;` is a directive); the language
57
+ statements are `@set`, `@snippet` and `@use`, and `@include` is the include extension's line.
58
+ An operation exists only where it can succeed: `rename` on a written directive and on set variables and
59
+ parameters, `set` on statements with values, on set variables and on every written value; an include line is an
60
+ `ExtensionLine`, with no `rename`. An override is a value the program passes in, as config text
61
+ (`overrides={"host": quote(host)}`; change it later with `graph.set_override(name, text)`); which `@set` lines it
62
+ wins over, and what an included file sees, is [spec/expansion.md](../spec/expansion.md). Named edits change the
63
+ text and keep comments and layout. They take config text, `ConfigText` (a `NewType` of `str`), made only by
64
+ `quote()` from a program string or `text()` from a text already written as knobly text: mypy refuses a plain
65
+ `str` there, and at run time anything but a `str` raises `TypeError`. Write a number with `text(str(n))`.
66
+
67
+ ```python
68
+ from knobly import memory_source, quote, text
69
+ from knobly.written import Processor
70
+
71
+ graph = Processor(source=memory_source({"main.conf": "host a;\nport 80;\n"})).load("main.conf")
72
+ host, port = graph.root.block
73
+ host.set(quote("10.0.60.31")) # set replaces a statement's values (not the language's `@set` line)
74
+ port.set(text(str(8080)))
75
+ assert graph.root.text == 'host "10.0.60.31";\nport 8080;\n'
76
+ ```
77
+
78
+ `set(text)`, `replace_with_text(text)`, `insert_before(text)`, `insert_after(text)`,
79
+ `insert_statements(index, text)`, `add_statements(text)` and `rename(name)` are the edits. Every statement ends
80
+ with `;` or a block, and a line break is whitespace, so a values text may hold line breaks
81
+ (`port.set(text("1\n2"))` writes `port 1\n2;`). A given text is checked with the grammar's entry rule for it
82
+ (spec 2.8): a values text that holds a `;` or a block, or ends in a comment (which would swallow the `;`), raises
83
+ `BadTextError` (E2001, its message built from its diagnostics); a statement's `set` takes the empty text to clear
84
+ its values and refuses any other text that holds no value. An edit that would make its file nest blocks and lists
85
+ past the nesting [limit](https://mcurcio.github.io/knobly/language/#limits) raises `BadTextError` too: E2010 at the bracket of the given text, or at 0:0 when the file
86
+ was already too deep (a document's `replace_with_text` is never refused). A written value's `set(text)` takes exactly one value
87
+ and keeps the handle: the same object, now of the new value's kind (its class becomes `Word`, `List`, ...). A bad name in `rename`, `set_override` or an override is a program bug,
88
+ `ValueError`. `graph.read()` reads files an edit added, through the graph's own source; until then an include
89
+ copy not read renders as an unreadable with reason `unread` (E3007). `graph.save(writer)` hands the changed
90
+ files to a writer in one batch (`memory_writer(files)` writes into the same map a `memory_source(files)` reads;
91
+ `file_writer()` to disk); every writer refuses a file changed since it was read with `LostUpdateError` (a
92
+ `SaveError`, `paths` the conflicting files) through the one check `check_lost_updates`, which a program's own
93
+ writer calls too. `graph.expand()` gives every copy as read, which the rendered layer builds on, and
94
+ `graph.diagnostics` is the one list a render shows. A variable's `references()` are
95
+ lexical: the `$name`s in its own file that it declares. A use in an included file is resolved per copy, so it is
96
+ only in `wins()` and `shadowed_at()`. Guide: [Editing and saving](https://mcurcio.github.io/knobly/editing/).
97
+ - **The rendered tree.** `render(graph)` builds on the low level: it expands variables, snippets and included
98
+ files into directives (`block`, `tree.block.walk()`). A directive's values are the low level's expanded values,
99
+ the same objects its directive expansion holds, one class per kind (`ExpandedWord`, `ExpandedString`,
100
+ `ExpandedList` with its `items`, `ExpandedSlice`, `ExpandedIndex`; the union `ExpandedValue`), equal when their
101
+ canonical texts are (`==`, `hash`), with its readers (`word()`, `string()`, `number()`, `integer()`, `boolean()`,
102
+ `list()`, `slice()`: a word stays untyped until the program reads it as one of these), its
103
+ origin, and `written` (the value on the same line), `definition` (where it is written) and `chain` (each `$name`
104
+ it went through) leading back to the low level. `tree.addresses(key=...)` names each directive under a key rule,
105
+ and `before.diff(after, key=...)` lists what changed, as `Added`, `Removed`, `Changed` and `Unknown`. Matching a
106
+ `ExpandedString` by position gives its canonical `text`, quotes included; read `string()` for the decoded text.
107
+ A file included twice renders as two copies that share one written line, so an edit loop over the render meets
108
+ the same `written` handle twice; a value's `set` keeps its handle, so setting it through each copy just writes
109
+ the line again.
110
+
111
+ ```python
112
+ from knobly import rendered, text
113
+
114
+ for d in rendered.render(graph).block.walk():
115
+ match d:
116
+ case rendered.Directive("max_count", [value]):
117
+ value.written.set(text("2400"))
118
+ ```
119
+
120
+ Guide: [Getting started](https://mcurcio.github.io/knobly/getting-started/).
121
+
122
+ There is no fetch source and no fetch writer in Python. The user guide, with every sample in TypeScript, Python and
123
+ C++, is [https://mcurcio.github.io/knobly/](https://mcurcio.github.io/knobly/) (source: [`docs/site/pages/`](../docs/site/pages/)); every sample is a file `pytest` runs
124
+ (`examples/`). The language is [the language reference](https://mcurcio.github.io/knobly/language/).
125
+
126
+ Diagnostic codes are the same as in TypeScript. Each diagnostic has a `severity`, `Severity.ERROR` or
127
+ `Severity.WARNING`: the one warning, W3005, is an `@include` glob that matches no file. Nothing raises a warning or
128
+ acts on one; a program that stops on problems reads the sink's `errors` (`Diagnostics(tree.diagnostics).errors`). Offsets and columns count code points, Python's own string unit
129
+ ([spec/contract.md](../spec/contract.md) 1), so `text[span.start.offset:span.end.offset]` is a span's text
130
+ (TypeScript counts UTF-16 code units; the two differ after a character outside the Basic Multilingual Plane). A
131
+ place prints as `main.conf:4:5` (`str(place)`, counting from 1), as do diagnostics and steps. Every expected
132
+ outcome (a diagnostic, read, bad text, save or lost update error) is under `KnoblyError`, and no program bug
133
+ is: a detached handle raises `DetachedError` (a `ReferenceError`), misuse `ValueError`, a wrong argument type
134
+ `TypeError`, an index out of range `IndexError` (spec 2.8). The shared cases in `conformance/` stay in UTF-16 units; one shared
135
+ driver ([spec/conformance.md](../spec/conformance.md)) runs every corpus against this package through its adapter,
136
+ `conformance/adapter.py`, converts positions, and compares (the adapter's own failing source and `throws` extension
137
+ give the faults of spec/conformance.md 4.5). The Python package claims level L3 (syntax, low level,
138
+ rendered tree, extensions) and passes every case, as the TypeScript package does.
139
+
140
+ ## Build and test
141
+
142
+ From `py/`, with [uv](https://docs.astral.sh/uv/) (`.python-version` picks 3.13):
143
+
144
+ ```bash
145
+ uv run pytest # also builds the wheel and sdist once (tests/test_build.py)
146
+ uv run ruff check
147
+ uv run ruff format --check
148
+ uv run mypy
149
+ uv run --python 3.11 pytest # the oldest supported Python
150
+ python3 ../conformance/driver/run.py -- uv run --locked python conformance/adapter.py # the shared cases
151
+ ```
152
+
153
+ `pytest` runs this package's own unit tests; the shared cases run only through the driver (after `pytest` or
154
+ `bash ../tools/gen.sh py` has generated the parser). The driver prints TAP and ends with the count per level and
155
+ whether the claimed level holds.
156
+
157
+ `tests/conftest.py` first runs `bash ../tools/gen.sh py`, which regenerates the parser into
158
+ `src/knobly/_gen/` and the API declarations (records, Protocols, kind lists, codes, errors, rules, from
159
+ `spec/schema/`) into `src/knobly/_api/`, the adapter's message shapes and error groups into
160
+ `conformance/shapes.py`, and the names, match fields, unions and module Protocols the tests check against into
161
+ `tests/api_contract.py` (all ignored by git, never committed). That needs Java 11+; the ANTLR jar is
162
+ cached in `$ANTLR_JAR_DIR` (default `~/.cache/antlr`). A build needs no step first: `hatch_build.py` runs `gen.sh` when it builds from a checkout that has no generated files (after a grammar or
163
+ schema change, run `gen.sh` or the tests first: the hook does not refresh files that exist), and `pyproject.toml`
164
+ forces the ignored `_gen/` and `_api/` files into the wheel and sdist. So another project can depend on a commit
165
+ before any release (it needs bash, Java 11+, `curl` and `uv` when it installs):
166
+
167
+ ```toml
168
+ dependencies = ["knobly @ git+https://github.com/mcurcio/knobly@<commit>#subdirectory=py"]
169
+ ```
@@ -0,0 +1,483 @@
1
+ """The Python binding's conformance adapter (spec/conformance.md): the shared driver runs every corpus through it.
2
+ From py/:
3
+
4
+ python3 ../conformance/driver/run.py -- uv run --locked python conformance/adapter.py
5
+
6
+ It speaks the adapter protocol on standard input and output: one JSON request per line, one JSON response per line.
7
+ Every object it dumps gets an id; a later request names the object by that id. It finds no target and compares
8
+ nothing: the driver does both. Its dumps are typed by the generated message shapes (``shapes.py``, from
9
+ spec/schema/protocol.json), members are spelled through the generated ``SPELLING`` and error groups named through the
10
+ generated ``ERROR_GROUP_OF_CLASS``. Parameters are typed with the binding's public classes, the types the generated
11
+ records' fields carry."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from collections.abc import Callable, Sequence
18
+ from typing import Any
19
+
20
+ from shapes import (
21
+ ERROR_GROUP_OF_CLASS,
22
+ PROTOCOL,
23
+ SPELLING,
24
+ AddressDump,
25
+ BlockDump,
26
+ ChangeDump,
27
+ CopyDump,
28
+ DirectiveDump,
29
+ EditedDump,
30
+ ExpandedValueDump,
31
+ GraphDump,
32
+ HelloResultDump,
33
+ KeyRuleDump,
34
+ LoadedDump,
35
+ LocatedDiagnosticDump,
36
+ OriginDump,
37
+ OverrideDump,
38
+ PlaceDump,
39
+ Position,
40
+ ReaderResultDump,
41
+ RenderDump,
42
+ ResolutionDump,
43
+ SavedDump,
44
+ StatementDump,
45
+ StepDump,
46
+ SyntaxDiagnosticDump,
47
+ SyntaxDump,
48
+ UnreadableDump,
49
+ VariableReferenceDump,
50
+ WrittenValueDump,
51
+ )
52
+
53
+ import knobly as vl
54
+ from knobly import rendered, syntax, written
55
+ from knobly.include import include
56
+
57
+ handles: list[object] = []
58
+
59
+
60
+ def ref(obj: object) -> str:
61
+ handles.append(obj)
62
+ return str(len(handles) - 1)
63
+
64
+
65
+ def get(id_: str) -> Any:
66
+ return handles[int(id_)]
67
+
68
+
69
+ def pos(p: vl.SourcePosition) -> Position:
70
+ return (p.line, p.column, p.offset)
71
+
72
+
73
+ def file_place(p: vl.Place) -> PlaceDump:
74
+ return {"id": ref(p), "path": p.resolved_path, "start": pos(p.span.start), "end": pos(p.span.end)}
75
+
76
+
77
+ def place(p: vl.Place | vl.Override) -> PlaceDump | OverrideDump:
78
+ if p.kind is vl.PlaceKind.OVERRIDE:
79
+ return {"id": ref(p), "override": p.variable}
80
+ return file_place(p)
81
+
82
+
83
+ def steps(via: Sequence[vl.Step]) -> list[StepDump]:
84
+ return [{"id": ref(s), "kind": str(s.kind), "name": s.name, "place": file_place(s.place)} for s in via]
85
+
86
+
87
+ def origin(o: vl.Origin | vl.DirectiveOrigin) -> OriginDump:
88
+ return {"place": place(o.place), "via": steps(o.via)}
89
+
90
+
91
+ def diagnostic(d: vl.Diagnostic) -> SyntaxDiagnosticDump:
92
+ return {
93
+ "id": ref(d),
94
+ "code": str(d.code),
95
+ "severity": str(d.severity),
96
+ "message": d.message,
97
+ "start": pos(d.span.start),
98
+ "end": pos(d.span.end),
99
+ }
100
+
101
+
102
+ def located(d: vl.LocatedDiagnostic) -> LocatedDiagnosticDump:
103
+ return {
104
+ "id": ref(d),
105
+ "code": str(d.code),
106
+ "severity": str(d.severity),
107
+ "message": d.message,
108
+ "place": file_place(d.place),
109
+ "via": steps(d.via),
110
+ }
111
+
112
+
113
+ def syntax_dump(s: syntax.Syntax) -> SyntaxDump:
114
+ def node(n: syntax.Node) -> Any:
115
+ return (str(n.kind), n.start, n.end, [node(c) for c in n.children])
116
+
117
+ return {
118
+ "tokens": [(str(t.kind), t.text, pos(t.span.start), pos(t.span.end)) for t in s.tokens],
119
+ "root": node(s.root),
120
+ "diagnostics": [diagnostic(d) for d in s.diagnostics],
121
+ }
122
+
123
+
124
+ def variable(v: written.Variable) -> VariableReferenceDump:
125
+ where = None if v.kind is written.VariableKind.OVERRIDE else file_place(v.place)
126
+ return {"id": ref(v), "name": v.name, "kind": str(v.kind), "place": where}
127
+
128
+
129
+ def resolution(r: written.ReferenceResolution) -> ResolutionDump:
130
+ return {
131
+ "reference": {"id": ref(r.reference), "place": file_place(r.reference.place)},
132
+ "via": steps(r.copy.via),
133
+ "winner": None if r.winner is None else variable(r.winner),
134
+ "shadowed": [variable(v) for v in r.shadowed],
135
+ }
136
+
137
+
138
+ def value(v: written.WrittenValue) -> WrittenValueDump:
139
+ """The dump of one value: a discriminated union on the wire's kind, so each kind's own fields are checked."""
140
+ i, at, text = ref(v), file_place(v.place), v.text
141
+ match v:
142
+ case written.List():
143
+ return {"id": i, "kind": "list", "place": at, "text": text, "items": [value(x) for x in v.items]}
144
+ case written.Slice():
145
+ start = None if v.start is None else value(v.start)
146
+ end = None if v.end is None else value(v.end)
147
+ return {
148
+ "id": i,
149
+ "kind": "slice",
150
+ "place": at,
151
+ "text": text,
152
+ "name": value(v.name),
153
+ "start": start,
154
+ "end": end,
155
+ }
156
+ case written.Index():
157
+ return {"id": i, "kind": "index", "place": at, "text": text, "name": value(v.name), "index": value(v.index)}
158
+ case written.Reference():
159
+ declaration = None if v.declaration is None else variable(v.declaration)
160
+ resolutions = [resolution(r) for r in v.resolutions()]
161
+ return {
162
+ "id": i,
163
+ "kind": "reference",
164
+ "place": at,
165
+ "text": text,
166
+ "declaration": declaration,
167
+ "resolutions": resolutions,
168
+ }
169
+ case written.Word():
170
+ return {"id": i, "kind": "word", "place": at, "text": text}
171
+ case _:
172
+ return {"id": i, "kind": "string", "place": at, "text": text}
173
+
174
+
175
+ def statement(s: written.Statement) -> StatementDump:
176
+ """The dump of one statement, one alternative per kind (as value above)."""
177
+ i, at, text = ref(s), file_place(s.place), s.text
178
+
179
+ def inner(b: written.WrittenBlock | None) -> BlockDump | None:
180
+ return None if b is None else block(b)
181
+
182
+ match s:
183
+ case written.WrittenDirective():
184
+ return {
185
+ "id": i,
186
+ "kind": "directive",
187
+ "place": at,
188
+ "text": text,
189
+ "values": [value(v) for v in s.values],
190
+ "block": inner(s.block),
191
+ }
192
+ case written.ExtensionLine():
193
+ return {
194
+ "id": i,
195
+ "kind": "extension",
196
+ "place": at,
197
+ "text": text,
198
+ "values": [value(v) for v in s.values],
199
+ "block": inner(s.block),
200
+ }
201
+ case written.SetLine():
202
+ return {"id": i, "kind": "set", "place": at, "text": text, "values": [value(v) for v in s.values]}
203
+ case written.UseLine():
204
+ return {"id": i, "kind": "use", "place": at, "text": text, "values": [value(v) for v in s.values]}
205
+ case written.SnippetLine():
206
+ return {"id": i, "kind": "snippet", "place": at, "text": text, "block": inner(s.block)}
207
+ case _:
208
+ return {"id": i, "kind": "unreadable", "place": at, "text": text}
209
+
210
+
211
+ def block(b: written.WrittenBlock) -> BlockDump:
212
+ return {"id": ref(b), "statements": [statement(s) for s in b]}
213
+
214
+
215
+ def copy(c: written.Copy) -> CopyDump:
216
+ return {"place": file_place(c.statement.place), "via": steps(c.via)}
217
+
218
+
219
+ def graph_dump(g: written.DocumentGraph) -> GraphDump:
220
+ return {
221
+ "id": ref(g),
222
+ "documents": [
223
+ {
224
+ "id": ref(d),
225
+ "path": d.resolved_path,
226
+ "text": d.text,
227
+ "savedText": d.saved_text,
228
+ "syntax": syntax_dump(d.syntax),
229
+ "block": block(d.block),
230
+ "includedBy": [copy(e.copy) for e in d.included_by()],
231
+ }
232
+ for d in g.documents.values()
233
+ ],
234
+ "edges": [{"copy": copy(e.copy), "specs": list(e.specs), "targets": list(e.targets)} for e in g.edges()],
235
+ "variables": [
236
+ {
237
+ **variable(v),
238
+ "wins": [resolution(r) for r in v.wins()],
239
+ "shadowedAt": [resolution(r) for r in v.shadowed_at()],
240
+ }
241
+ for v in g.variables()
242
+ ],
243
+ "diagnostics": [located(d) for d in g.diagnostics],
244
+ }
245
+
246
+
247
+ def key_rule(rules: Sequence[KeyRuleDump] | None) -> rendered.KeyRule | None:
248
+ """A key rule from its data (spec/conformance.md section 3)."""
249
+ if rules is None:
250
+ return None
251
+
252
+ def key(d: rendered.Directive) -> str | None:
253
+ for rule in rules:
254
+ parent = rule.get("parent")
255
+ if rule["directive"] == d.name and (parent is None or (d.parent is not None and d.parent.name == parent)):
256
+ how, arg = rule["key"]
257
+ if how == "value":
258
+ return d.values[int(arg)].text if len(d.values) > int(arg) else None
259
+ return str(arg).join(a.text for a in d.values)
260
+ return None
261
+
262
+ return key
263
+
264
+
265
+ def address(a: rendered.Address) -> AddressDump:
266
+ return {"text": a.text, "people": str(a)}
267
+
268
+
269
+ def render_dump(r: rendered.Rendered, rules: Sequence[KeyRuleDump] | None) -> RenderDump:
270
+ addresses = r.addresses(key=key_rule(rules))
271
+
272
+ def unreadable(u: written.UnreadableExpansion) -> UnreadableDump:
273
+ return {"reason": str(u.reason), "origin": origin(u.origin)}
274
+
275
+ def expanded(a: written.ExpandedValue) -> ExpandedValueDump:
276
+ return {
277
+ "id": ref(a),
278
+ "kind": str(a.kind),
279
+ "text": a.text,
280
+ "origin": origin(a.origin),
281
+ "definition": None if a.definition is None else file_place(a.definition.place),
282
+ "chain": [resolution(x) for x in a.chain],
283
+ }
284
+
285
+ def directive(d: rendered.Directive) -> DirectiveDump:
286
+ return {
287
+ "id": ref(d),
288
+ "name": d.name,
289
+ "address": address(addresses[d]),
290
+ "origin": origin(d.origin),
291
+ "values": [expanded(a) for a in d.values],
292
+ "block": None if d.block is None else [directive(x) for x in d.block],
293
+ "unreadable": [unreadable(u) for u in d.unreadable],
294
+ }
295
+
296
+ return {
297
+ "id": ref(r),
298
+ "block": [directive(d) for d in r.block],
299
+ "unreadable": [unreadable(u) for u in r.unreadable],
300
+ "diagnostics": [located(d) for d in r.diagnostics],
301
+ }
302
+
303
+
304
+ def result(x: Any) -> ReaderResultDump:
305
+ """A reader's result, neutral (spec/conformance.md, ``read``)."""
306
+ if isinstance(x, bool):
307
+ return {"boolean": x}
308
+ if isinstance(x, str):
309
+ return {"text": x}
310
+ if isinstance(x, int | float):
311
+ return {"number": "-0" if x == 0 and str(x).startswith("-") else x}
312
+ if isinstance(x, written.SliceValue):
313
+ return {"slice": (x.name, x.start, x.end)}
314
+ if isinstance(x, written.IndexValue):
315
+ return {"index": (x.name, x.index)}
316
+ if isinstance(x, tuple): # the list reader's items
317
+ return {"list": [{"value": item.text} for item in x]}
318
+ raise TypeError(f"not a reader result: {x!r}")
319
+
320
+
321
+ # Where each edit takes config text (the driver's OPS parameters): the adapter wraps the text the driver sends with
322
+ # text(), as a program does with a text already written as knobly text (spec 6.9). Names stay plain text.
323
+ TEXT_AT = {
324
+ "set": 0,
325
+ "replace with text": 0,
326
+ "insert statements": 1,
327
+ "add statements": 0,
328
+ "insert before": 0,
329
+ "insert after": 0,
330
+ "set override": 1,
331
+ }
332
+
333
+
334
+ def config_text(given: Any) -> Any:
335
+ """``text(given)`` for a str; anything else as given (a case may pass a non-str to see the argument type error)."""
336
+ return vl.text(given) if isinstance(given, str) else given
337
+
338
+
339
+ def edit(target: str, member: str, args: list[Any]) -> EditedDump:
340
+ at = TEXT_AT.get(member)
341
+ if at is not None and at < len(args):
342
+ args = [config_text(a) if i == at else a for i, a in enumerate(args)]
343
+ out = getattr(get(target), SPELLING[member])(*args)
344
+ return {"handles": [{"id": ref(h), "text": h.text} for h in out]} if isinstance(out, tuple) else {}
345
+
346
+
347
+ class Recorder:
348
+ def __init__(self) -> None:
349
+ self.batches: list[list[tuple[str, str]]] = []
350
+
351
+ def write(self, files: Sequence[vl.FileWrite], /) -> None:
352
+ self.batches.append([(f.resolved_path, f.text) for f in files])
353
+
354
+
355
+ def save(graph: str, disk: dict[str, str] | None = None) -> SavedDump:
356
+ """Without ``disk``, what a recording writer got; with it, a memory writer over a new map holding ``disk`` (so a
357
+ file whose text there differs from its saved text is a lost update), then the map after, sorted by path."""
358
+ if disk is None:
359
+ recorder = Recorder()
360
+ get(graph).save(recorder)
361
+ return {"batches": recorder.batches}
362
+ files = dict(disk)
363
+ get(graph).save(vl.memory_writer(files))
364
+ return {"files": sorted(files.items())}
365
+
366
+
367
+ class FailingReads:
368
+ """The test source of spec/conformance.md 4.5: the memory source, but ``read`` of a path named fails."""
369
+
370
+ def __init__(self, source: vl.DocumentSource, failing: Sequence[str]) -> None:
371
+ self.source, self.failing = source, frozenset(failing)
372
+
373
+ def resolve(self, spec: str, context: vl.LoadContext, /) -> tuple[str, ...]:
374
+ return self.source.resolve(spec, context)
375
+
376
+ def read(self, resolved_path: str, /) -> str:
377
+ if resolved_path in self.failing:
378
+ raise RuntimeError(f"failReads: {resolved_path}")
379
+ return self.source.read(resolved_path)
380
+
381
+
382
+ class Throws:
383
+ """The test extension ``throws`` of spec/conformance.md 4.5: its ``specs`` raises a diagnostic error of the code
384
+ its first value names when that value is a word, else an error of no spec 2.8 group."""
385
+
386
+ @property
387
+ def directive(self) -> str:
388
+ return "throws"
389
+
390
+ def specs(self, use: written.ExtensionUse, /) -> tuple[str, ...]:
391
+ first = use.values[0] if use.values else None
392
+ if not isinstance(first, written.ExpandedWord):
393
+ raise RuntimeError("the throws extension")
394
+ code = vl.DiagnosticCode(first.text)
395
+ message = f"the throws extension: {code}"
396
+ raise vl.DiagnosticError(vl.LocatedDiagnostic(code, message, use.origin.place, use.origin.via))
397
+
398
+
399
+ EXTENSIONS: dict[str, Callable[[], written.Extension]] = {"include": include, "throws": Throws}
400
+
401
+
402
+ def load(
403
+ files: dict[str, str],
404
+ root: str,
405
+ extensions: list[str],
406
+ overrides: dict[str, Any] | None = None,
407
+ failReads: list[str] | None = None, # the wire's own spelling
408
+ ) -> LoadedDump:
409
+ source = vl.memory_source(files)
410
+ if failReads is not None:
411
+ source = FailingReads(source, failReads)
412
+ processor = written.Processor(source=source, extensions=[EXTENSIONS[name]() for name in extensions])
413
+ given = None if overrides is None else {name: config_text(text) for name, text in overrides.items()}
414
+ return {"graph": ref(processor.load(root, overrides=given))}
415
+
416
+
417
+ def hello(protocol: int) -> HelloResultDump:
418
+ return {"protocol": PROTOCOL, "binding": "python", "unit": "code point", "level": 3}
419
+
420
+
421
+ def diff(before: str, after: str, key: Sequence[KeyRuleDump] | None = None) -> list[ChangeDump]:
422
+ changes = get(before).diff(get(after), key=key_rule(key))
423
+ return [{"kind": str(c.kind), "address": address(c.address)} for c in changes]
424
+
425
+
426
+ OPS: dict[str, Callable[..., Any]] = {
427
+ "hello": hello,
428
+ "parse": lambda text: syntax_dump(syntax.parse(text)),
429
+ "load": load,
430
+ "graph": lambda graph: graph_dump(get(graph)),
431
+ "render": lambda graph, key=None: render_dump(rendered.render(get(graph)), key),
432
+ "diff": diff,
433
+ "edit": edit,
434
+ "save": save,
435
+ "quote": lambda text: vl.quote(text),
436
+ "read": lambda value, reader: result(getattr(get(value), SPELLING[reader])()),
437
+ "print": lambda target: str(get(target)),
438
+ "format": lambda diagnostic, graph=None: written.format_diagnostic(
439
+ get(diagnostic), None if graph is None else get(graph)
440
+ ),
441
+ "cut": lambda text, start, end: text[start:end],
442
+ }
443
+
444
+
445
+ def group_of(error: Exception) -> str | None:
446
+ """The spec 2.8 group of an error: the first class of its ``__mro__`` that ``ERROR_GROUP_OF_CLASS`` names, so the
447
+ most specific (a ``LostUpdateError`` before its ``SaveError``; the language's own ``TypeError``, ``ValueError``
448
+ and ``IndexError`` for the program-bug groups)."""
449
+ names = (cls.__name__ for cls in type(error).__mro__)
450
+ return next((ERROR_GROUP_OF_CLASS[name] for name in names if name in ERROR_GROUP_OF_CLASS), None)
451
+
452
+
453
+ def thrown(error: Exception) -> dict[str, Any] | None:
454
+ """The thrown response's body: the group, with its code, diagnostics or paths; None for anything else (a fault)."""
455
+ name = group_of(error)
456
+ if name is None:
457
+ return None
458
+ out: dict[str, Any] = {"group": name}
459
+ if isinstance(error, vl.DiagnosticError):
460
+ out["code"] = str(error.diagnostic.code)
461
+ if isinstance(error, vl.BadTextError):
462
+ out["diagnostics"] = [diagnostic(d) for d in error.diagnostics]
463
+ if isinstance(error, vl.ReadError | vl.SaveError):
464
+ out["paths"] = list(error.paths)
465
+ return out
466
+
467
+
468
+ def main() -> None:
469
+ for line in sys.stdin:
470
+ request = json.loads(line)
471
+ id_, op = request.pop("id"), request.pop("op")
472
+ try:
473
+ response = {"id": id_, "ok": OPS[op](**request)}
474
+ except Exception as error:
475
+ body = thrown(error)
476
+ fault = f"{type(error).__name__}: {error}"
477
+ response = {"id": id_, "thrown": body} if body is not None else {"id": id_, "fault": fault}
478
+ sys.stdout.write(json.dumps(response) + "\n")
479
+ sys.stdout.flush()
480
+
481
+
482
+ if __name__ == "__main__":
483
+ main()