links-notation 0.22.0__tar.gz → 0.24.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 (45) hide show
  1. {links_notation-0.22.0/links_notation.egg-info → links_notation-0.24.0}/PKG-INFO +15 -12
  2. {links_notation-0.22.0 → links_notation-0.24.0}/README.md +4 -1
  3. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/__init__.py +23 -0
  4. links_notation-0.24.0/links_notation/binary.py +624 -0
  5. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/link.py +3 -18
  6. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/parser.py +5 -1
  7. links_notation-0.24.0/links_notation/reference_literal.py +38 -0
  8. {links_notation-0.22.0 → links_notation-0.24.0/links_notation.egg-info}/PKG-INFO +15 -12
  9. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation.egg-info/SOURCES.txt +4 -0
  10. links_notation-0.24.0/links_notation.egg-info/requires.txt +16 -0
  11. {links_notation-0.22.0 → links_notation-0.24.0}/pyproject.toml +8 -5
  12. links_notation-0.24.0/tests/test_binary.py +107 -0
  13. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_empty_reference.py +2 -2
  14. links_notation-0.24.0/tests/test_reference_fidelity.py +73 -0
  15. links_notation-0.22.0/links_notation.egg-info/requires.txt +0 -16
  16. {links_notation-0.22.0 → links_notation-0.24.0}/LICENSE +0 -0
  17. {links_notation-0.22.0 → links_notation-0.24.0}/MANIFEST.in +0 -0
  18. {links_notation-0.22.0 → links_notation-0.24.0}/README.ru.md +0 -0
  19. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/comments.py +0 -0
  20. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/format_config.py +0 -0
  21. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/formatter.py +0 -0
  22. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/quotes.py +0 -0
  23. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation/stream_parser.py +0 -0
  24. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation.egg-info/dependency_links.txt +0 -0
  25. {links_notation-0.22.0 → links_notation-0.24.0}/links_notation.egg-info/top_level.txt +0 -0
  26. {links_notation-0.22.0 → links_notation-0.24.0}/setup.cfg +0 -0
  27. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_api.py +0 -0
  28. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_comments.py +0 -0
  29. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_edge_case_parser.py +0 -0
  30. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_format_config.py +0 -0
  31. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_indentation_consistency.py +0 -0
  32. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_indented_id_nested_values.py +0 -0
  33. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_indented_id_syntax.py +0 -0
  34. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_link.py +0 -0
  35. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_links_group.py +0 -0
  36. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_mixed_indentation_modes.py +0 -0
  37. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_multi_quote_parser.py +0 -0
  38. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_multiline_parser.py +0 -0
  39. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_multiline_quoted_string.py +0 -0
  40. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_nested_indentation.py +0 -0
  41. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_nested_parser.py +0 -0
  42. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_nested_self_reference.py +0 -0
  43. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_nesting_limit.py +0 -0
  44. {links_notation-0.22.0 → links_notation-0.24.0}/tests/test_single_line_parser.py +0 -0
  45. {links_notation-0.22.0 → links_notation-0.24.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.24.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
@@ -306,7 +306,7 @@ python -m build
306
306
 
307
307
  This project uses [Black](https://github.com/psf/black) for code formatting,
308
308
  [isort](https://pycqa.github.io/isort/) for import sorting, and
309
- [flake8](https://flake8.pycqa.org/) for linting.
309
+ [flake8](https://flake8.pycqa.org/en/latest/) for linting.
310
310
 
311
311
  Install linting tools:
312
312
 
@@ -354,3 +354,6 @@ This project is released into the public domain under the [Unlicense](../LICENSE
354
354
  - [Main Repository](https://github.com/link-foundation/links-notation)
355
355
  - [PyPI Package](https://pypi.org/project/links-notation/)
356
356
  - [Documentation](https://link-foundation.github.io/links-notation/)
357
+
358
+ Exact Unicode identifiers and values round-trip with the [reference literal contract](../docs/protocol/reference-literals.md)
359
+ introduced in 0.24.0, including mixed quotes, empty strings, controls and multiline text.
@@ -268,7 +268,7 @@ python -m build
268
268
 
269
269
  This project uses [Black](https://github.com/psf/black) for code formatting,
270
270
  [isort](https://pycqa.github.io/isort/) for import sorting, and
271
- [flake8](https://flake8.pycqa.org/) for linting.
271
+ [flake8](https://flake8.pycqa.org/en/latest/) for linting.
272
272
 
273
273
  Install linting tools:
274
274
 
@@ -316,3 +316,6 @@ This project is released into the public domain under the [Unlicense](../LICENSE
316
316
  - [Main Repository](https://github.com/link-foundation/links-notation)
317
317
  - [PyPI Package](https://pypi.org/project/links-notation/)
318
318
  - [Documentation](https://link-foundation.github.io/links-notation/)
319
+
320
+ Exact Unicode identifiers and values round-trip with the [reference literal contract](../docs/protocol/reference-literals.md)
321
+ introduced in 0.24.0, including mixed quotes, empty strings, controls and multiline text.
@@ -10,11 +10,23 @@ 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
16
27
  from .link import Link
17
28
  from .parser import DEFAULT_MAX_DEPTH, ParseError, Parser
29
+ from .reference_literal import decode_reference_literal, encode_reference_literal
18
30
  from .stream_parser import StreamParseError, StreamParser, StreamPosition, parse_async_chunks, parse_chunks
19
31
 
20
32
 
@@ -47,6 +59,17 @@ def _read_version() -> str:
47
59
  __version__ = _read_version()
48
60
 
49
61
  __all__ = [
62
+ "decode_reference_literal",
63
+ "encode_reference_literal",
64
+ "ArityRange",
65
+ "BinaryLinoCodec",
66
+ "BinaryLinoOptions",
67
+ "DecodeLimits",
68
+ "External",
69
+ "LinksPacket",
70
+ "Section",
71
+ "format_binary_document",
72
+ "format_binary_reference",
50
73
  "DEFAULT_MAX_DEPTH",
51
74
  "Link",
52
75
  "ParseError",
@@ -0,0 +1,624 @@
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
+ from .reference_literal import format_reference
16
+
17
+ U64 = (1 << 64) - 1
18
+ WIDTHS = (1, 2, 4, 8)
19
+
20
+
21
+ def _require(condition, message):
22
+ if not condition:
23
+ raise ValueError(message)
24
+
25
+
26
+ def _u64(value):
27
+ _require(isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= U64, "expected uint64")
28
+ return value
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class External:
33
+ """Hybrid external reference; bare integers denote internal addresses."""
34
+
35
+ value: int
36
+
37
+ def __post_init__(self):
38
+ _u64(self.value)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ArityRange:
43
+ min: int = 2
44
+ max: Optional[int] = 2
45
+
46
+ def __post_init__(self):
47
+ _require(1 <= _u64(self.min) <= U64 >> 4, "invalid minimum arity")
48
+ _require(self.max is None or _u64(self.max) >= self.min, "invalid maximum arity")
49
+
50
+ def contains(self, length):
51
+ return length >= self.min and (self.max is None or length <= self.max)
52
+
53
+ @property
54
+ def fixed(self):
55
+ return self.min == self.max
56
+
57
+ @classmethod
58
+ def parse(cls, text):
59
+ _require(re.fullmatch(r"[0-9]+(?:\.\.[0-9]*)?", text), "invalid arity")
60
+ parts = text.split("..")
61
+ return cls(int(parts[0]), int(parts[-1]) if parts[-1] else None)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class DecodeLimits:
66
+ """Work budgets applied to both byte/model input and encoding."""
67
+
68
+ max_links: int = 1 << 22
69
+ max_references: int = 1 << 24
70
+ max_nodes: int = 1 << 22
71
+ max_string_bytes: int = 64 << 20
72
+ max_depth: int = 64
73
+
74
+ def __post_init__(self):
75
+ for value in vars(self).values():
76
+ _u64(value)
77
+
78
+ @classmethod
79
+ def unlimited(cls):
80
+ """For trusted input; caller must provide adequate memory/stack."""
81
+ return cls(U64, U64, U64, U64, U64)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class BinaryLinoOptions:
86
+ external_references: bool = False
87
+ arity: ArityRange = field(default_factory=ArityRange)
88
+ packed_widths: bool = False
89
+
90
+ @classmethod
91
+ def of_packet(cls, packet):
92
+ lengths = [2] + [len(link) for _, link in packet.links()]
93
+ return cls(
94
+ packet.external_references,
95
+ ArityRange(min(lengths), max(lengths)),
96
+ len({section.width for section in packet.sections}) > 1,
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class Section:
102
+ gap: int
103
+ arity: ArityRange
104
+ width: int
105
+ links: list
106
+
107
+
108
+ def _width(reference, external):
109
+ value = reference.value if isinstance(reference, External) else _u64(reference)
110
+ _require(not isinstance(reference, External) or external, "external reference disabled")
111
+ for width in WIDTHS:
112
+ bits = width * 8 - int(external or isinstance(reference, External))
113
+ if value < 1 << bits:
114
+ return width
115
+ raise ValueError("reference exceeds capacity")
116
+
117
+
118
+ def _leb(out, value):
119
+ value = _u64(value)
120
+ while value >= 128:
121
+ out.append((value & 127) | 128)
122
+ value >>= 7
123
+ out.append(value)
124
+
125
+
126
+ class _Reader:
127
+ def __init__(self, stream):
128
+ self.stream = stream
129
+
130
+ def raw(self, count):
131
+ chunks = bytearray()
132
+ while len(chunks) < count:
133
+ part = self.stream.read(count - len(chunks))
134
+ _require(part, "unexpected end of packet")
135
+ chunks.extend(part)
136
+ return int.from_bytes(chunks, "little")
137
+
138
+ def leb(self):
139
+ value = 0
140
+ for shift in range(0, 64, 7):
141
+ byte = self.raw(1)
142
+ payload = byte & 127
143
+ _require(shift != 63 or payload <= 1, "LEB128 overflow")
144
+ value |= payload << shift
145
+ if byte < 128:
146
+ return value
147
+ raise ValueError("LEB128 overflow")
148
+
149
+
150
+ @dataclass
151
+ class LinksPacket:
152
+ external_references: bool = False
153
+ sections: list = field(default_factory=list)
154
+
155
+ def links(self):
156
+ result, address = [], 1
157
+ for section in self.sections:
158
+ address = _u64(address + _u64(section.gap))
159
+ for link in section.links:
160
+ result.append((address, list(link)))
161
+ address = _u64(address + 1)
162
+ return result
163
+
164
+ def validate(self, limits=DecodeLimits()):
165
+ _require(len(self.sections) <= limits.max_links, "too many sections")
166
+ links = self.links()
167
+ _require(len(links) <= limits.max_links, "too many links")
168
+ references = 0
169
+ for section in self.sections:
170
+ _require(section.width in WIDTHS, "invalid reference width")
171
+ # Also validate mutable in-memory sections/packets.
172
+ ArityRange(section.arity.min, section.arity.max)
173
+ for link in section.links:
174
+ _require(section.arity.contains(len(link)), "link outside arity range")
175
+ references += len(link)
176
+ _require(references <= limits.max_references, "too many references")
177
+ for reference in link:
178
+ _require(_width(reference, self.external_references) <= section.width, "reference outside width")
179
+ return self
180
+
181
+ def to_bytes(self, limits=DecodeLimits()):
182
+ self.validate(limits)
183
+ compact = not self.sections or (
184
+ len(self.sections) == 1
185
+ and self.sections[0].gap == 5
186
+ and self.sections[0].arity == ArityRange()
187
+ and bool(self.sections[0].links)
188
+ )
189
+ out = bytearray([0x10 | int(self.external_references)])
190
+ if compact:
191
+ width = self.sections[0].width if self.sections else 1
192
+ out[0] |= WIDTHS.index(width) << 2
193
+ _leb(out, sum(len(s.links) for s in self.sections))
194
+ else:
195
+ out[0] |= 2
196
+ _leb(out, len(self.sections))
197
+ for section in self.sections:
198
+ _leb(
199
+ out,
200
+ section.arity.min << 4
201
+ | WIDTHS.index(section.width)
202
+ | (4 if section.gap else 0)
203
+ | (0 if section.arity.fixed else 8),
204
+ )
205
+ if section.gap:
206
+ _leb(out, section.gap)
207
+ if not section.arity.fixed:
208
+ _leb(out, 0 if section.arity.max is None else section.arity.max - section.arity.min)
209
+ _leb(out, len(section.links))
210
+ for section in self.sections:
211
+ for link in section.links:
212
+ if not section.arity.fixed:
213
+ _leb(out, len(link) - section.arity.min)
214
+ for reference in link:
215
+ if isinstance(reference, External):
216
+ value = (
217
+ (1 << (section.width * 8 - 1))
218
+ if reference.value == 0
219
+ else (1 << (section.width * 8)) - reference.value
220
+ )
221
+ else:
222
+ value = reference
223
+ out.extend(value.to_bytes(section.width, "little"))
224
+ return bytes(out)
225
+
226
+ def write_to(self, stream, limits=DecodeLimits()):
227
+ data = self.to_bytes(limits)
228
+ offset = 0
229
+ while offset < len(data):
230
+ written = stream.write(data[offset:])
231
+ _require(written is not None and written > 0, "stream did not write data")
232
+ offset += written
233
+
234
+ @classmethod
235
+ def read_from(cls, stream, limits=DecodeLimits()):
236
+ """Read exactly one packet; None only at a clean end of stream."""
237
+ first = stream.read(1)
238
+ if not first:
239
+ return None
240
+ header = first[0]
241
+ _require(header & 0xF0 == 0x10, "unsupported binary version")
242
+ reader, sections, counts = _Reader(stream), [], []
243
+ if header & 2:
244
+ _require(header & 12 == 0, "explicit header width bits set")
245
+ count = reader.leb()
246
+ _require(count <= limits.max_links, "too many sections")
247
+ address, total = 1, 0
248
+ for _ in range(count):
249
+ shape = reader.leb()
250
+ gap = reader.leb() if shape & 4 else 0
251
+ minimum = shape >> 4
252
+ extra = reader.leb() if shape & 8 else None
253
+ maximum = minimum if extra is None else (None if extra == 0 else _u64(minimum + extra))
254
+ arity = ArityRange(minimum, maximum)
255
+ count = reader.leb()
256
+ address = _u64(address + gap + count)
257
+ total += count
258
+ _require(total <= limits.max_links, "too many links")
259
+ sections.append(Section(gap, arity, WIDTHS[shape & 3], []))
260
+ counts.append(count)
261
+ else:
262
+ count = reader.leb()
263
+ _u64(6 + count)
264
+ _require(count <= limits.max_links, "too many links")
265
+ if count:
266
+ sections.append(Section(5, ArityRange(), WIDTHS[(header >> 2) & 3], []))
267
+ counts.append(count)
268
+ references = 0
269
+ for section, count in zip(sections, counts):
270
+ for _ in range(count):
271
+ length = section.arity.min + (0 if section.arity.fixed else reader.leb())
272
+ _require(section.arity.contains(_u64(length)), "link outside arity range")
273
+ references += length
274
+ _require(references <= limits.max_references, "too many references")
275
+ link = []
276
+ for _ in range(length):
277
+ raw = reader.raw(section.width)
278
+ top = 1 << (section.width * 8 - 1)
279
+ link.append(
280
+ External(0 if raw == top else (1 << (section.width * 8)) - raw)
281
+ if header & 1 and raw >= top
282
+ else raw
283
+ )
284
+ section.links.append(link)
285
+ return cls(bool(header & 1), sections)
286
+
287
+ @classmethod
288
+ def from_bytes(cls, data, limits=DecodeLimits()):
289
+ stream = io.BytesIO(data)
290
+ packet = cls.read_from(stream, limits)
291
+ _require(packet is not None, "empty input")
292
+ _require(not stream.read(1), "trailing bytes")
293
+ return packet
294
+
295
+ @classmethod
296
+ def parse_links(cls, text):
297
+ links = []
298
+ for entry in text.split(";"):
299
+ if not entry.strip():
300
+ continue
301
+ address, refs = entry.split(":")
302
+ links.append(
303
+ (
304
+ _u64(int(address)),
305
+ [External(int(r[1:])) if r.startswith("#") else _u64(int(r)) for r in refs.split()],
306
+ )
307
+ )
308
+ return links
309
+
310
+ @classmethod
311
+ def pack(cls, external_references, links, packed_widths=False):
312
+ links = list(links)
313
+ needs, previous = [], 0
314
+ for address, refs in links:
315
+ _require(previous < _u64(address) < U64, "addresses must ascend and leave space for end")
316
+ _require(refs, "empty raw link")
317
+ previous = address
318
+ needs.append(max(_width(r, external_references) for r in refs))
319
+
320
+ def layout(packed):
321
+ if not links:
322
+ return []
323
+ widest, costs, opens, bests = max(needs), [float("inf")] * 8, [], []
324
+ for index, (address, refs) in enumerate(links):
325
+ best = min(range(8), key=lambda s: costs[s])
326
+ bests.append(best)
327
+ before = costs[best] if index else 0
328
+ next_costs, mask = [float("inf")] * 8, 0
329
+ for state in range(8):
330
+ width, variable = WIDTHS[state // 2], state % 2
331
+ if (not packed and width != widest) or width < needs[index]:
332
+ continue
333
+ opening = before + 2 + variable
334
+ continuing = (
335
+ costs[state]
336
+ if index
337
+ and links[index - 1][0] + 1 == address
338
+ and (variable or len(links[index - 1][1]) == len(refs))
339
+ else float("inf")
340
+ )
341
+ if continuing <= opening:
342
+ next_costs[state] = continuing + len(refs) * width + variable
343
+ else:
344
+ next_costs[state] = opening + len(refs) * width + variable
345
+ mask |= 1 << state
346
+ opens.append(mask)
347
+ costs = next_costs
348
+ result, end, state = [], len(links), min(range(8), key=lambda s: costs[s])
349
+ for index in range(len(links) - 1, -1, -1):
350
+ if opens[index] & (1 << state):
351
+ result.append((end - index, WIDTHS[state // 2]))
352
+ end, state = index, bests[index]
353
+ return list(reversed(result))
354
+
355
+ def packet(plan):
356
+ sections, index, address = [], 0, 1
357
+ for count, width in plan:
358
+ members = links[index : index + count]
359
+ lengths = [len(refs) for _, refs in members]
360
+ start = members[0][0]
361
+ sections.append(
362
+ Section(
363
+ start - address,
364
+ ArityRange(min(lengths), max(lengths)),
365
+ width,
366
+ [list(refs) for _, refs in members],
367
+ )
368
+ )
369
+ index, address = index + count, start + count
370
+ return cls(external_references, sections)
371
+
372
+ uniform = packet(layout(False))
373
+ if not packed_widths:
374
+ return uniform
375
+ packed = packet(layout(True))
376
+ return (
377
+ packed
378
+ if len(packed.to_bytes(DecodeLimits.unlimited())) < len(uniform.to_bytes(DecodeLimits.unlimited()))
379
+ else uniform
380
+ )
381
+
382
+
383
+ class _Encoder:
384
+ def __init__(self, options):
385
+ self.options = options
386
+ self.doublets, self.tuples, self.created = [], [], {}
387
+ self.powers = [("i", 1)]
388
+
389
+ def link(self, items):
390
+ key = tuple(items)
391
+ if key not in self.created:
392
+ doublet = len(items) == 2 and all(kind != "t" for kind, _ in items)
393
+ target = self.doublets if doublet else self.tuples
394
+ node = ("d" if doublet else "t", len(target))
395
+ target.append(list(items))
396
+ self.created[key] = node
397
+ return self.created[key]
398
+
399
+ def chain(self, items):
400
+ tail = ("i", 0)
401
+ for head in reversed(items):
402
+ tail = self.link([head, tail])
403
+ return tail
404
+
405
+ def typed(self, marker, elements):
406
+ items = [("i", marker)] + elements
407
+ return (
408
+ self.link(items)
409
+ if len(items) != 2 and self.options.arity.contains(len(items))
410
+ else self.link([("i", marker), self.chain(elements)])
411
+ )
412
+
413
+ def list(self, elements):
414
+ if not elements:
415
+ return ("i", 0)
416
+ if len(elements) == 2 or self.options.arity.contains(len(elements)):
417
+ return self.link(elements)
418
+ return self.typed(4, elements)
419
+
420
+ def unary(self, value):
421
+ powers = []
422
+ for bit in range(63, -1, -1):
423
+ if value & (1 << bit):
424
+ while len(self.powers) <= bit:
425
+ self.powers.append(self.link([self.powers[-1], self.powers[-1]]))
426
+ powers.append(self.powers[bit])
427
+ if not powers:
428
+ return ("i", 0)
429
+ result = powers[-1]
430
+ for power in reversed(powers[:-1]):
431
+ result = self.link([power, result])
432
+ return result
433
+
434
+ def scalar(self, value):
435
+ return ("e", value) if self.options.external_references and value < 1 << 63 else self.unary(value)
436
+
437
+ def reference(self, text):
438
+ if re.fullmatch(r"0|[1-9][0-9]*", text) and len(text) <= 20 and int(text) <= U64:
439
+ value = int(text)
440
+ if self.options.external_references and value < 1 << 63:
441
+ return ("e", value)
442
+ return self.link([("i", 2), self.unary(value)])
443
+ return self.typed(3, [self.scalar(ord(c)) for c in text])
444
+
445
+ def encode_identified(self, node):
446
+ if node.id is not None and node.values:
447
+ identifier = self.reference(node.id)
448
+ return self.typed(5, [identifier] + [self.encode_identified(v) for v in node.values])
449
+ if node.id is not None:
450
+ return self.reference(node.id)
451
+ return self.list([self.encode_identified(v) for v in node.values])
452
+
453
+ def finish(self):
454
+ def resolve(node):
455
+ kind, value = node
456
+ return (
457
+ External(value)
458
+ if kind == "e"
459
+ else (value if kind == "i" else 6 + value + (len(self.doublets) if kind == "t" else 0))
460
+ )
461
+
462
+ links = [(6 + i, [resolve(n) for n in nodes]) for i, nodes in enumerate(self.doublets + self.tuples)]
463
+ return LinksPacket.pack(self.options.external_references, links, self.options.packed_widths)
464
+
465
+
466
+ class _Decoder:
467
+ def __init__(self, packet, limits):
468
+ packet.validate(limits)
469
+ self.limits = limits
470
+ self.links, self.unary = [], []
471
+ self.nodes, self.strings = limits.max_nodes, limits.max_string_bytes
472
+ for address, refs in packet.links():
473
+ _require(address == 6 + len(self.links), "document links must be contiguous from 6")
474
+ _require(all(isinstance(r, External) or r < address for r in refs), "document contains forward reference")
475
+
476
+ def value(r):
477
+ if isinstance(r, External):
478
+ return None
479
+ return r if r <= 1 else (self.unary[r - 6] if r >= 6 else None)
480
+
481
+ a, b = (value(refs[0]), value(refs[1])) if len(refs) == 2 else (None, None)
482
+ self.unary.append(a + b if a is not None and b is not None and a + b <= U64 else None)
483
+ self.links.append(refs)
484
+
485
+ def chain(self, tail):
486
+ result = []
487
+ while tail != 0:
488
+ _require(isinstance(tail, int) and tail >= 6, "broken element chain")
489
+ items = self.links[tail - 6]
490
+ _require(len(items) == 2, "broken element chain")
491
+ _require(len(result) < self.limits.max_nodes, "chain too long")
492
+ result.append(items[0])
493
+ tail = items[1]
494
+ return result
495
+
496
+ def number(self, reference):
497
+ if isinstance(reference, External):
498
+ return reference.value
499
+ if reference <= 1:
500
+ return reference
501
+ value = self.unary[reference - 6] if reference >= 6 else None
502
+ _require(value is not None, "expected unary number")
503
+ return value
504
+
505
+ def text(self, text):
506
+ self.strings -= len(text.encode("utf-8"))
507
+ _require(self.strings >= 0, "too many expanded string bytes")
508
+ return Link(text)
509
+
510
+ def decode(self, reference, depth):
511
+ _require(depth < self.limits.max_depth, "nesting limit exceeded")
512
+ self.nodes -= 1
513
+ _require(self.nodes >= 0, "too many expanded nodes")
514
+ if isinstance(reference, External):
515
+ return self.text(str(reference.value))
516
+ if reference == 0:
517
+ return Link()
518
+ _require(reference >= 6, "standalone marker")
519
+ items = self.links[reference - 6]
520
+ if isinstance(items[0], int) and 1 <= items[0] <= 5:
521
+ marker = items[0]
522
+ elements = items[1:] if len(items) != 2 or marker == 2 else self.chain(items[1])
523
+ if marker == 2:
524
+ _require(len(elements) == 1, "number needs one value")
525
+ return self.text(str(self.number(elements[0])))
526
+ if marker == 3:
527
+ chars, byte_count = [], 0
528
+ for e in elements:
529
+ p = self.number(e)
530
+ _require(p <= 0x10FFFF and not 0xD800 <= p <= 0xDFFF, "invalid Unicode scalar")
531
+ size = 1 if p <= 0x7F else 2 if p <= 0x7FF else 3 if p <= 0xFFFF else 4
532
+ _require(size <= self.strings - byte_count, "too many expanded string bytes")
533
+ byte_count += size
534
+ chars.append(chr(p))
535
+ return self.text("".join(chars))
536
+ if marker == 5:
537
+ _require(elements, "identified link needs id")
538
+ identifier = self.decode(elements[0], depth)
539
+ _require(identifier.id is not None and not identifier.values, "id must be reference")
540
+ return Link(identifier.id, [self.decode(e, depth + 1) for e in elements[1:]])
541
+ _require(marker == 4, "invalid typed marker")
542
+ else:
543
+ elements = items
544
+ return Link(None, [self.decode(e, depth + 1) for e in elements])
545
+
546
+ def document(self):
547
+ if not self.links:
548
+ return []
549
+ root = self.links[-1]
550
+ if len(root) == 2 and root[0] == 4:
551
+ elements = self.chain(root[1])
552
+ else:
553
+ _require(not (isinstance(root[0], int) and 1 <= root[0] <= 5), "root must be list")
554
+ elements = root
555
+ return [self.decode(e, 0) for e in elements]
556
+
557
+
558
+ def _canonical(node):
559
+ if node.id is None and len(node.values) == 1 and node.values[0].id is not None and not node.values[0].values:
560
+ return node.values[0]
561
+ return Link(node.id, [_canonical(v) for v in node.values])
562
+
563
+
564
+ def format_document(document):
565
+ def nested(node, top=False):
566
+ if node.id is not None and not node.values:
567
+ return format_reference(node.id)
568
+ values = " ".join(nested(v) for v in node.values)
569
+ if node.id is not None:
570
+ return "(" + format_reference(node.id) + ": " + values + ")"
571
+ if len(node.values) == 1 and node.values[0].id is not None and not node.values[0].values:
572
+ return "((" + values + "))"
573
+ return values if top and len(node.values) >= 2 else "(" + values + ")"
574
+
575
+ return "\n".join(nested(n, True) for n in document)
576
+
577
+
578
+ class BinaryLinoCodec:
579
+ def __init__(self, options=None, limits=None):
580
+ self.options = options or BinaryLinoOptions()
581
+ self.limits = limits or DecodeLimits()
582
+
583
+ def encode_packet(self, document):
584
+ _require(self.options.arity.contains(2), "arity must contain 2")
585
+ pending = [(node, 0) for node in document]
586
+ nodes, strings = 0, 0
587
+ while pending:
588
+ node, depth = pending.pop()
589
+ _require(isinstance(node, Link), "expected Link model")
590
+ _require(depth < self.limits.max_depth, "nesting limit exceeded")
591
+ nodes += 1 + int(node.id is not None and bool(node.values))
592
+ if node.id is not None:
593
+ _require(isinstance(node.id, str), "reference must be string")
594
+ strings += len(node.id.encode("utf-8"))
595
+ _require(nodes <= self.limits.max_nodes, "too many nodes")
596
+ _require(strings <= self.limits.max_string_bytes, "too many string bytes")
597
+ pending.extend((v, depth + 1) for v in node.values)
598
+ encoder = _Encoder(self.options)
599
+ if document:
600
+ encoder.list([encoder.encode_identified(node) for node in document])
601
+ return encoder.finish().validate(self.limits)
602
+
603
+ def encode(self, document):
604
+ return self.encode_packet(document).to_bytes(self.limits)
605
+
606
+ def decode_packet(self, packet):
607
+ return _Decoder(packet, self.limits).document()
608
+
609
+ def decode(self, data):
610
+ return self.decode_packet(LinksPacket.from_bytes(data, self.limits))
611
+
612
+ @staticmethod
613
+ def parse_document(text, parser=None):
614
+ return [_canonical(n) for n in (parser or Parser()).parse(text)]
615
+
616
+ @staticmethod
617
+ def format_document(document):
618
+ return format_document(document)
619
+
620
+ def encode_text(self, text, parser=None):
621
+ return self.encode(self.parse_document(text, parser))
622
+
623
+ def decode_text(self, data):
624
+ return format_document(self.decode(data))
@@ -4,6 +4,8 @@ Link class representing a Lino link with optional ID and values.
4
4
 
5
5
  from typing import TYPE_CHECKING, List, Optional, Union
6
6
 
7
+ from .reference_literal import format_reference
8
+
7
9
  if TYPE_CHECKING:
8
10
  from .format_config import FormatConfig
9
11
 
@@ -92,24 +94,7 @@ class Link:
92
94
  if reference is None:
93
95
  return ""
94
96
 
95
- # The empty reference is written as a bare delimiter pair, so that it
96
- # reads back as itself instead of disappearing from the document.
97
- if reference == "":
98
- return '""'
99
-
100
- # Check if single quotes are needed. A reference that begins with a "#"
101
- # is quoted too, or it would read back as a comment; a "#" anywhere else
102
- # is content ("issue#1047"), so only the first character matters.
103
- needs_single_quotes = reference.startswith("#") or any(
104
- c in reference for c in [":", "(", ")", " ", "\t", "\n", "\r", '"']
105
- )
106
-
107
- if needs_single_quotes:
108
- return f"'{reference}'"
109
- elif "'" in reference:
110
- return f'"{reference}"'
111
- else:
112
- return reference
97
+ return format_reference(reference)
113
98
 
114
99
  def to_link_or_id_string(self) -> str:
115
100
  """Convert to string, using just ID if no values, otherwise full format."""
@@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
11
11
  from .comments import strip_comments
12
12
  from .link import Link
13
13
  from .quotes import _parse_quoted_string_at
14
+ from .reference_literal import decode_reference_literal
14
15
 
15
16
  #: How deep links may nest unless a parser is told otherwise: every
16
17
  #: parenthesized group and every indentation level is one level, and the lines
@@ -612,6 +613,9 @@ class Parser:
612
613
  """Extract reference, handling quoted strings with escaping support."""
613
614
  text = text.strip()
614
615
 
616
+ if re.match(r"~[0-9]+\{", text):
617
+ return decode_reference_literal(text)
618
+
615
619
  # Try delimited references (any N quotes, or a bare delimiter pair)
616
620
  quoted = _parse_quoted_string_at(text, 0)
617
621
  if quoted is not None:
@@ -645,7 +649,7 @@ class Parser:
645
649
  children = item.get("children", [])
646
650
 
647
651
  # Special case: indented ID syntax (id: followed by children)
648
- if item.get("is_indented_id") and item.get("id") and not item.get("values") and children:
652
+ if item.get("is_indented_id") and item.get("id") is not None and not item.get("values") and children:
649
653
  child_values = [self._transform_indented_value(child) for child in children]
650
654
 
651
655
  link_with_children = {"id": item["id"], "values": child_values}
@@ -0,0 +1,38 @@
1
+ """Version 1 lossless Unicode references, with readable legacy quoting."""
2
+
3
+ import re
4
+
5
+
6
+ def encode_reference_literal(text: str) -> str:
7
+ """Encode exact text as a versioned UTF-8 hexadecimal literal."""
8
+ return "~1{" + text.encode("utf-8").hex() + "}"
9
+
10
+
11
+ def decode_reference_literal(literal: str) -> str:
12
+ """Decode a complete literal; reject unsupported versions and invalid UTF-8."""
13
+ match = re.fullmatch(r"~([0-9]+)\{([0-9a-fA-F]*)\}", literal)
14
+ if not match or match[1] != "1" or len(match[2]) % 2:
15
+ raise ValueError("Invalid or unsupported reference literal (expected ~1{UTF-8 hex})")
16
+ return bytes.fromhex(match[2]).decode("utf-8")
17
+
18
+
19
+ def format_reference(text: str) -> str:
20
+ """Quote readable text, using a literal for empty strings and C0 controls."""
21
+ text.encode("utf-8") # Reject unpaired surrogates before formatting.
22
+ if not text or any(ord(c) < 32 or ord(c) == 127 for c in text):
23
+ return encode_reference_literal(text)
24
+ if (
25
+ not text.startswith("#")
26
+ and not re.match(r"~[0-9]+\{", text)
27
+ and not any(c.isspace() or c in "\ufeff():\"'`" for c in text)
28
+ ):
29
+ return text
30
+ choices = []
31
+ for quote in "'\"`":
32
+ if text.startswith(quote):
33
+ continue
34
+ longest = max([len(m.group()) for m in re.finditer(re.escape(quote) + "+", text)] or [0])
35
+ choices.append(((longest + 1) | 1, quote))
36
+ count, quote = min(choices, key=lambda item: item[0])
37
+ delimiter = quote * count
38
+ return delimiter + text + delimiter
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: links-notation
3
- Version: 0.22.0
3
+ Version: 0.24.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
@@ -306,7 +306,7 @@ python -m build
306
306
 
307
307
  This project uses [Black](https://github.com/psf/black) for code formatting,
308
308
  [isort](https://pycqa.github.io/isort/) for import sorting, and
309
- [flake8](https://flake8.pycqa.org/) for linting.
309
+ [flake8](https://flake8.pycqa.org/en/latest/) for linting.
310
310
 
311
311
  Install linting tools:
312
312
 
@@ -354,3 +354,6 @@ This project is released into the public domain under the [Unlicense](../LICENSE
354
354
  - [Main Repository](https://github.com/link-foundation/links-notation)
355
355
  - [PyPI Package](https://pypi.org/project/links-notation/)
356
356
  - [Documentation](https://link-foundation.github.io/links-notation/)
357
+
358
+ Exact Unicode identifiers and values round-trip with the [reference literal contract](../docs/protocol/reference-literals.md)
359
+ introduced in 0.24.0, including mixed quotes, empty strings, controls and multiline text.
@@ -5,12 +5,14 @@ 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
11
12
  links_notation/link.py
12
13
  links_notation/parser.py
13
14
  links_notation/quotes.py
15
+ links_notation/reference_literal.py
14
16
  links_notation/stream_parser.py
15
17
  links_notation.egg-info/PKG-INFO
16
18
  links_notation.egg-info/SOURCES.txt
@@ -18,6 +20,7 @@ links_notation.egg-info/dependency_links.txt
18
20
  links_notation.egg-info/requires.txt
19
21
  links_notation.egg-info/top_level.txt
20
22
  tests/test_api.py
23
+ tests/test_binary.py
21
24
  tests/test_comments.py
22
25
  tests/test_edge_case_parser.py
23
26
  tests/test_empty_reference.py
@@ -35,5 +38,6 @@ tests/test_nested_indentation.py
35
38
  tests/test_nested_parser.py
36
39
  tests/test_nested_self_reference.py
37
40
  tests/test_nesting_limit.py
41
+ tests/test_reference_fidelity.py
38
42
  tests/test_single_line_parser.py
39
43
  tests/test_stream_parser.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.24.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
@@ -76,8 +76,8 @@ class TestNQuoteMeaningsSurvive:
76
76
  class TestEmptyReferenceFormatting:
77
77
  """The empty reference is written so that it reads back as itself."""
78
78
 
79
- def test_empty_reference_is_written_as_a_delimiter_pair(self):
80
- assert format_links(Parser().parse('(a "" b)')) == '(a "" b)'
79
+ def test_empty_reference_is_written_as_a_versioned_literal(self):
80
+ assert format_links(Parser().parse('(a "" b)')) == "(a ~1{} b)"
81
81
 
82
82
  def test_empty_reference_survives_a_round_trip(self):
83
83
  for source in [
@@ -0,0 +1,73 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from links_notation import (
6
+ FormatConfig,
7
+ Link,
8
+ Parser,
9
+ StreamParser,
10
+ decode_reference_literal,
11
+ encode_reference_literal,
12
+ format_binary_reference,
13
+ format_links,
14
+ )
15
+ from links_notation.binary import BinaryLinoCodec
16
+
17
+ REFERENCES = [
18
+ "" if line == "-" else bytes.fromhex(line).decode("utf-8")
19
+ for line in (Path(__file__).resolve().parents[2] / "docs/protocol/reference-literals.txt").read_text().splitlines()
20
+ if not line.startswith("#")
21
+ ]
22
+
23
+
24
+ def test_native_reference_fidelity():
25
+ for reference in REFERENCES:
26
+ original = [Link(reference, [Link(reference), Link("nested", [Link(reference)])])]
27
+ for less_parentheses in (False, True, FormatConfig(max_inline_refs=1, prefer_inline=False)):
28
+ assert Parser().parse(format_links(original, less_parentheses)) == original
29
+
30
+
31
+ def test_reference_literals_at_every_chunk_split():
32
+ for reference in REFERENCES:
33
+ assert decode_reference_literal(encode_reference_literal(reference)) == reference
34
+ for literal in (
35
+ Link.escape_reference(reference),
36
+ format_binary_reference(reference),
37
+ encode_reference_literal(reference),
38
+ ):
39
+ text = f"(root: {literal})\n({literal}: fixture)"
40
+ expected = [Link("root", [Link(reference)]), Link(reference, [Link("fixture")])]
41
+ assert Parser().parse(text) == expected
42
+ for split in range(len(text) + 1):
43
+ stream = StreamParser()
44
+ stream.write(text[:split])
45
+ stream.write(text[split:])
46
+ assert stream.finish() == expected
47
+
48
+
49
+ def test_binary_reference_fidelity():
50
+ codec = BinaryLinoCodec()
51
+ for reference in REFERENCES:
52
+ original = [Link(reference, [Link(reference)])]
53
+ decoded = codec.decode(codec.encode(original))
54
+ assert decoded == original
55
+ assert codec.parse_document(codec.format_document(decoded)) == original
56
+
57
+
58
+ def test_malformed_reference_literals_are_rejected():
59
+ invalid = (
60
+ (Path(__file__).resolve().parents[2] / "docs/protocol/invalid-reference-literals.txt").read_text().splitlines()
61
+ )
62
+ for literal in invalid:
63
+ if literal.startswith("#"):
64
+ continue
65
+ with pytest.raises(ValueError):
66
+ decode_reference_literal(literal)
67
+ with pytest.raises(Exception):
68
+ Parser().parse(literal)
69
+ assert decode_reference_literal("~1{C3A9}") == "é"
70
+ with pytest.raises(UnicodeEncodeError):
71
+ encode_reference_literal("\ud800")
72
+ with pytest.raises(UnicodeEncodeError):
73
+ Link.escape_reference("\ud800")
@@ -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