links-notation 0.22.0__tar.gz → 0.23.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {links_notation-0.22.0/links_notation.egg-info → links_notation-0.23.0}/PKG-INFO +11 -11
  2. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/__init__.py +20 -0
  3. links_notation-0.23.0/links_notation/binary.py +637 -0
  4. {links_notation-0.22.0 → links_notation-0.23.0/links_notation.egg-info}/PKG-INFO +11 -11
  5. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation.egg-info/SOURCES.txt +2 -0
  6. links_notation-0.23.0/links_notation.egg-info/requires.txt +16 -0
  7. {links_notation-0.22.0 → links_notation-0.23.0}/pyproject.toml +8 -5
  8. links_notation-0.23.0/tests/test_binary.py +107 -0
  9. links_notation-0.22.0/links_notation.egg-info/requires.txt +0 -16
  10. {links_notation-0.22.0 → links_notation-0.23.0}/LICENSE +0 -0
  11. {links_notation-0.22.0 → links_notation-0.23.0}/MANIFEST.in +0 -0
  12. {links_notation-0.22.0 → links_notation-0.23.0}/README.md +0 -0
  13. {links_notation-0.22.0 → links_notation-0.23.0}/README.ru.md +0 -0
  14. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/comments.py +0 -0
  15. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/format_config.py +0 -0
  16. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/formatter.py +0 -0
  17. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/link.py +0 -0
  18. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/parser.py +0 -0
  19. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/quotes.py +0 -0
  20. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation/stream_parser.py +0 -0
  21. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation.egg-info/dependency_links.txt +0 -0
  22. {links_notation-0.22.0 → links_notation-0.23.0}/links_notation.egg-info/top_level.txt +0 -0
  23. {links_notation-0.22.0 → links_notation-0.23.0}/setup.cfg +0 -0
  24. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_api.py +0 -0
  25. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_comments.py +0 -0
  26. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_edge_case_parser.py +0 -0
  27. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_empty_reference.py +0 -0
  28. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_format_config.py +0 -0
  29. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_indentation_consistency.py +0 -0
  30. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_indented_id_nested_values.py +0 -0
  31. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_indented_id_syntax.py +0 -0
  32. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_link.py +0 -0
  33. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_links_group.py +0 -0
  34. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_mixed_indentation_modes.py +0 -0
  35. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_multi_quote_parser.py +0 -0
  36. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_multiline_parser.py +0 -0
  37. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_multiline_quoted_string.py +0 -0
  38. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_nested_indentation.py +0 -0
  39. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_nested_parser.py +0 -0
  40. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_nested_self_reference.py +0 -0
  41. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_nesting_limit.py +0 -0
  42. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_single_line_parser.py +0 -0
  43. {links_notation-0.22.0 → links_notation-0.23.0}/tests/test_stream_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: links-notation
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: Python implementation of the Links Notation parser
5
5
  Author-email: "Link.Foundation" <drakonard@gmail.com>
6
6
  License-Expression: Unlicense
@@ -22,18 +22,18 @@ Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Provides-Extra: test
25
- Requires-Dist: pytest>=9.1; extra == "test"
26
- Requires-Dist: pytest-timeout>=2.4; extra == "test"
25
+ Requires-Dist: pytest>=9.1.1; extra == "test"
26
+ Requires-Dist: pytest-timeout>=2.4.0; extra == "test"
27
27
  Provides-Extra: lint
28
- Requires-Dist: black>=26.5; extra == "lint"
29
- Requires-Dist: isort>=9.0; extra == "lint"
30
- Requires-Dist: flake8>=7.3; extra == "lint"
28
+ Requires-Dist: black>=26.10.0; extra == "lint"
29
+ Requires-Dist: isort>=9.0.2; extra == "lint"
30
+ Requires-Dist: flake8>=7.4.1; extra == "lint"
31
31
  Provides-Extra: dev
32
- Requires-Dist: pytest>=9.1; extra == "dev"
33
- Requires-Dist: pytest-timeout>=2.4; extra == "dev"
34
- Requires-Dist: black>=26.5; extra == "dev"
35
- Requires-Dist: isort>=9.0; extra == "dev"
36
- Requires-Dist: flake8>=7.3; extra == "dev"
32
+ Requires-Dist: pytest>=9.1.1; extra == "dev"
33
+ Requires-Dist: pytest-timeout>=2.4.0; extra == "dev"
34
+ Requires-Dist: black>=26.10.0; extra == "dev"
35
+ Requires-Dist: isort>=9.0.2; extra == "dev"
36
+ Requires-Dist: flake8>=7.4.1; extra == "dev"
37
37
  Dynamic: license-file
38
38
 
39
39
  # Links Notation Parser for Python
@@ -10,6 +10,17 @@ from importlib.metadata import PackageNotFoundError
10
10
  from importlib.metadata import version as _distribution_version
11
11
  from pathlib import Path
12
12
 
13
+ from .binary import (
14
+ ArityRange,
15
+ BinaryLinoCodec,
16
+ BinaryLinoOptions,
17
+ DecodeLimits,
18
+ External,
19
+ LinksPacket,
20
+ Section,
21
+ )
22
+ from .binary import format_document as format_binary_document
23
+ from .binary import format_reference as format_binary_reference
13
24
  from .comments import strip_comments
