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 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