linksql 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.
- linksql/__init__.py +117 -0
- linksql/lino.py +289 -0
- linksql/names.py +72 -0
- linksql/protocol.py +225 -0
- linksql/query.py +254 -0
- linksql/schema.py +293 -0
- linksql/store.py +182 -0
- linksql/substitution.py +342 -0
- linksql-0.1.0.dist-info/METADATA +90 -0
- linksql-0.1.0.dist-info/RECORD +11 -0
- linksql-0.1.0.dist-info/WHEEL +4 -0
linksql/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""LinksQL — a Python reference implementation.
|
|
2
|
+
|
|
3
|
+
LinksQL is an associative query language built on **one data primitive** (the
|
|
4
|
+
link, a doublet) and **one operation** (substitution). This package is a
|
|
5
|
+
faithful, behaviourally-identical port of the JavaScript reference implementation
|
|
6
|
+
in ``../src``; the normative specification lives in ``../docs/SPECIFICATION.md``.
|
|
7
|
+
|
|
8
|
+
The public surface mirrors the JavaScript package entry point: parse and
|
|
9
|
+
serialise LiNo, build a :class:`LinksStore`, and run queries through a
|
|
10
|
+
:class:`Database`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .lino import (
|
|
16
|
+
Link as LinkNode,
|
|
17
|
+
)
|
|
18
|
+
from .lino import (
|
|
19
|
+
LinoSyntaxError,
|
|
20
|
+
Node,
|
|
21
|
+
Ref,
|
|
22
|
+
Token,
|
|
23
|
+
parse,
|
|
24
|
+
serialize,
|
|
25
|
+
serialize_all,
|
|
26
|
+
tokenize,
|
|
27
|
+
)
|
|
28
|
+
from .names import Names, UnknownNameError
|
|
29
|
+
from .protocol import (
|
|
30
|
+
JSON_CONTENT_TYPE,
|
|
31
|
+
LINO_CONTENT_TYPE,
|
|
32
|
+
decode,
|
|
33
|
+
encode,
|
|
34
|
+
escape_reference,
|
|
35
|
+
prefers_json,
|
|
36
|
+
)
|
|
37
|
+
from .query import (
|
|
38
|
+
Database,
|
|
39
|
+
Introspection,
|
|
40
|
+
MatchRow,
|
|
41
|
+
QueryError,
|
|
42
|
+
QueryReport,
|
|
43
|
+
SplitQuery,
|
|
44
|
+
link_to_lino,
|
|
45
|
+
patterns_of,
|
|
46
|
+
split_query,
|
|
47
|
+
)
|
|
48
|
+
from .schema import (
|
|
49
|
+
NamedQuery,
|
|
50
|
+
NamedSubscription,
|
|
51
|
+
Relation,
|
|
52
|
+
Schema,
|
|
53
|
+
SchemaError,
|
|
54
|
+
)
|
|
55
|
+
from .store import Link, LinkIntegrityError, LinksStore
|
|
56
|
+
from .substitution import (
|
|
57
|
+
Context,
|
|
58
|
+
RawResult,
|
|
59
|
+
Row,
|
|
60
|
+
Slots,
|
|
61
|
+
Spec,
|
|
62
|
+
SubstitutionError,
|
|
63
|
+
execute,
|
|
64
|
+
link_matches,
|
|
65
|
+
link_slots,
|
|
66
|
+
match_restriction,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__version__ = "0.1.0"
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"JSON_CONTENT_TYPE",
|
|
73
|
+
"LINO_CONTENT_TYPE",
|
|
74
|
+
"Context",
|
|
75
|
+
"Database",
|
|
76
|
+
"Introspection",
|
|
77
|
+
"Link",
|
|
78
|
+
"LinkIntegrityError",
|
|
79
|
+
"LinkNode",
|
|
80
|
+
"LinksStore",
|
|
81
|
+
"LinoSyntaxError",
|
|
82
|
+
"MatchRow",
|
|
83
|
+
"NamedQuery",
|
|
84
|
+
"NamedSubscription",
|
|
85
|
+
"Names",
|
|
86
|
+
"Node",
|
|
87
|
+
"QueryError",
|
|
88
|
+
"QueryReport",
|
|
89
|
+
"RawResult",
|
|
90
|
+
"Ref",
|
|
91
|
+
"Relation",
|
|
92
|
+
"Row",
|
|
93
|
+
"Schema",
|
|
94
|
+
"SchemaError",
|
|
95
|
+
"Slots",
|
|
96
|
+
"Spec",
|
|
97
|
+
"SplitQuery",
|
|
98
|
+
"SubstitutionError",
|
|
99
|
+
"Token",
|
|
100
|
+
"UnknownNameError",
|
|
101
|
+
"__version__",
|
|
102
|
+
"decode",
|
|
103
|
+
"encode",
|
|
104
|
+
"escape_reference",
|
|
105
|
+
"execute",
|
|
106
|
+
"link_matches",
|
|
107
|
+
"link_slots",
|
|
108
|
+
"link_to_lino",
|
|
109
|
+
"match_restriction",
|
|
110
|
+
"parse",
|
|
111
|
+
"patterns_of",
|
|
112
|
+
"prefers_json",
|
|
113
|
+
"serialize",
|
|
114
|
+
"serialize_all",
|
|
115
|
+
"split_query",
|
|
116
|
+
"tokenize",
|
|
117
|
+
]
|
linksql/lino.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Links Notation (LiNo) parser and serializer.
|
|
2
|
+
|
|
3
|
+
LiNo represents associative data as nested links. Every link has the form::
|
|
4
|
+
|
|
5
|
+
(index: source target)
|
|
6
|
+
|
|
7
|
+
where ``index`` (the identity) is optional and the values (``source``,
|
|
8
|
+
``target``, ...) are themselves references or links. The notation is the surface
|
|
9
|
+
syntax for the LinksQL substitution model.
|
|
10
|
+
|
|
11
|
+
The grammar implemented here is::
|
|
12
|
+
|
|
13
|
+
document = { value } ;
|
|
14
|
+
value = link | reference ;
|
|
15
|
+
link = "(" [ value ":" ] { value } ")" ;
|
|
16
|
+
reference = number | name | variable | wildcard | string ;
|
|
17
|
+
variable = "$" name ;
|
|
18
|
+
wildcard = "*" ;
|
|
19
|
+
|
|
20
|
+
The parser produces a small, explicit AST so the rest of the engine never has to
|
|
21
|
+
re-tokenize text.
|
|
22
|
+
|
|
23
|
+
This module is a faithful, behaviourally-identical port of ``src/lino.js`` from
|
|
24
|
+
the JavaScript reference implementation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Union
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LinoSyntaxError(Exception):
|
|
35
|
+
"""Error raised when LiNo input cannot be parsed.
|
|
36
|
+
|
|
37
|
+
Mirrors the JavaScript ``LinoSyntaxError``: the formatted message appends a
|
|
38
|
+
position suffix only when ``position`` is non-negative, and ``position``
|
|
39
|
+
stays available as an attribute.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, position: int) -> None:
|
|
43
|
+
full = f"{message} (at position {position})" if position >= 0 else message
|
|
44
|
+
super().__init__(full)
|
|
45
|
+
self.position = position
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Whitespace characters that separate tokens (otherwise insignificant).
|
|
49
|
+
_WHITESPACE = frozenset((" ", "\t", "\n", "\r", "\f", "\v"))
|
|
50
|
+
# Structural delimiters that break a bareword and/or carry their own meaning.
|
|
51
|
+
_DELIMITERS = frozenset(("(", ")", ":", '"', "'"))
|
|
52
|
+
# Characters that force a name to be quoted on output.
|
|
53
|
+
_QUOTE_REQUIRED = re.compile(r"[\s():\"']")
|
|
54
|
+
# A bareword that is entirely ASCII digits is a numeric reference.
|
|
55
|
+
_DIGITS = re.compile(r"^\d+$")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Ref:
|
|
60
|
+
"""A reference node: a number, name, variable, or wildcard.
|
|
61
|
+
|
|
62
|
+
``kind`` is one of ``"number"``, ``"name"``, ``"variable"``, ``"wildcard"``.
|
|
63
|
+
For ``"number"`` references ``value`` is an :class:`int`; otherwise it is the
|
|
64
|
+
string label / variable name / ``"*"``.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
kind: str
|
|
68
|
+
value: int | str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Link:
|
|
73
|
+
"""A link node: ``( [ id ":" ] values... )``.
|
|
74
|
+
|
|
75
|
+
``id`` is a reference node, a nested link node, or ``None`` (absent) and
|
|
76
|
+
``values`` is an ordered list of child nodes.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
id: Node | None
|
|
80
|
+
values: list[Node]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# A LiNo AST node is either a reference or a link.
|
|
84
|
+
Node = Union[Ref, Link]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Token:
|
|
89
|
+
"""A LiNo token.
|
|
90
|
+
|
|
91
|
+
``type`` is one of ``"lparen"``, ``"rparen"``, ``"colon"``, ``"ref"``. For
|
|
92
|
+
``"ref"`` tokens ``kind`` classifies the reference and ``value`` carries its
|
|
93
|
+
payload; ``pos`` is the source offset and ``quoted`` records whether the ref
|
|
94
|
+
came from a quoted string.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
type: str
|
|
98
|
+
pos: int
|
|
99
|
+
kind: str | None = None
|
|
100
|
+
value: int | str | None = None
|
|
101
|
+
quoted: bool = field(default=False)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _classify_word(word: str, pos: int) -> Token:
|
|
105
|
+
"""Classify a bareword into a reference token.
|
|
106
|
+
|
|
107
|
+
Numbers become numeric references, ``$x`` becomes a variable, ``*`` becomes a
|
|
108
|
+
wildcard, and everything else becomes a name.
|
|
109
|
+
"""
|
|
110
|
+
if word == "*":
|
|
111
|
+
return Token(type="ref", kind="wildcard", value="*", pos=pos)
|
|
112
|
+
if word[:1] == "$":
|
|
113
|
+
return Token(type="ref", kind="variable", value=word[1:], pos=pos)
|
|
114
|
+
if _DIGITS.match(word):
|
|
115
|
+
return Token(type="ref", kind="number", value=int(word), pos=pos)
|
|
116
|
+
return Token(type="ref", kind="name", value=word, pos=pos)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_string(text: str, start: int) -> tuple[Token, int]:
|
|
120
|
+
"""Read a quoted string starting at ``start`` (which points at the quote).
|
|
121
|
+
|
|
122
|
+
Returns the resulting name token and the index just past the closing quote.
|
|
123
|
+
"""
|
|
124
|
+
quote = text[start]
|
|
125
|
+
i = start + 1
|
|
126
|
+
chars: list[str] = []
|
|
127
|
+
n = len(text)
|
|
128
|
+
while i < n and text[i] != quote:
|
|
129
|
+
if text[i] == "\\" and i + 1 < n:
|
|
130
|
+
chars.append(text[i + 1])
|
|
131
|
+
i += 2
|
|
132
|
+
else:
|
|
133
|
+
chars.append(text[i])
|
|
134
|
+
i += 1
|
|
135
|
+
if i >= n:
|
|
136
|
+
message = "Unterminated quoted string"
|
|
137
|
+
raise LinoSyntaxError(message, start)
|
|
138
|
+
token = Token(type="ref", kind="name", value="".join(chars), pos=start, quoted=True)
|
|
139
|
+
return token, i + 1
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def tokenize(text: str) -> list[Token]:
|
|
143
|
+
"""Split LiNo source text into a flat list of tokens."""
|
|
144
|
+
tokens: list[Token] = []
|
|
145
|
+
i = 0
|
|
146
|
+
n = len(text)
|
|
147
|
+
while i < n:
|
|
148
|
+
ch = text[i]
|
|
149
|
+
if ch in _WHITESPACE:
|
|
150
|
+
i += 1
|
|
151
|
+
elif ch == "(":
|
|
152
|
+
tokens.append(Token(type="lparen", pos=i))
|
|
153
|
+
i += 1
|
|
154
|
+
elif ch == ")":
|
|
155
|
+
tokens.append(Token(type="rparen", pos=i))
|
|
156
|
+
i += 1
|
|
157
|
+
elif ch == ":":
|
|
158
|
+
tokens.append(Token(type="colon", pos=i))
|
|
159
|
+
i += 1
|
|
160
|
+
elif ch in ('"', "'"):
|
|
161
|
+
token, nxt = _read_string(text, i)
|
|
162
|
+
tokens.append(token)
|
|
163
|
+
i = nxt
|
|
164
|
+
else:
|
|
165
|
+
start = i
|
|
166
|
+
chars: list[str] = []
|
|
167
|
+
while i < n and text[i] not in _WHITESPACE:
|
|
168
|
+
if text[i] in _DELIMITERS:
|
|
169
|
+
break
|
|
170
|
+
chars.append(text[i])
|
|
171
|
+
i += 1
|
|
172
|
+
tokens.append(_classify_word("".join(chars), start))
|
|
173
|
+
return tokens
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class _Cursor:
|
|
178
|
+
"""A parser cursor over a flat token list."""
|
|
179
|
+
|
|
180
|
+
tokens: list[Token]
|
|
181
|
+
index: int = 0
|
|
182
|
+
|
|
183
|
+
def peek(self) -> Token | None:
|
|
184
|
+
if self.index < len(self.tokens):
|
|
185
|
+
return self.tokens[self.index]
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def advance(self) -> Token:
|
|
189
|
+
token = self.tokens[self.index]
|
|
190
|
+
self.index += 1
|
|
191
|
+
return token
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _reference_node(token: Token) -> Ref:
|
|
195
|
+
"""Build a reference AST node from a reference token."""
|
|
196
|
+
assert token.kind is not None
|
|
197
|
+
assert token.value is not None
|
|
198
|
+
return Ref(kind=token.kind, value=token.value)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_value(cursor: _Cursor) -> Node:
|
|
202
|
+
"""Parse a single value (a reference or a parenthesized link)."""
|
|
203
|
+
token = cursor.peek()
|
|
204
|
+
if token is None:
|
|
205
|
+
message = "Unexpected end of input"
|
|
206
|
+
raise LinoSyntaxError(message, -1)
|
|
207
|
+
if token.type == "lparen":
|
|
208
|
+
return _parse_link(cursor)
|
|
209
|
+
if token.type == "ref":
|
|
210
|
+
cursor.advance()
|
|
211
|
+
return _reference_node(token)
|
|
212
|
+
message = f"Unexpected '{token.type}'"
|
|
213
|
+
raise LinoSyntaxError(message, token.pos)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_link(cursor: _Cursor) -> Link:
|
|
217
|
+
"""Parse a link: ``(`` [ value ``:`` ] values... ``)``."""
|
|
218
|
+
cursor.advance() # consume '('
|
|
219
|
+
nxt = cursor.peek()
|
|
220
|
+
if nxt is not None and nxt.type == "rparen":
|
|
221
|
+
cursor.advance()
|
|
222
|
+
return Link(id=None, values=[])
|
|
223
|
+
first = _parse_value(cursor)
|
|
224
|
+
link_id: Node | None = None
|
|
225
|
+
values: list[Node] = []
|
|
226
|
+
nxt = cursor.peek()
|
|
227
|
+
if nxt is not None and nxt.type == "colon":
|
|
228
|
+
cursor.advance() # consume ':'
|
|
229
|
+
link_id = first
|
|
230
|
+
else:
|
|
231
|
+
values.append(first)
|
|
232
|
+
while True:
|
|
233
|
+
nxt = cursor.peek()
|
|
234
|
+
if nxt is None or nxt.type == "rparen":
|
|
235
|
+
break
|
|
236
|
+
values.append(_parse_value(cursor))
|
|
237
|
+
closing = cursor.peek()
|
|
238
|
+
if closing is None or closing.type != "rparen":
|
|
239
|
+
message = "Expected )"
|
|
240
|
+
raise LinoSyntaxError(message, closing.pos if closing is not None else -1)
|
|
241
|
+
cursor.advance() # consume ')'
|
|
242
|
+
return Link(id=link_id, values=values)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse(text: str) -> list[Node]:
|
|
246
|
+
"""Parse LiNo source text into a list of top-level AST nodes."""
|
|
247
|
+
if not isinstance(text, str):
|
|
248
|
+
message = "LiNo input must be a string"
|
|
249
|
+
raise LinoSyntaxError(message, -1)
|
|
250
|
+
cursor = _Cursor(tokens=tokenize(text))
|
|
251
|
+
values: list[Node] = []
|
|
252
|
+
while cursor.index < len(cursor.tokens):
|
|
253
|
+
values.append(_parse_value(cursor))
|
|
254
|
+
return values
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _quote_name(name: str) -> str:
|
|
258
|
+
"""Quote a name for output if it contains structure-significant characters."""
|
|
259
|
+
if name == "" or _QUOTE_REQUIRED.search(name):
|
|
260
|
+
escaped = name.replace("\\", "\\\\").replace('"', '\\"')
|
|
261
|
+
return f'"{escaped}"'
|
|
262
|
+
return name
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _serialize_ref(node: Ref) -> str:
|
|
266
|
+
"""Serialize a reference node to LiNo text."""
|
|
267
|
+
if node.kind == "variable":
|
|
268
|
+
return f"${node.value}"
|
|
269
|
+
if node.kind == "wildcard":
|
|
270
|
+
return "*"
|
|
271
|
+
if node.kind == "number":
|
|
272
|
+
return str(node.value)
|
|
273
|
+
return _quote_name(str(node.value))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def serialize(node: Node) -> str:
|
|
277
|
+
"""Serialize an AST node (reference or link) back to LiNo text."""
|
|
278
|
+
if isinstance(node, Ref):
|
|
279
|
+
return _serialize_ref(node)
|
|
280
|
+
body = " ".join(serialize(value) for value in node.values)
|
|
281
|
+
if node.id is not None:
|
|
282
|
+
link_id = serialize(node.id)
|
|
283
|
+
return f"({link_id}: {body})" if body else f"({link_id}:)"
|
|
284
|
+
return f"({body})"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def serialize_all(nodes: list[Node], joiner: str = "\n") -> str:
|
|
288
|
+
"""Serialize a list of top-level nodes to LiNo text, joined by ``joiner``."""
|
|
289
|
+
return joiner.join(serialize(node) for node in nodes)
|
linksql/names.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Named references.
|
|
2
|
+
|
|
3
|
+
Link indices are convenient for machines but opaque to humans. A :class:`Names`
|
|
4
|
+
registry maps human-readable labels to link identities (mirroring the sidecar
|
|
5
|
+
``<db>.names.links`` file used by link-cli) so a query can say
|
|
6
|
+
``(alice loves bob)`` instead of ``(7 9 8)``.
|
|
7
|
+
|
|
8
|
+
A freshly-named identity is materialised as a *point* — the link ``(i: i i)`` —
|
|
9
|
+
so that everything in the model remains a link. When ``auto_create`` is disabled,
|
|
10
|
+
referring to an unknown name is an error instead, which is useful for catching
|
|
11
|
+
typos in production schemas.
|
|
12
|
+
|
|
13
|
+
This module is a faithful, behaviourally-identical port of ``src/names.js`` from
|
|
14
|
+
the JavaScript reference implementation.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .store import LinksStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnknownNameError(Exception):
|
|
26
|
+
"""Error raised when an unknown name is used while auto-creation is off."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, name: str) -> None:
|
|
29
|
+
super().__init__(f"Unknown named reference: {name}")
|
|
30
|
+
self.name = name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Names:
|
|
34
|
+
"""A bidirectional registry of label <-> link-index associations."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, store: LinksStore, *, auto_create: bool = True) -> None:
|
|
37
|
+
self.store = store
|
|
38
|
+
self.auto_create = auto_create
|
|
39
|
+
self.by_name: dict[str, int] = {}
|
|
40
|
+
self.by_index: dict[int, str] = {}
|
|
41
|
+
|
|
42
|
+
def resolve(self, name: str) -> int | None:
|
|
43
|
+
"""Resolve a name to its index without creating anything."""
|
|
44
|
+
return self.by_name.get(name)
|
|
45
|
+
|
|
46
|
+
def ensure(self, name: str) -> int:
|
|
47
|
+
"""Resolve a name, allocating and materialising it when permitted."""
|
|
48
|
+
existing = self.by_name.get(name)
|
|
49
|
+
if existing is not None:
|
|
50
|
+
return existing
|
|
51
|
+
if not self.auto_create:
|
|
52
|
+
raise UnknownNameError(name)
|
|
53
|
+
index = self.store.allocate_index()
|
|
54
|
+
self.bind(name, index)
|
|
55
|
+
if not self.store.has(index):
|
|
56
|
+
self.store.create(index=index, source=index, target=index)
|
|
57
|
+
return index
|
|
58
|
+
|
|
59
|
+
def bind(self, name: str, index: int) -> int:
|
|
60
|
+
"""Associate a name with an existing index (no materialisation)."""
|
|
61
|
+
self.by_name[name] = index
|
|
62
|
+
self.by_index[index] = name
|
|
63
|
+
self.store.reserve_index(index)
|
|
64
|
+
return index
|
|
65
|
+
|
|
66
|
+
def name_of(self, index: int) -> str | None:
|
|
67
|
+
"""Look up the label associated with an index."""
|
|
68
|
+
return self.by_index.get(index)
|
|
69
|
+
|
|
70
|
+
def entries(self) -> list[tuple[str, int]]:
|
|
71
|
+
"""Return all ``[name, index]`` associations in insertion order."""
|
|
72
|
+
return list(self.by_name.items())
|
linksql/protocol.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""LinksQL wire protocol — Links Notation as the data transfer format.
|
|
2
|
+
|
|
3
|
+
The issue (and the PR review) is explicit: Links Notation, not JSON, is the
|
|
4
|
+
actual data protocol. Every structured value that crosses the network — query
|
|
5
|
+
reports, link lists, introspection snapshots — travels as Links Notation text.
|
|
6
|
+
Inside a process we still work with plain Python values (dataclasses, dicts,
|
|
7
|
+
lists, scalars), so this module is the single boundary that converts between the
|
|
8
|
+
two::
|
|
9
|
+
|
|
10
|
+
value --encode--> "((operation update) (created ()) ...)"
|
|
11
|
+
"(...)" --decode--> value
|
|
12
|
+
|
|
13
|
+
This is a faithful, behaviourally-identical port of the ``lino-objects-codec``
|
|
14
|
+
convention used by the JavaScript reference implementation (see ``js/src``):
|
|
15
|
+
|
|
16
|
+
- an object becomes a link of key/value pairs: ``((key value) (key value) ...)``
|
|
17
|
+
- an array becomes a link of its elements: ``(a b c)``
|
|
18
|
+
- an empty object or array becomes ``()``
|
|
19
|
+
- ``None`` becomes ``null``; numbers and booleans become their literal text
|
|
20
|
+
- strings are escaped only when they contain whitespace, quotes, parentheses,
|
|
21
|
+
colons or newlines
|
|
22
|
+
|
|
23
|
+
Decoding reuses the engine's own LiNo parser (:mod:`linksql.lino`) and then
|
|
24
|
+
applies the codec's "all-pairs means object, otherwise array" rule.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import dataclasses
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
from .lino import Link as LinkNode
|
|
33
|
+
from .lino import Node, Ref, parse
|
|
34
|
+
|
|
35
|
+
# The canonical content type for Links Notation payloads.
|
|
36
|
+
LINO_CONTENT_TYPE = "application/lino"
|
|
37
|
+
# The opt-in content type for the JSON projection of a payload.
|
|
38
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
39
|
+
|
|
40
|
+
# A reference needs escaping when it contains whitespace, quotes, parentheses,
|
|
41
|
+
# or a colon (mirrors the codec's ``/[\s()'":]/`` test plus an explicit newline).
|
|
42
|
+
_NEEDS_ESCAPING = re.compile(r"[\s()'\":]")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def escape_reference(value: object) -> str: # noqa: PLR0911
|
|
46
|
+
"""Escape a scalar value for use as a Links Notation reference.
|
|
47
|
+
|
|
48
|
+
Faithful port of ``escapeReference`` from ``lino-objects-codec``: numbers and
|
|
49
|
+
booleans pass through, and strings are wrapped only when they contain
|
|
50
|
+
structure-significant characters, preferring whichever quote minimises
|
|
51
|
+
internal escaping.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(value, bool):
|
|
54
|
+
return "true" if value else "false"
|
|
55
|
+
if isinstance(value, (int, float)):
|
|
56
|
+
return _number_to_text(value)
|
|
57
|
+
|
|
58
|
+
text = str(value)
|
|
59
|
+
if not (_NEEDS_ESCAPING.search(text) or "\n" in text):
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
has_single = "'" in text
|
|
63
|
+
has_double = '"' in text
|
|
64
|
+
if has_single and not has_double:
|
|
65
|
+
return f'"{text}"'
|
|
66
|
+
if has_double and not has_single:
|
|
67
|
+
return f"'{text}'"
|
|
68
|
+
if has_single and has_double:
|
|
69
|
+
single_count = text.count("'")
|
|
70
|
+
double_count = text.count('"')
|
|
71
|
+
if double_count < single_count:
|
|
72
|
+
return '"' + text.replace('"', '""') + '"'
|
|
73
|
+
return "'" + text.replace("'", "''") + "'"
|
|
74
|
+
# Just spaces or other special characters: single-quote by default.
|
|
75
|
+
return f"'{text}'"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _number_to_text(value: float) -> str:
|
|
79
|
+
"""Render a number the way JavaScript's ``String(value)`` would.
|
|
80
|
+
|
|
81
|
+
Integers (and integer-valued floats) print without a trailing ``.0`` so the
|
|
82
|
+
encoding matches the JavaScript reference byte-for-byte.
|
|
83
|
+
"""
|
|
84
|
+
if isinstance(value, bool): # defensive; bool handled by callers first
|
|
85
|
+
return "true" if value else "false"
|
|
86
|
+
if isinstance(value, int):
|
|
87
|
+
return str(value)
|
|
88
|
+
if value.is_integer():
|
|
89
|
+
return str(int(value))
|
|
90
|
+
return repr(value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _to_jsonable(value: object) -> object:
|
|
94
|
+
"""Project dataclasses (and nested ones) onto plain dict/list/scalar values.
|
|
95
|
+
|
|
96
|
+
``encode`` accepts the engine's report dataclasses directly; this keeps the
|
|
97
|
+
conversion boundary in one place while preserving field order.
|
|
98
|
+
"""
|
|
99
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
100
|
+
return {
|
|
101
|
+
field.name: _to_jsonable(getattr(value, field.name))
|
|
102
|
+
for field in dataclasses.fields(value)
|
|
103
|
+
}
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
return {key: _to_jsonable(item) for key, item in value.items()}
|
|
106
|
+
if isinstance(value, (list, tuple)):
|
|
107
|
+
return [_to_jsonable(item) for item in value]
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def encode(value: object) -> str:
|
|
112
|
+
"""Encode a Python value (or report dataclass) as Links Notation text."""
|
|
113
|
+
return _encode_jsonable(_to_jsonable(value))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _encode_jsonable(value: object) -> str: # noqa: PLR0911
|
|
117
|
+
"""Encode an already-projected JSON-shaped value as Links Notation."""
|
|
118
|
+
if value is None:
|
|
119
|
+
return "null"
|
|
120
|
+
if isinstance(value, bool):
|
|
121
|
+
return "true" if value else "false"
|
|
122
|
+
if isinstance(value, (int, float)):
|
|
123
|
+
return _number_to_text(value)
|
|
124
|
+
if isinstance(value, str):
|
|
125
|
+
return escape_reference(value)
|
|
126
|
+
if isinstance(value, list):
|
|
127
|
+
if not value:
|
|
128
|
+
return "()"
|
|
129
|
+
return "(" + " ".join(_encode_jsonable(item) for item in value) + ")"
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
if not value:
|
|
132
|
+
return "()"
|
|
133
|
+
pairs = [
|
|
134
|
+
f"({escape_reference(key)} {_encode_jsonable(item)})"
|
|
135
|
+
for key, item in value.items()
|
|
136
|
+
]
|
|
137
|
+
return "(" + " ".join(pairs) + ")"
|
|
138
|
+
# Fallback for unknown types, matching the codec.
|
|
139
|
+
return escape_reference(str(value))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_reference(ref: Ref) -> object:
|
|
143
|
+
"""Parse a reference node into its primitive value (true/false/null/num/str)."""
|
|
144
|
+
if ref.kind == "number":
|
|
145
|
+
return ref.value
|
|
146
|
+
text = str(ref.value)
|
|
147
|
+
if text == "true":
|
|
148
|
+
return True
|
|
149
|
+
if text == "false":
|
|
150
|
+
return False
|
|
151
|
+
if text == "null":
|
|
152
|
+
return None
|
|
153
|
+
if text.strip() != "":
|
|
154
|
+
try:
|
|
155
|
+
number = float(text)
|
|
156
|
+
except ValueError:
|
|
157
|
+
number = None
|
|
158
|
+
if number is not None:
|
|
159
|
+
return int(number) if number.is_integer() else number
|
|
160
|
+
return text
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _is_pair(child: Node) -> bool:
|
|
164
|
+
"""Whether a node is a key/value pair with a string-like key (object entry)."""
|
|
165
|
+
if not isinstance(child, LinkNode) or len(child.values) != 2:
|
|
166
|
+
return False
|
|
167
|
+
key = child.values[0]
|
|
168
|
+
if not isinstance(key, Ref):
|
|
169
|
+
return False
|
|
170
|
+
key_value = _parse_reference(key)
|
|
171
|
+
return not isinstance(key_value, (int, float))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _convert(node: Node) -> object:
|
|
175
|
+
"""Convert a parsed LiNo node into a JSON-shaped value (codec semantics)."""
|
|
176
|
+
if isinstance(node, Ref):
|
|
177
|
+
return _parse_reference(node)
|
|
178
|
+
# A link with no values is either the empty link () -> [] or a lone reference.
|
|
179
|
+
if not node.values:
|
|
180
|
+
if node.id is None:
|
|
181
|
+
return []
|
|
182
|
+
return (
|
|
183
|
+
_parse_reference(node.id) if isinstance(node.id, Ref) else _convert(node.id)
|
|
184
|
+
)
|
|
185
|
+
# All children are key/value pairs -> object; otherwise an array.
|
|
186
|
+
if all(_is_pair(child) for child in node.values):
|
|
187
|
+
result: dict[str, object] = {}
|
|
188
|
+
for child in node.values:
|
|
189
|
+
assert isinstance(child, LinkNode)
|
|
190
|
+
key = _parse_reference(child.values[0]) # type: ignore[arg-type]
|
|
191
|
+
result[str(key)] = _convert(child.values[1])
|
|
192
|
+
return result
|
|
193
|
+
return [_convert(value) for value in node.values]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def decode(lino: str) -> object:
|
|
197
|
+
"""Decode Links Notation text back into a JSON-shaped Python value."""
|
|
198
|
+
if not isinstance(lino, str) or not lino:
|
|
199
|
+
return None
|
|
200
|
+
nodes = parse(lino)
|
|
201
|
+
if not nodes:
|
|
202
|
+
return None
|
|
203
|
+
result = _convert(nodes[0])
|
|
204
|
+
# Unwrap a single primitive the parser wrapped in a one-element list.
|
|
205
|
+
if (
|
|
206
|
+
isinstance(result, list)
|
|
207
|
+
and len(result) == 1
|
|
208
|
+
and isinstance(result[0], (str, int, float, bool, type(None)))
|
|
209
|
+
):
|
|
210
|
+
return result[0]
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def prefers_json(header: str | None) -> bool:
|
|
215
|
+
"""Whether a caller's ``Accept``/``Content-Type`` header opts into JSON.
|
|
216
|
+
|
|
217
|
+
Links Notation is always the default; JSON is only used when a client asks
|
|
218
|
+
for it explicitly, and Links Notation wins when both are present.
|
|
219
|
+
"""
|
|
220
|
+
if not header:
|
|
221
|
+
return False
|
|
222
|
+
lower = header.lower()
|
|
223
|
+
if LINO_CONTENT_TYPE in lower:
|
|
224
|
+
return False
|
|
225
|
+
return "application/json" in lower or "text/json" in lower
|