14
25
  from .format_config import FormatConfig
15
26
  from .formatter import format_links
@@ -47,6 +58,15 @@ def _read_version() -> str:
47
58
  __version__ = _read_version()
48
59
 
49
60
  __all__ = [
61
+ "ArityRange",
62
+ "BinaryLinoCodec",
63
+ "BinaryLinoOptions",
64
+ "DecodeLimits",
65
+ "External",
66
+ "LinksPacket",
67
+ "Section",
68
+ "format_binary_document",
69
+ "format_binary_reference",
50
70
  "DEFAULT_MAX_DEPTH",
51
71
  "Link",
52
72
  "ParseError",
@@ -0,0 +1,637 @@
1
+ """Binary links notation v1. See docs/protocol/binary-links-notation.md.
2
+
3
+ The packet layer is independent of the document mapping. Native Link models
4
+ are preserved; parse_document explicitly selects link-cli canonical groups.
5
+ All integers on the wire are unsigned 64-bit values.
6
+ """
7
+
8
+ import io
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional
12
+
13
+ from .link import Link
14
+ from .parser import Parser
15
+
16
+ U64 = (1 << 64) - 1
17
+ WIDTHS = (1, 2, 4, 8)
18
+
19
+
20
+ def _require(condition, message):
21
+ if not condition:
22
+ raise ValueError(message)
23
+
24
+
25
+ def _u64(value):
26
+ _require(isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= U64, "expected uint64")
27
+ return value
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class External:
32
+ """Hybrid external reference; bare integers denote internal addresses."""
33
+
34
+ value: int
35
+
36
+ def __post_init__(self):
37
+ _u64(self.value)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ArityRange:
42
+ min: int = 2
43
+ max: Optional[int] = 2
44
+
45
+ def __post_init__(self):
46
+ _require(1 <= _u64(self.min) <= U64 >> 4, "invalid minimum arity")
47
+ _require(self.max is None or _u64(self.max) >= self.min, "invalid maximum arity")
48
+
49
+ def contains(self, length):
50
+ return length >= self.min and (self.max is None or length <= self.max)
51
+
52
+ @property
53
+ def fixed(self):
54
+ return self.min == self.max
55
+
56
+ @classmethod
57
+ def parse(cls, text):
58
+ _require(re.fullmatch(r"[0-9]+(?:\.\.[0-9]*)?", text), "invalid arity")
59
+ parts = text.split("..")
60
+ return cls(int(parts[0]), int(parts[-1]) if parts[-1] else None)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class DecodeLimits:
65
+ """Work budgets applied to both byte/model input and encoding."""
66
+
67
+ max_links: int = 1 << 22
68
+ max_references: int = 1 << 24
69
+ max_nodes: int = 1 << 22
70
+ max_string_bytes: int = 64 << 20
71
+ max_depth: int = 64
72
+
73
+ def __post_init__(self):
74
+ for value in vars(self).values():
75
+ _u64(value)
76
+
77
+ @classmethod
78
+ def unlimited(cls):
79
+ """For trusted input; caller must provide adequate memory/stack."""
80
+ return cls(U64, U64, U64, U64, U64)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class BinaryLinoOptions:
85
+ external_references: bool = False
86
+ arity: ArityRange = field(default_factory=ArityRange)
87
+ packed_widths: bool = False
88
+
89
+ @classmethod
90
+ def of_packet(cls, packet):
91
+ lengths = [2] + [len(link) for _, link in packet.links()]
92
+ return cls(
93
+ packet.external_references,
94
+ ArityRange(min(lengths), max(lengths)),
95
+ len({section.width for section in packet.sections}) > 1,
96
+ )
97
+
98
+
99
+ @dataclass
100
+ class Section:
101
+ gap: int
102
+ arity: ArityRange
103
+ width: int
104
+ links: list
105
+
106
+
107
+ def _width(reference, external):
108
+ value = reference.value if isinstance(reference, External) else _u64(reference)
109
+ _require(not isinstance(reference, External) or external, "external reference disabled")
110
+ for width in WIDTHS:
111
+ bits = width * 8 - int(external or isinstance(reference, External))
112
+ if value < 1 << bits:
113
+ return width
114
+ raise ValueError("reference exceeds capacity")
115
+
116
+
117
+ def _leb(out, value):
118
+ value = _u64(value)
119
+ while value >= 128:
120
+ out.append((value & 127) | 128)
121
+ value >>= 7
122
+ out.append(value)
123
+
124
+
125
+ class _Reader:
126
+ def __init__(self, stream):
127
+ self.stream = stream
128
+
129
+ def raw(self, count):
130
+ chunks = bytearray()
131
+ while len(chunks) < count:
132
+ part = self.stream.read(count - len(chunks))
133
+ _require(part, "unexpected end of packet")
134
+ chunks.extend(part)
135
+ return int.from_bytes(chunks, "little")
136
+
137
+ def leb(self):
138
+ value = 0
139
+ for shift in range(0, 64, 7):
140
+ byte = self.raw(1)
141
+ payload = byte & 127
142
+ _require(shift != 63 or payload <= 1, "LEB128 overflow")
143
+ value |= payload << shift
144
+ if byte < 128:
145
+ return value
146
+ raise ValueError("LEB128 overflow")
147
+
148
+
149
+ @dataclass
150
+ class LinksPacket:
151
+ external_references: bool = False
152
+ sections: list = field(default_factory=list)
153
+
154
+ def links(self):
155
+ result, address = [], 1
156
+ for section in self.sections:
157
+ address = _u64(address + _u64(section.gap))
158
+ for link in section.links:
159
+ result.append((address, list(link)))
160
+ address = _u64(address + 1)
161
+ return result
162
+
163
+ def validate(self, limits=DecodeLimits()):
164
+ _require(len(self.sections) <= limits.max_links, "too many sections")
165
+ links = self.links()
166
+ _require(len(links) <= limits.max_links, "too many links")
167
+ references = 0
168
+ for section in self.sections:
169
+ _require(section.width in WIDTHS, "invalid reference width")
170
+ # Also validate mutable in-memory sections/packets.
171
+ ArityRange(section.arity.min, section.arity.max)
172
+ for link in section.links:
173
+ _require(section.arity.contains(len(link)), "link outside arity range")
174
+ references += len(link)
175
+ _require(references <= limits.max_references, "too many references")
176
+ for reference in link:
177
+ _require(_width(reference, self.external_references) <= section.width, "reference outside width")
178
+ return self
179
+
180
+ def to_bytes(self, limits=DecodeLimits()):
181
+ self.validate(limits)
182
+ compact = not self.sections or (
183
+ len(self.sections) == 1
184
+ and self.sections[0].gap == 5
185
+ and self.sections[0].arity == ArityRange()
186
+ and bool(self.sections[0].links)
187
+ )
188
+ out = bytearray([0x10 | int(self.external_references)])
189
+ if compact:
190
+ width = self.sections[0].width if self.sections else 1
191
+ out[0] |= WIDTHS.index(width) << 2
192
+ _leb(out, sum(len(s.links) for s in self.sections))
193
+ else:
194
+ out[0] |= 2
195
+ _leb(out, len(self.sections))
196
+ for section in self.sections:
197
+ _leb(
198
+ out,
199
+ section.arity.min << 4
200
+ | WIDTHS.index(section.width)
201
+ | (4 if section.gap else 0)
202
+ | (0 if section.arity.fixed else 8),
203
+ )
204
+ if section.gap:
205
+ _leb(out, section.gap)
206
+ if not section.arity.fixed:
207
+ _leb(out, 0 if section.arity.max is None else section.arity.max - section.arity.min)
208
+ _leb(out, len(section.links))
209
+ for section in self.sections:
210
+ for link in section.links:
211
+ if not section.arity.fixed:
212
+ _leb(out, len(link) - section.arity.min)
213
+ for reference in link:
214
+ if isinstance(reference, External):
215
+ value = (
216
+ (1 << (section.width * 8 - 1))
217
+ if reference.value == 0
218
+ else (1 << (section.width * 8)) - reference.value
219
+ )
220
+ else:
221
+ value = reference
222
+ out.extend(value.to_bytes(section.width, "little"))
223
+ return bytes(out)
224
+
225
+ def write_to(self, stream, limits=DecodeLimits()):
226
+ data = self.to_bytes(limits)
227
+ offset = 0
228
+ while offset < len(data):
229
+ written = stream.write(data[offset:])
230
+ _require(written is not None and written > 0, "stream did not write data")
231
+ offset += written
232
+
233
+ @classmethod
234
+ def read_from(cls, stream, limits=DecodeLimits()):
235
+ """Read exactly one packet; None only at a clean end of stream."""
236
+ first = stream.read(1)
237
+ if not first:
238
+ return None
239
+ header = first[0]
240
+ _require(header & 0xF0 == 0x10, "unsupported binary version")
241
+ reader, sections, counts = _Reader(stream), [], []
242
+ if header & 2:
243
+ _require(header & 12 == 0, "explicit header width bits set")
244
+ count = reader.leb()
245
+ _require(count <= limits.max_links, "too many sections")
246
+ address, total = 1, 0
247
+ for _ in range(count):
248
+ shape = reader.leb()
249
+ gap = reader.leb() if shape & 4 else 0
250
+ minimum = shape >> 4
251
+ extra = reader.leb() if shape & 8 else None
252
+ maximum = minimum if extra is None else (None if extra == 0 else _u64(minimum + extra))
253
+ arity = ArityRange(minimum, maximum)
254
+ count = reader.leb()
255
+ address = _u64(address + gap + count)
256
+ total += count
257
+ _require(total <= limits.max_links, "too many links")
258
+ sections.append(Section(gap, arity, WIDTHS[shape & 3], []))
259
+ counts.append(count)
260
+ else:
261
+ count = reader.leb()
262
+ _u64(6 + count)
263
+ _require(count <= limits.max_links, "too many links")
264
+ if count:
265
+ sections.append(Section(5, ArityRange(), WIDTHS[(header >> 2) & 3], []))
266
+ counts.append(count)
267
+ references = 0
268
+ for section, count in zip(sections, counts):
269
+ for _ in range(count):
270
+ length = section.arity.min + (0 if section.arity.fixed else reader.leb())
271
+ _require(section.arity.contains(_u64(length)), "link outside arity range")
272
+ references += length
273
+ _require(references <= limits.max_references, "too many references")
274
+ link = []
275
+ for _ in range(length):
276
+ raw = reader.raw(section.width)
277
+ top = 1 << (section.width * 8 - 1)
278
+ link.append(
279
+ External(0 if raw == top else (1 << (section.width * 8)) - raw)
280
+ if header & 1 and raw >= top
281
+ else raw
282
+ )
283
+ section.links.append(link)
284
+ return cls(bool(header & 1), sections)
285
+
286
+ @classmethod
287
+ def from_bytes(cls, data, limits=DecodeLimits()):
288
+ stream = io.BytesIO(data)
289
+ packet = cls.read_from(stream, limits)
290
+ _require(packet is not None, "empty input")
291
+ _require(not stream.read(1), "trailing bytes")
292
+ return packet
293
+
294
+ @classmethod
295
+ def parse_links(cls, text):
296
+ links = []
297
+ for entry in text.split(";"):
298
+ if not entry.strip():
299
+ continue
300
+ address, refs = entry.split(":")
301
+ links.append(
302
+ (
303
+ _u64(int(address)),
304
+ [External(int(r[1:])) if r.startswith("#") else _u64(int(r)) for r in refs.split()],
305
+ )
306
+ )
307
+ return links
308
+
309
+ @classmethod
310
+ def pack(cls, external_references, links, packed_widths=False):
311
+ links = list(links)
312
+ needs, previous = [], 0
313
+ for address, refs in links:
314
+ _require(previous < _u64(address) < U64, "addresses must ascend and leave space for end")
315
+ _require(refs, "empty raw link")
316
+ previous = address
317
+ needs.append(max(_width(r, external_references) for r in refs))
318
+
319
+ def layout(packed):
320
+ if not links:
321
+ return []
322
+ widest, costs, opens, bests = max(needs), [float("inf")] * 8, [], []
323
+ for index, (address, refs) in enumerate(links):
324
+ best = min(range(8), key=lambda s: costs[s])
325
+ bests.append(best)
326
+ before = costs[best] if index else 0
327
+ next_costs, mask = [float("inf")] * 8, 0
328
+ for state in range(8):
329
+ width, variable = WIDTHS[state // 2], state % 2
330
+ if (not packed and width != widest) or width < needs[index]:
331
+ continue
332
+ opening = before + 2 + variable
333
+ continuing = (
334
+ costs[state]
335
+ if index
336
+ and links[index - 1][0] + 1 == address
337
+ and (variable or len(links[index - 1][1]) == len(refs))
338
+ else float("inf")
339
+ )
340
+ if continuing <= opening:
341
+ next_costs[state] = continuing + len(refs) * width + variable
342
+ else:
343
+ next_costs[state] = opening + len(refs) * width + variable
344
+ mask |= 1 << state
345
+ opens.append(mask)
346
+ costs = next_costs
347
+ result, end, state = [], len(links), min(range(8), key=lambda s: costs[s])
348
+ for index in range(len(links) - 1, -1, -1):
349
+ if opens[index] & (1 << state):
350
+ result.append((end - index, WIDTHS[state // 2]))
351
+ end, state = index, bests[index]
352
+ return list(reversed(result))
353
+
354
+ def packet(plan):
355
+ sections, index, address = [], 0, 1
356
+ for count, width in plan:
357
+ members = links[index : index + count]
358
+ lengths = [len(refs) for _, refs in members]
359
+ start = members[0][0]
360
+ sections.append(
361
+ Section(
362
+ start - address,
363
+ ArityRange(min(lengths), max(lengths)),
364
+ width,
365
+ [list(refs) for _, refs in members],
366
+ )
367
+ )
368
+ index, address = index + count, start + count
369
+ return cls(external_references, sections)
370
+
371
+ uniform = packet(layout(False))
372
+ if not packed_widths:
373
+ return uniform
374
+ packed = packet(layout(True))
375
+ return (
376
+ packed
377
+ if len(packed.to_bytes(DecodeLimits.unlimited())) < len(uniform.to_bytes(DecodeLimits.unlimited()))
378
+ else uniform
379
+ )
380
+
381
+
382
+ class _Encoder:
383
+ def __init__(self, options):
384
+ self.options = options
385
+ self.doublets, self.tuples, self.created = [], [], {}
386
+ self.powers = [("i", 1)]
387
+
388
+ def link(self, items):
389
+ key = tuple(items)
390
+ if key not in self.created:
391
+ doublet = len(items) == 2 and all(kind != "t" for kind, _ in items)
392
+ target = self.doublets if doublet else self.tuples
393
+ node = ("d" if doublet else "t", len(target))
394
+ target.append(list(items))
395
+ self.created[key] = node
396
+ return self.created[key]
397
+
398
+ def chain(self, items):
399
+ tail = ("i", 0)
400
+ for head in reversed(items):
401
+ tail = self.link([head, tail])
402
+ return tail
403
+
404
+ def typed(self, marker, elements):
405
+ items = [("i", marker)] + elements
406
+ return (
407
+ self.link(items)
408
+ if len(items) != 2 and self.options.arity.contains(len(items))
409
+ else self.link([("i", marker), self.chain(elements)])
410
+ )
411
+
412
+ def list(self, elements):
413
+ if not elements:
414
+ return ("i", 0)
415
+ if len(elements) == 2 or self.options.arity.contains(len(elements)):
416
+ return self.link(elements)
417
+ return self.typed(4, elements)
418
+
419
+ def unary(self, value):
420
+ powers = []
421
+ for bit in range(63, -1, -1):
422
+ if value & (1 << bit):
423
+ while len(self.powers) <= bit:
424
+ self.powers.append(self.link([self.powers[-1], self.powers[-1]]))
425
+ powers.append(self.powers[bit])
426
+ if not powers:
427
+ return ("i", 0)
428
+ result = powers[-1]
429
+ for power in reversed(powers[:-1]):
430
+ result = self.link([power, result])
431
+ return result
432
+
433
+ def scalar(self, value):
434
+ return ("e", value) if self.options.external_references and value < 1 << 63 else self.unary(value)
435
+
436
+ def reference(self, text):
437
+ if re.fullmatch(r"0|[1-9][0-9]*", text) and len(text) <= 20 and int(text) <= U64:
438
+ value = int(text)
439
+ if self.options.external_references and value < 1 << 63:
440
+ return ("e", value)
441
+ return self.link([("i", 2), self.unary(value)])
442
+ return self.typed(3, [self.scalar(ord(c)) for c in text])
443
+
444
+ def encode_identified(self, node):
445
+ if node.id is not None and node.values:
446
+ identifier = self.reference(node.id)
447
+ return self.typed(5, [identifier] + [self.encode_identified(v) for v in node.values])
448
+ if node.id is not None:
449
+ return self.reference(node.id)
450
+ return self.list([self.encode_identified(v) for v in node.values])
451
+
452
+ def finish(self):
453
+ def resolve(node):
454
+ kind, value = node
455
+ return (
456
+ External(value)
457
+ if kind == "e"
458
+ else (value if kind == "i" else 6 + value + (len(self.doublets) if kind == "t" else 0))
459
+ )
460
+
461
+ links = [(6 + i, [resolve(n) for n in nodes]) for i, nodes in enumerate(self.doublets + self.tuples)]
462
+ return LinksPacket.pack(self.options.external_references, links, self.options.packed_widths)
463
+
464
+
465
+ class _Decoder:
466
+ def __init__(self, packet, limits):
467
+ packet.validate(limits)
468
+ self.limits = limits
469
+ self.links, self.unary = [], []
470
+ self.nodes, self.strings = limits.max_nodes, limits.max_string_bytes
471
+ for address, refs in packet.links():
472
+ _require(address == 6 + len(self.links), "document links must be contiguous from 6")
473
+ _require(all(isinstance(r, External) or r < address for r in refs), "document contains forward reference")
474
+
475
+ def value(r):
476
+ if isinstance(r, External):
477
+ return None
478
+ return r if r <= 1 else (self.unary[r - 6] if r >= 6 else None)
479
+
480
+ a, b = (value(refs[0]), value(refs[1])) if len(refs) == 2 else (None, None)
481
+ self.unary.append(a + b if a is not None and b is not None and a + b <= U64 else None)
482
+ self.links.append(refs)
483
+
484
+ def chain(self, tail):
485
+ result = []
486
+ while tail != 0:
487
+ _require(isinstance(tail, int) and tail >= 6, "broken element chain")
488
+ items = self.links[tail - 6]
489
+ _require(len(items) == 2, "broken element chain")
490
+ _require(len(result) < self.limits.max_nodes, "chain too long")
491
+ result.append(items[0])
492
+ tail = items[1]
493
+ return result
494
+
495
+ def number(self, reference):
496
+ if isinstance(reference, External):
497
+ return reference.value
498
+ if reference <= 1:
499
+ return reference
500
+ value = self.unary[reference - 6] if reference >= 6 else None
501
+ _require(value is not None, "expected unary number")
502
+ return value
503
+
504
+ def text(self, text):
505
+ self.strings -= len(text.encode("utf-8"))
506
+ _require(self.strings >= 0, "too many expanded string bytes")
507
+ return Link(text)
508
+
509
+ def decode(self, reference, depth):
510
+ _require(depth < self.limits.max_depth, "nesting limit exceeded")
511
+ self.nodes -= 1
512
+ _require(self.nodes >= 0, "too many expanded nodes")
513
+ if isinstance(reference, External):
514
+ return self.text(str(reference.value))
515
+ if reference == 0:
516
+ return Link()
517
+ _require(reference >= 6, "standalone marker")
518
+ items = self.links[reference - 6]
519
+ if isinstance(items[0], int) and 1 <= items[0] <= 5:
520
+ marker = items[0]
521
+ elements = items[1:] if len(items) != 2 or marker == 2 else self.chain(items[1])
522
+ if marker == 2:
523
+ _require(len(elements) == 1, "number needs one value")
524
+ return self.text(str(self.number(elements[0])))
525
+ if marker == 3:
526
+ chars, byte_count = [], 0
527
+ for e in elements:
528
+ p = self.number(e)
529
+ _require(p <= 0x10FFFF and not 0xD800 <= p <= 0xDFFF, "invalid Unicode scalar")
530
+ size = 1 if p <= 0x7F else 2 if p <= 0x7FF else 3 if p <= 0xFFFF else 4
531
+ _require(size <= self.strings - byte_count, "too many expanded string bytes")
532
+ byte_count += size
533
+ chars.append(chr(p))
534
+ return self.text("".join(chars))
535
+ if marker == 5:
536
+ _require(elements, "identified link needs id")
537
+ identifier = self.decode(elements[0], depth)
538
+ _require(identifier.id is not None and not identifier.values, "id must be reference")
539
+ return Link(identifier.id, [self.decode(e, depth + 1) for e in elements[1:]])
540
+ _require(marker == 4, "invalid typed marker")
541
+ else:
542
+ elements = items
543
+ return Link(None, [self.decode(e, depth + 1) for e in elements])
544
+
545
+ def document(self):
546
+ if not self.links:
547
+ return []
548
+ root = self.links[-1]
549
+ if len(root) == 2 and root[0] == 4:
550
+ elements = self.chain(root[1])
551
+ else:
552
+ _require(not (isinstance(root[0], int) and 1 <= root[0] <= 5), "root must be list")
553
+ elements = root
554
+ return [self.decode(e, 0) for e in elements]
555
+
556
+
557
+ def _canonical(node):
558
+ if node.id is None and len(node.values) == 1 and node.values[0].id is not None and not node.values[0].values:
559
+ return node.values[0]
560
+ return Link(node.id, [_canonical(v) for v in node.values])
561
+
562
+
563
+ def format_reference(text):
564
+ if text and not text.startswith("#") and not any(c.isspace() or c in "\ufeff():\"'`" for c in text):
565
+ return text
566
+ choices = []
567
+ for quote in "'\"`":
568
+ if text.startswith(quote):
569
+ continue
570
+ longest = max([len(m.group()) for m in re.finditer(re.escape(quote) + "+", text)] or [0])
571
+ choices.append(((longest + 1) | 1, quote))
572
+ count, quote = min(choices, key=lambda item: item[0])
573
+ delimiter = quote * count
574
+ return delimiter + text + delimiter
575
+
576
+
577
+ def format_document(document):
578
+ def nested(node, top=False):
579
+ if node.id is not None and not node.values:
580
+ return format_reference(node.id)
581
+ values = " ".join(nested(v) for v in node.values)
582
+ if node.id is not None:
583
+ return "(" + format_reference(node.id) + ": " + values + ")"
584
+ if len(node.values) == 1 and node.values[0].id is not None and not node.values[0].values:
585
+ return "((" + values + "))"
586
+ return values if top and len(node.values) >= 2 else "(" + values + ")"
587
+
588
+ return "\n".join(nested(n, True) for n in document)
589
+
590
+
591
+ class BinaryLinoCodec:
592
+ def __init__(self, options=None, limits=None):
593
+ self.options = options or BinaryLinoOptions()
594
+ self.limits = limits or DecodeLimits()
595
+
596
+ def encode_packet(self, document):
597
+ _require(self.options.arity.contains(2), "arity must contain 2")
598
+ pending = [(node, 0) for node in document]
599
+ nodes, strings = 0, 0
600
+ while pending:
601
+ node, depth = pending.pop()
602
+ _require(isinstance(node, Link), "expected Link model")
603
+ _require(depth < self.limits.max_depth, "nesting limit exceeded")
604
+ nodes += 1 + int(node.id is not None and bool(node.values))
605
+ if node.id is not None:
606
+ _require(isinstance(node.id, str), "reference must be string")
607
+ strings += len(node.id.encode("utf-8"))
608
+ _require(nodes <= self.limits.max_nodes, "too many nodes")
609
+ _require(strings <= self.limits.max_string_bytes, "too many string bytes")
610
+ pending.extend((v, depth + 1) for v in node.values)
611
+ encoder = _Encoder(self.options)
612
+ if document:
613
+ encoder.list([encoder.encode_identified(node) for node in document])
614
+ return encoder.finish().validate(self.limits)
615
+
616
+ def encode(self, document):
617
+ return self.encode_packet(document).to_bytes(self.limits)
618
+
619
+ def decode_packet(self, packet):
620
+ return _Decoder(packet, self.limits).document()
621
+
622
+ def decode(self, data):
623
+ return self.decode_packet(LinksPacket.from_bytes(data, self.limits))
624
+
625
+ @staticmethod
626
+ def parse_document(text, parser=None):
627
+ return [_canonical(n) for n in (parser or Parser()).parse(text)]
628
+
629
+ @staticmethod
630
+ def format_document(document):
631
+ return format_document(document)
632
+
633
+ def encode_text(self, text, parser=None):
634
+ return self.encode(self.parse_document(text, parser))
635
+
636
+ def decode_text(self, data):
637
+ return format_document(self.decode(data))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: links-notation
3
- Version: 0.22.0
3
+ Version: 0.23.0
4
4
  Summary: Python implementation of the Links Notation parser
5
5
  Author-email: "Link.Foundation" <drakonard@gmail.com>
6
6
  License-Expression: Unlicense
@@ -22,18 +22,18 @@ Requires-Python: >=3.9
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Provides-Extra: test
25
- Requires-Dist: pytest>=9.1; extra == "test"
26
- Requires-Dist: pytest-timeout>=2.4; extra == "test"
25
+ Requires-Dist: pytest>=9.1.1; extra == "test"
26
+ Requires-Dist: pytest-timeout>=2.4.0; extra == "test"
27
27
  Provides-Extra: lint
28
- Requires-Dist: black>=26.5; extra == "lint"
29
- Requires-Dist: isort>=9.0; extra == "lint"
30
- Requires-Dist: flake8>=7.3; extra == "lint"
28
+ Requires-Dist: black>=26.10.0; extra == "lint"
29
+ Requires-Dist: isort>=9.0.2; extra == "lint"
30
+ Requires-Dist: flake8>=7.4.1; extra == "lint"
31
31
  Provides-Extra: dev
32
- Requires-Dist: pytest>=9.1; extra == "dev"
33
- Requires-Dist: pytest-timeout>=2.4; extra == "dev"
34
- Requires-Dist: black>=26.5; extra == "dev"
35
- Requires-Dist: isort>=9.0; extra == "dev"
36
- Requires-Dist: flake8>=7.3; extra == "dev"
32
+ Requires-Dist: pytest>=9.1.1; extra == "dev"
33
+ Requires-Dist: pytest-timeout>=2.4.0; extra == "dev"
34
+ Requires-Dist: black>=26.10.0; extra == "dev"
35
+ Requires-Dist: isort>=9.0.2; extra == "dev"
36
+ Requires-Dist: flake8>=7.4.1; extra == "dev"
37
37
  Dynamic: license-file
38
38
 
39
39
  # Links Notation Parser for Python
@@ -5,6 +5,7 @@ README.ru.md
5
5
  pyproject.toml
6
6
  setup.cfg
7
7
  links_notation/__init__.py
8
+ links_notation/binary.py
8
9
  links_notation/comments.py
9
10
  links_notation/format_config.py
10
11
  links_notation/formatter.py
@@ -18,6 +19,7 @@ links_notation.egg-info/dependency_links.txt
18
19
  links_notation.egg-info/requires.txt
19
20
  links_notation.egg-info/top_level.txt
20
21
  tests/test_api.py
22
+ tests/test_binary.py
21
23
  tests/test_comments.py
22
24
  tests/test_edge_case_parser.py
23
25
  tests/test_empty_reference.py
@@ -0,0 +1,16 @@
1
+
2
+ [dev]
3
+ pytest>=9.1.1
4
+ pytest-timeout>=2.4.0
5
+ black>=26.10.0
6
+ isort>=9.0.2
7
+ flake8>=7.4.1
8
+
9
+ [lint]
10
+ black>=26.10.0
11
+ isort>=9.0.2
12
+ flake8>=7.4.1
13
+
14
+ [test]
15
+ pytest>=9.1.1
16
+ pytest-timeout>=2.4.0
@@ -1,10 +1,13 @@
1
1
  [build-system]
2
- requires = ["setuptools>=77.0"]
2
+ requires = [
3
+ "setuptools>=84.0.0; python_version >= '3.10'",
4
+ "setuptools>=82.0.1; python_version < '3.10'",
5
+ ]
3
6
  build-backend = "setuptools.build_meta"
4
7
 
5
8
  [project]
6
9
  name = "links-notation"
7
- version = "0.22.0"
10
+ version = "0.23.0"
8
11
  description = "Python implementation of the Links Notation parser"
9
12
  readme = "README.md"
10
13
  license = "Unlicense"
@@ -29,9 +32,9 @@ requires-python = ">=3.9"
29
32
  dependencies = []
30
33
 
31
34
  [project.optional-dependencies]
32
- test = ["pytest>=9.1", "pytest-timeout>=2.4"]
33
- lint = ["black>=26.5", "isort>=9.0", "flake8>=7.3"]
34
- dev = ["pytest>=9.1", "pytest-timeout>=2.4", "black>=26.5", "isort>=9.0", "flake8>=7.3"]
35
+ test = ["pytest>=9.1.1", "pytest-timeout>=2.4.0"]
36
+ lint = ["black>=26.10.0", "isort>=9.0.2", "flake8>=7.4.1"]
37
+ dev = ["pytest>=9.1.1", "pytest-timeout>=2.4.0", "black>=26.10.0", "isort>=9.0.2", "flake8>=7.4.1"]
35
38
 
36
39
  [project.urls]
37
40
  Homepage = "https://github.com/link-foundation/links-notation"
@@ -0,0 +1,107 @@
1
+ """Shared binary vectors and native text-model parity (issue 325)."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from links_notation import Link, Parser
8
+ from links_notation.binary import ArityRange, BinaryLinoCodec, BinaryLinoOptions, DecodeLimits, LinksPacket
9
+
10
+
11
+ def test_string_budget_stops_decoding_before_later_scalars():
12
+ data = bytes.fromhex("13024605011001030000009fffffff9effffff0028ffff06")
13
+ with pytest.raises(ValueError, match="string bytes"):
14
+ BinaryLinoCodec(limits=DecodeLimits(max_string_bytes=1)).decode(data)
15
+
16
+
17
+ def test_shared_vectors():
18
+ count = 0
19
+ for line in (
20
+ (Path(__file__).parents[2] / "docs/protocol/binary-links-notation-vectors.txt").read_text().splitlines()
21
+ ):
22
+ if not line or line.startswith("#"):
23
+ continue
24
+ fields = line.split("\t")
25
+ expected = bytes.fromhex(fields[-1])
26
+ if fields[0] == "document":
27
+ codec = BinaryLinoCodec(
28
+ BinaryLinoOptions(fields[2] == "external", ArityRange.parse(fields[3]), fields[4] == "packed")
29
+ )
30
+ document = codec.parse_document(fields[1].replace("\\n", "\n"))
31
+ assert codec.encode(document) == expected, line
32
+ assert codec.decode(expected) == document, line
33
+ else:
34
+ links = LinksPacket.parse_links(fields[1])
35
+ packet = LinksPacket.pack(fields[2] == "external", links, fields[3] == "packed")
36
+ assert packet.to_bytes() == expected, line
37
+ assert LinksPacket.from_bytes(expected).links() == links, line
38
+ count += 1
39
+ assert count == 96
40
+
41
+
42
+ @pytest.mark.parametrize(
43
+ "source",
44
+ [
45
+ "",
46
+ "()",
47
+ '""',
48
+ "a",
49
+ "(a)",
50
+ "((a))",
51
+ "(a b c)",
52
+ '("": () "" (b))',
53
+ "a:\n b\n c",
54
+ '"#x" "λ😀" "007" "18446744073709551615"',
55
+ "# comment\n(a # tail\n b)",
56
+ ],
57
+ )
58
+ def test_native_text_model_round_trip(source):
59
+ document = Parser().parse(source)
60
+ for external in (False, True):
61
+ for arity in ("2", "2..3", "1.."):
62
+ for packed in (False, True):
63
+ codec = BinaryLinoCodec(BinaryLinoOptions(external, ArityRange.parse(arity), packed))
64
+ assert codec.decode(codec.encode(document)) == document
65
+
66
+
67
+ def test_limits_and_malformed_packets():
68
+ document = [Link(None, [Link("abcdef")])]
69
+ bytes_ = BinaryLinoCodec().encode(document)
70
+ for limits in (
71
+ DecodeLimits(max_nodes=1),
72
+ DecodeLimits(max_string_bytes=2),
73
+ DecodeLimits(max_depth=1),
74
+ DecodeLimits(max_links=1),
75
+ DecodeLimits(max_references=1),
76
+ ):
77
+ with pytest.raises(ValueError):
78
+ BinaryLinoCodec(limits=limits).decode(bytes_)
79
+ with pytest.raises(ValueError):
80
+ BinaryLinoCodec(limits=limits).encode(document)
81
+ for bytes_ in (b"", b"\x20", b"\x1e\x00", b"\x10\x01", b"\x10\x00\x00", b"\x10" + b"\xff" * 10):
82
+ with pytest.raises(ValueError):
83
+ BinaryLinoCodec().decode(bytes_)
84
+
85
+
86
+ def test_packet_stream_boundaries_uint64_and_every_truncated_prefix():
87
+ from io import BytesIO
88
+
89
+ for external, references in [(False, "0 18446744073709551615"), (True, "#0 #9223372036854775807")]:
90
+ links = LinksPacket.parse_links("6:" + references)
91
+ packet = LinksPacket.pack(external, links, True)
92
+ data = packet.to_bytes()
93
+ stream = BytesIO(data + data)
94
+ assert LinksPacket.read_from(stream).links() == links
95
+ assert stream.tell() == len(data)
96
+ assert LinksPacket.read_from(stream).links() == links
97
+ assert LinksPacket.read_from(stream) is None
98
+ for end in range(len(data)):
99
+ with pytest.raises(ValueError):
100
+ LinksPacket.from_bytes(data[:end])
101
+ document = [Link("leaf")]
102
+ for _ in range(70):
103
+ document = [Link(None, document)]
104
+ with pytest.raises(ValueError):
105
+ BinaryLinoCodec().encode(document)
106
+ codec = BinaryLinoCodec(limits=DecodeLimits(max_depth=80))
107
+ assert codec.decode(codec.encode(document)) == document
@@ -1,16 +0,0 @@
1
-
2
- [dev]
3
- pytest>=9.1
4
- pytest-timeout>=2.4
5
- black>=26.5
6
- isort>=9.0
7
- flake8>=7.3
8
-
9
- [lint]
10
- black>=26.5
11
- isort>=9.0
12
- flake8>=7.3
13
-
14
- [test]
15
- pytest>=9.1
16
- pytest-timeout>=2.4
File without changes