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.
- knobly-1.1.0/.gitignore +7 -0
- knobly-1.1.0/.python-version +1 -0
- knobly-1.1.0/PKG-INFO +8 -0
- knobly-1.1.0/README.md +169 -0
- knobly-1.1.0/conformance/adapter.py +483 -0
- knobly-1.1.0/examples/addresses.py +26 -0
- knobly-1.1.0/examples/custom_source.py +38 -0
- knobly-1.1.0/examples/diagnostics.py +15 -0
- knobly-1.1.0/examples/diff_reload.py +31 -0
- knobly-1.1.0/examples/edit_save.py +30 -0
- knobly-1.1.0/examples/edit_statements.py +35 -0
- knobly-1.1.0/examples/errors.py +26 -0
- knobly-1.1.0/examples/hello.py +12 -0
- knobly-1.1.0/examples/include_read.py +25 -0
- knobly-1.1.0/examples/load_render.py +23 -0
- knobly-1.1.0/examples/lost_update.py +33 -0
- knobly-1.1.0/examples/overrides.py +21 -0
- knobly-1.1.0/examples/quickstart.py +24 -0
- knobly-1.1.0/examples/read_values.py +24 -0
- knobly-1.1.0/examples/sink.py +25 -0
- knobly-1.1.0/examples/snippets.py +25 -0
- knobly-1.1.0/examples/syntax_tree.py +29 -0
- knobly-1.1.0/examples/variables.py +29 -0
- knobly-1.1.0/hatch_build.py +47 -0
- knobly-1.1.0/pyproject.toml +60 -0
- knobly-1.1.0/src/knobly/__init__.py +82 -0
- knobly-1.1.0/src/knobly/_anatomy.py +65 -0
- knobly-1.1.0/src/knobly/_api/.gitignore +2 -0
- knobly-1.1.0/src/knobly/_api/__init__.py +1 -0
- knobly-1.1.0/src/knobly/_api/codes.py +294 -0
- knobly-1.1.0/src/knobly/_api/docs.py +29 -0
- knobly-1.1.0/src/knobly/_api/errors.py +88 -0
- knobly-1.1.0/src/knobly/_api/io.py +134 -0
- knobly-1.1.0/src/knobly/_api/kinds.py +171 -0
- knobly-1.1.0/src/knobly/_api/limits.py +37 -0
- knobly-1.1.0/src/knobly/_api/record.py +17 -0
- knobly-1.1.0/src/knobly/_api/rendered.py +276 -0
- knobly-1.1.0/src/knobly/_api/shared.py +349 -0
- knobly-1.1.0/src/knobly/_api/syntax.py +117 -0
- knobly-1.1.0/src/knobly/_api/written.py +2189 -0
- knobly-1.1.0/src/knobly/_block.py +79 -0
- knobly-1.1.0/src/knobly/_diagnostics.py +46 -0
- knobly-1.1.0/src/knobly/_docs.py +70 -0
- knobly-1.1.0/src/knobly/_gen/Knobly.interp +75 -0
- knobly-1.1.0/src/knobly/_gen/Knobly.tokens +30 -0
- knobly-1.1.0/src/knobly/_gen/KnoblyLexer.interp +85 -0
- knobly-1.1.0/src/knobly/_gen/KnoblyLexer.py +165 -0
- knobly-1.1.0/src/knobly/_gen/KnoblyLexer.tokens +30 -0
- knobly-1.1.0/src/knobly/_gen/KnoblyParser.py +1531 -0
- knobly-1.1.0/src/knobly/_gen/__init__.py +0 -0
- knobly-1.1.0/src/knobly/_glob.py +18 -0
- knobly-1.1.0/src/knobly/_origin.py +59 -0
- knobly-1.1.0/src/knobly/_report.py +104 -0
- knobly-1.1.0/src/knobly/_source.py +88 -0
- knobly-1.1.0/src/knobly/_text.py +49 -0
- knobly-1.1.0/src/knobly/_values.py +87 -0
- knobly-1.1.0/src/knobly/files.py +98 -0
- knobly-1.1.0/src/knobly/include.py +42 -0
- knobly-1.1.0/src/knobly/py.typed +0 -0
- knobly-1.1.0/src/knobly/rendered/__init__.py +32 -0
- knobly-1.1.0/src/knobly/rendered/_render.py +138 -0
- knobly-1.1.0/src/knobly/rendered/_tree.py +145 -0
- knobly-1.1.0/src/knobly/syntax/__init__.py +27 -0
- knobly-1.1.0/src/knobly/syntax/_cst.py +42 -0
- knobly-1.1.0/src/knobly/syntax/_parse.py +137 -0
- knobly-1.1.0/src/knobly/syntax/_recovery.py +307 -0
- knobly-1.1.0/src/knobly/syntax/_walk.py +138 -0
- knobly-1.1.0/src/knobly/written/__init__.py +126 -0
- knobly-1.1.0/src/knobly/written/_bind.py +985 -0
- knobly-1.1.0/src/knobly/written/_document.py +996 -0
- knobly-1.1.0/src/knobly/written/_expand.py +68 -0
- knobly-1.1.0/src/knobly/written/_format.py +32 -0
- knobly-1.1.0/src/knobly/written/_graph.py +143 -0
- knobly-1.1.0/src/knobly/written/_model.py +971 -0
- knobly-1.1.0/src/knobly/written/_names.py +29 -0
- knobly-1.1.0/src/knobly/written/_processor.py +39 -0
- knobly-1.1.0/src/knobly/written/_read.py +86 -0
- knobly-1.1.0/src/knobly/written/_shape.py +122 -0
- knobly-1.1.0/src/knobly/written/_value.py +324 -0
- knobly-1.1.0/tests/conftest.py +11 -0
- knobly-1.1.0/tests/test_api_names.py +222 -0
- knobly-1.1.0/tests/test_build.py +102 -0
- knobly-1.1.0/tests/test_examples.py +36 -0
- knobly-1.1.0/tests/test_files.py +122 -0
- knobly-1.1.0/tests/test_kind_lint.py +28 -0
- knobly-1.1.0/tests/test_layers.py +203 -0
- knobly-1.1.0/tests/test_load.py +954 -0
- knobly-1.1.0/tests/test_parse.py +163 -0
- knobly-1.1.0/tests/test_rasl.py +92 -0
- knobly-1.1.0/tests/test_readers.py +194 -0
- knobly-1.1.0/tests/test_recovery.py +261 -0
- knobly-1.1.0/tests/test_report.py +148 -0
- knobly-1.1.0/tests/test_tokens.py +18 -0
- knobly-1.1.0/tests/test_tracer.py +135 -0
- knobly-1.1.0/tests/test_written.py +962 -0
- knobly-1.1.0/uv.lock +394 -0
knobly-1.1.0/.gitignore
ADDED
|
@@ -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()
|