links-notation 0.28.0__tar.gz → 0.30.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.
- {links_notation-0.28.0/links_notation.egg-info → links_notation-0.30.0}/PKG-INFO +3 -3
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/comments.py +39 -8
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/cst.py +12 -14
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/parser.py +53 -41
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/quotes.py +33 -8
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/reference_literal.py +9 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/stream_parser.py +31 -13
- {links_notation-0.28.0 → links_notation-0.30.0/links_notation.egg-info}/PKG-INFO +3 -3
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/requires.txt +2 -2
- {links_notation-0.28.0 → links_notation-0.30.0}/pyproject.toml +4 -4
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_comments.py +29 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_stream_parser.py +10 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/LICENSE +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/MANIFEST.in +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/README.md +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/README.ru.md +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/__init__.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/binary.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/format_config.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/formatter.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation/link.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/SOURCES.txt +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/dependency_links.txt +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/top_level.txt +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/setup.cfg +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_api.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_binary.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_cst.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_edge_case_parser.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_empty_reference.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_format_config.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_indentation_consistency.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_indented_id_nested_values.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_indented_id_syntax.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_link.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_links_group.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_mixed_indentation_modes.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_multi_quote_parser.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_multiline_parser.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_multiline_quoted_string.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nested_indentation.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nested_parser.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nested_self_reference.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_nesting_limit.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_reference_fidelity.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_shared_syntax_parity.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_shared_text_parity.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_single_line_parser.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_syntax_metadata.py +0 -0
- {links_notation-0.28.0 → links_notation-0.30.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: links-notation
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.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,12 +22,12 @@ Requires-Python: >=3.11
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Provides-Extra: test
|
|
25
|
-
Requires-Dist: pytest>=9.
|
|
25
|
+
Requires-Dist: pytest>=9.2.0; extra == "test"
|
|
26
26
|
Requires-Dist: pytest-timeout>=2.4.0; extra == "test"
|
|
27
27
|
Provides-Extra: lint
|
|
28
28
|
Requires-Dist: black>=26.10.1; extra == "lint"
|
|
29
29
|
Requires-Dist: isort>=9.0.2; extra == "lint"
|
|
30
|
-
Requires-Dist: flake8>=7.
|
|
30
|
+
Requires-Dist: flake8>=7.5.0; extra == "lint"
|
|
31
31
|
Provides-Extra: dev
|
|
32
32
|
Requires-Dist: links-notation[lint,test]; extra == "dev"
|
|
33
33
|
Dynamic: license-file
|
|
@@ -13,12 +13,15 @@ still points at what the reader can see.
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
from .quotes import QUOTE_CHARS, _parse_quoted_string_at
|
|
16
|
+
from .reference_literal import reference_literal_end
|
|
16
17
|
|
|
17
18
|
#: The character that starts a comment.
|
|
18
19
|
COMMENT = "#"
|
|
19
20
|
|
|
20
|
-
#: What a
|
|
21
|
-
|
|
21
|
+
#: What ends a token, so that what follows it starts a new one. A delimited
|
|
22
|
+
#: reference or a literal may start at the start of any token, and also right
|
|
23
|
+
#: after a delimited reference or a literal, as the parser reads them.
|
|
24
|
+
SEPARATORS = (" ", "\t", "\n", "\r", "(", ":", ")")
|
|
22
25
|
|
|
23
26
|
#: What a comment may start after.
|
|
24
27
|
BEFORE_COMMENT = (" ", "\t", "\n", "\r")
|
|
@@ -28,7 +31,9 @@ def strip_comments(document: str) -> str:
|
|
|
28
31
|
"""
|
|
29
32
|
Return ``document`` with every comment blanked out.
|
|
30
33
|
|
|
31
|
-
The result has the same length as the document it was given.
|
|
34
|
+
The result has the same length as the document it was given. Tokens are
|
|
35
|
+
read as the parser reads them, so a ``#`` inside a delimited reference that
|
|
36
|
+
follows a group, another reference or a literal stays content (issue #377).
|
|
32
37
|
|
|
33
38
|
>>> strip_comments("a: b # why\\n")
|
|
34
39
|
'a: b \\n'
|
|
@@ -36,18 +41,16 @@ def strip_comments(document: str) -> str:
|
|
|
36
41
|
'"# kept"\\n'
|
|
37
42
|
>>> strip_comments("issue#1047\\n")
|
|
38
43
|
'issue#1047\\n'
|
|
44
|
+
>>> strip_comments('(a)" #b"\\n')
|
|
45
|
+
'(a)" #b"\\n'
|
|
39
46
|
"""
|
|
40
47
|
blanked = None
|
|
41
48
|
position = 0
|
|
49
|
+
token_start = True
|
|
42
50
|
|
|
43
51
|
while position < len(document):
|
|
44
52
|
char = document[position]
|
|
45
53
|
|
|
46
|
-
if char in QUOTE_CHARS and _follows(document, position, BEFORE_REFERENCE):
|
|
47
|
-
parsed = _parse_quoted_string_at(document, position)
|
|
48
|
-
position = position + 1 if parsed is None else parsed[1]
|
|
49
|
-
continue
|
|
50
|
-
|
|
51
54
|
if char == COMMENT and _follows(document, position, BEFORE_COMMENT):
|
|
52
55
|
if blanked is None:
|
|
53
56
|
blanked = list(document)
|
|
@@ -56,11 +59,39 @@ def strip_comments(document: str) -> str:
|
|
|
56
59
|
position += 1
|
|
57
60
|
continue
|
|
58
61
|
|
|
62
|
+
if char in SEPARATORS:
|
|
63
|
+
token_start = True
|
|
64
|
+
position += 1
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if token_start:
|
|
68
|
+
end = delimited_token_end(document, position)
|
|
69
|
+
if end is not None:
|
|
70
|
+
position = end
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
token_start = False
|
|
59
74
|
position += 1
|
|
60
75
|
|
|
61
76
|
return document if blanked is None else "".join(blanked)
|
|
62
77
|
|
|
63
78
|
|
|
79
|
+
def delimited_token_end(document: str, start: int):
|
|
80
|
+
"""
|
|
81
|
+
Where the versioned literal or delimited reference that starts at ``start``
|
|
82
|
+
ends, or None when neither can be read there. What follows one starts a new
|
|
83
|
+
token.
|
|
84
|
+
"""
|
|
85
|
+
literal = reference_literal_end(document, start)
|
|
86
|
+
if literal != -1:
|
|
87
|
+
return literal
|
|
88
|
+
if document[start] in QUOTE_CHARS:
|
|
89
|
+
parsed = _parse_quoted_string_at(document, start)
|
|
90
|
+
if parsed is not None:
|
|
91
|
+
return parsed[1]
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
64
95
|
def _follows(document: str, position: int, allowed) -> bool:
|
|
65
96
|
"""
|
|
66
97
|
Report whether the character before ``position`` is one of ``allowed``, the
|
|
@@ -21,7 +21,7 @@ from typing import List, Optional, Tuple
|
|
|
21
21
|
from .comments import strip_comments
|
|
22
22
|
from .parser import ParseError
|
|
23
23
|
from .quotes import QUOTE_CHARS, _parse_quoted_string_at
|
|
24
|
-
from .reference_literal import decode_reference_literal
|
|
24
|
+
from .reference_literal import decode_reference_literal, reference_literal_end
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class CstKind:
|
|
@@ -55,9 +55,6 @@ _NODE_KINDS = frozenset((CstKind.DOCUMENT, CstKind.LINE, CstKind.GROUP))
|
|
|
55
55
|
_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
56
56
|
_ESCAPED = re.compile(r'[\\"\n\r\t]')
|
|
57
57
|
|
|
58
|
-
#: A versioned literal.
|
|
59
|
-
_LITERAL = re.compile(r"~[0-9]+\{[^} \t\r\n()]*\}")
|
|
60
|
-
|
|
61
58
|
#: What ends a bare reference.
|
|
62
59
|
_BARE_REFERENCE_END = frozenset(" \t\r\n(:)")
|
|
63
60
|
|
|
@@ -170,8 +167,11 @@ def build_cst(input_text: str, comments: bool = True) -> CstNode:
|
|
|
170
167
|
Raises:
|
|
171
168
|
ParseError: If the parentheses are unbalanced
|
|
172
169
|
"""
|
|
173
|
-
#
|
|
174
|
-
# comments blanked, which keeps every position of the one
|
|
170
|
+
# Bare references end where they end in the document the parser reads: the
|
|
171
|
+
# one with its comments blanked, which keeps every position of the one
|
|
172
|
+
# written. Delimited references and literals are read, as the parser reads
|
|
173
|
+
# them, in the document as written, so a comment never changes what a
|
|
174
|
+
# reference holds (issue #377).
|
|
175
175
|
prepared = strip_comments(input_text) if comments else input_text
|
|
176
176
|
document = CstNode(CstKind.DOCUMENT)
|
|
177
177
|
# The open containers, innermost last.
|
|
@@ -232,14 +232,12 @@ def _reference(input_text: str, prepared: str, start: int) -> Tuple[CstNode, int
|
|
|
232
232
|
The reference token that starts at ``start``, read as the parser reads it,
|
|
233
233
|
and where it ends.
|
|
234
234
|
"""
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if prepared[start] in QUOTE_CHARS:
|
|
242
|
-
quoted = _parse_quoted_string_at(prepared, start)
|
|
235
|
+
end = reference_literal_end(input_text, start)
|
|
236
|
+
if end != -1:
|
|
237
|
+
text = input_text[start:end]
|
|
238
|
+
return CstNode(CstKind.REFERENCE, text=text, value=decode_reference_literal(text)), end
|
|
239
|
+
if input_text[start] in QUOTE_CHARS:
|
|
240
|
+
quoted = _parse_quoted_string_at(input_text, start)
|
|
243
241
|
if quoted is not None:
|
|
244
242
|
value, end = quoted
|
|
245
243
|
reference = CstNode(CstKind.REFERENCE, text=input_text[start:end], value=value, quote=input_text[start])
|
|
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, 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
|
+
from .reference_literal import decode_reference_literal, reference_literal_end
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
17
17
|
from .cst import CstNode
|
|
@@ -274,14 +274,29 @@ class Parser:
|
|
|
274
274
|
self.parse(input_text)
|
|
275
275
|
return build_cst(input_text, comments=self.comments)
|
|
276
276
|
|
|
277
|
-
def
|
|
277
|
+
def _quoted_at(self, text: str, start: int, offset: int) -> Optional[tuple]:
|
|
278
278
|
"""
|
|
279
|
-
|
|
279
|
+
Read the delimited reference at ``start`` in ``text``, a part of the
|
|
280
|
+
document that starts at ``offset``: ``(value, end)`` with ``end`` in
|
|
281
|
+
``text``, or None.
|
|
282
|
+
|
|
283
|
+
The reference is read in the document as written, within the extent of
|
|
284
|
+
``text``. ``text`` comes from the document with its comments blanked,
|
|
285
|
+
which keeps every position, and blanking a comment must not change what
|
|
286
|
+
a reference holds or how far it reaches (issue #377).
|
|
287
|
+
"""
|
|
288
|
+
parsed = _parse_quoted_string_at(self.source, offset + start, offset + len(text))
|
|
289
|
+
return None if parsed is None else (parsed[0], parsed[1] - offset)
|
|
290
|
+
|
|
291
|
+
def _skip_quoted_string(self, text: str, start: int, offset: int) -> int:
|
|
292
|
+
"""
|
|
293
|
+
Skip over the quoted string starting at start, ``text`` starting at
|
|
294
|
+
``offset`` in the document.
|
|
280
295
|
|
|
281
296
|
Returns the position right after the closing quotes, or -1 when text
|
|
282
297
|
does not start a terminated quoted string.
|
|
283
298
|
"""
|
|
284
|
-
parsed =
|
|
299
|
+
parsed = self._quoted_at(text, start, offset)
|
|
285
300
|
return -1 if parsed is None else parsed[1]
|
|
286
301
|
|
|
287
302
|
def _split_lines_respecting_quotes(self, text: str, base: int) -> Tuple[List[str], List[int]]:
|
|
@@ -323,7 +338,7 @@ class Parser:
|
|
|
323
338
|
position = i + 1
|
|
324
339
|
else:
|
|
325
340
|
# A quoted string is opaque: newlines inside it are content
|
|
326
|
-
end = self._skip_quoted_string(text, i)
|
|
341
|
+
end = self._skip_quoted_string(text, i, base)
|
|
327
342
|
position = end if end > i else i + 1
|
|
328
343
|
counted = position
|
|
329
344
|
|
|
@@ -434,7 +449,7 @@ class Parser:
|
|
|
434
449
|
def _parse_line_content(self, content: str, offset: int) -> Dict:
|
|
435
450
|
"""Parse the content of a single line, which starts at ``offset``."""
|
|
436
451
|
# A whole parenthesized group: (id: values), (values) or a nested document
|
|
437
|
-
if content.startswith("(") and self._find_matching_paren(content, 0) == len(content) - 1:
|
|
452
|
+
if content.startswith("(") and self._find_matching_paren(content, 0, offset) == len(content) - 1:
|
|
438
453
|
return self._parse_parenthesized(content[1:-1], offset)
|
|
439
454
|
|
|
440
455
|
# Try indented ID syntax: id:
|
|
@@ -443,7 +458,7 @@ class Parser:
|
|
|
443
458
|
return {"id": ref, "raw_id": raw, "values": [], "is_indented_id": True}
|
|
444
459
|
|
|
445
460
|
# Try single-line link: id: values
|
|
446
|
-
colon_pos = self._find_colon_outside_quotes(content)
|
|
461
|
+
colon_pos = self._find_colon_outside_quotes(content, offset)
|
|
447
462
|
if colon_pos >= 0:
|
|
448
463
|
after_colon = content[colon_pos + 1 :]
|
|
449
464
|
values_part = after_colon.strip()
|
|
@@ -496,9 +511,10 @@ class Parser:
|
|
|
496
511
|
self.context_depth = saved_context_depth
|
|
497
512
|
self.depth = saved_depth
|
|
498
513
|
|
|
499
|
-
def _find_matching_paren(self, text: str, start: int) -> int:
|
|
514
|
+
def _find_matching_paren(self, text: str, start: int, offset: int) -> int:
|
|
500
515
|
"""
|
|
501
|
-
Find the position of the parenthesis closing the one at start
|
|
516
|
+
Find the position of the parenthesis closing the one at start, ``text``
|
|
517
|
+
starting at ``offset`` in the document.
|
|
502
518
|
|
|
503
519
|
Quoted strings are skipped, so parentheses inside them are ignored.
|
|
504
520
|
Returns -1 when the group is not closed, or when start is not at an
|
|
@@ -525,16 +541,17 @@ class Parser:
|
|
|
525
541
|
return run_start + depth - 1
|
|
526
542
|
depth -= run_end - run_start
|
|
527
543
|
else:
|
|
528
|
-
end = self._skip_quoted_string(text, run_start)
|
|
544
|
+
end = self._skip_quoted_string(text, run_start, offset)
|
|
529
545
|
if end > run_start:
|
|
530
546
|
position = end
|
|
531
547
|
break
|
|
532
548
|
else:
|
|
533
549
|
return -1
|
|
534
550
|
|
|
535
|
-
def _find_colon_outside_quotes(self, text: str) -> int:
|
|
551
|
+
def _find_colon_outside_quotes(self, text: str, offset: int) -> int:
|
|
536
552
|
"""
|
|
537
|
-
Find the position of a colon that's not inside quotes or parentheses
|
|
553
|
+
Find the position of a colon that's not inside quotes or parentheses,
|
|
554
|
+
``text`` starting at ``offset`` in the document.
|
|
538
555
|
|
|
539
556
|
This is crucial for correctly parsing nested self-referenced objects.
|
|
540
557
|
For example, in: ((str key) (obj_1: dict ...))
|
|
@@ -558,7 +575,7 @@ class Parser:
|
|
|
558
575
|
return i
|
|
559
576
|
position = i + 1
|
|
560
577
|
else:
|
|
561
|
-
end = self._skip_quoted_string(text, i)
|
|
578
|
+
end = self._skip_quoted_string(text, i, offset)
|
|
562
579
|
position = end if end > i else i + 1
|
|
563
580
|
counted = position
|
|
564
581
|
|
|
@@ -578,7 +595,7 @@ class Parser:
|
|
|
578
595
|
break
|
|
579
596
|
|
|
580
597
|
# Try to extract the next value
|
|
581
|
-
value_end, value_text = self._extract_next_value(text, i)
|
|
598
|
+
value_end, value_text = self._extract_next_value(text, i, offset)
|
|
582
599
|
if value_text and value_text.strip():
|
|
583
600
|
values.append(self._parse_value(value_text, offset + i))
|
|
584
601
|
if value_end == i:
|
|
@@ -589,9 +606,10 @@ class Parser:
|
|
|
589
606
|
|
|
590
607
|
return values
|
|
591
608
|
|
|
592
|
-
def _extract_next_value(self, text: str, start: int) -> tuple:
|
|
609
|
+
def _extract_next_value(self, text: str, start: int, offset: int) -> tuple:
|
|
593
610
|
"""
|
|
594
|
-
Extract the next value from text starting at start position
|
|
611
|
+
Extract the next value from text starting at start position, ``text``
|
|
612
|
+
starting at ``offset`` in the document.
|
|
595
613
|
Returns (end_position, value_text).
|
|
596
614
|
"""
|
|
597
615
|
if start >= len(text):
|
|
@@ -599,14 +617,20 @@ class Parser:
|
|
|
599
617
|
|
|
600
618
|
# Check if this starts with a delimited reference (any N quotes, or a
|
|
601
619
|
# bare delimiter pair standing for the empty reference)
|
|
602
|
-
quoted =
|
|
620
|
+
quoted = self._quoted_at(text, start, offset)
|
|
603
621
|
if quoted is not None:
|
|
604
622
|
_, end = quoted
|
|
605
623
|
return (end, text[start:end])
|
|
606
624
|
|
|
625
|
+
# A versioned literal is a whole token: what follows it starts the
|
|
626
|
+
# next one, as the reference grammar (js/src/grammar.pegjs) reads it
|
|
627
|
+
literal = reference_literal_end(text, start)
|
|
628
|
+
if literal != -1:
|
|
629
|
+
return (literal, text[start:literal])
|
|
630
|
+
|
|
607
631
|
# Check if this starts with a parenthesized expression
|
|
608
632
|
if text[start] == "(":
|
|
609
|
-
end = self._find_matching_paren(text, start)
|
|
633
|
+
end = self._find_matching_paren(text, start, offset)
|
|
610
634
|
if end >= 0:
|
|
611
635
|
return (end + 1, text[start : end + 1])
|
|
612
636
|
return (len(text), text[start:])
|
|
@@ -625,44 +649,32 @@ class Parser:
|
|
|
625
649
|
def _parse_value(self, value: str, offset: int) -> Dict:
|
|
626
650
|
"""Parse a single value (could be a reference or nested link) starting at ``offset``."""
|
|
627
651
|
# Nested link in parentheses
|
|
628
|
-
if value.startswith("(") and self._find_matching_paren(value, 0) == len(value) - 1:
|
|
652
|
+
if value.startswith("(") and self._find_matching_paren(value, 0, offset) == len(value) - 1:
|
|
629
653
|
return self._parse_parenthesized(value[1:-1], offset)
|
|
630
654
|
|
|
631
655
|
# Simple reference
|
|
632
656
|
ref, raw = self._read_reference(value, offset)
|
|
633
657
|
return {"id": ref, "raw_id": raw}
|
|
634
658
|
|
|
635
|
-
def _extract_reference(self, text: str) -> str:
|
|
636
|
-
"""Extract reference, handling quoted strings with escaping support."""
|
|
637
|
-
text = text.strip()
|
|
638
|
-
|
|
639
|
-
if re.match(r"~[0-9]+\{", text):
|
|
640
|
-
return decode_reference_literal(text)
|
|
641
|
-
|
|
642
|
-
# Try delimited references (any N quotes, or a bare delimiter pair)
|
|
643
|
-
quoted = _parse_quoted_string_at(text, 0)
|
|
644
|
-
if quoted is not None:
|
|
645
|
-
return quoted[0]
|
|
646
|
-
|
|
647
|
-
# Unquoted
|
|
648
|
-
return text
|
|
649
|
-
|
|
650
659
|
def _read_reference(self, text: str, offset: int) -> Tuple[str, str]:
|
|
651
660
|
"""
|
|
652
661
|
Read the reference ``text`` written at ``offset`` (surrounding
|
|
653
662
|
whitespace allowed): what it means, and the text it was written as.
|
|
654
663
|
|
|
655
|
-
The
|
|
656
|
-
the same positions as the one with its comments blanked out.
|
|
664
|
+
The reference is read in the document as the caller wrote it, which
|
|
665
|
+
has the same positions as the one with its comments blanked out.
|
|
657
666
|
"""
|
|
658
667
|
reference = text.strip()
|
|
659
668
|
start = offset + len(text) - len(text.lstrip())
|
|
660
669
|
end = start + len(reference)
|
|
661
|
-
if
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
670
|
+
if re.match(r"~[0-9]+\{", reference):
|
|
671
|
+
return decode_reference_literal(reference), self.source[start:end]
|
|
672
|
+
# Delimited references (any N quotes, or a bare delimiter pair)
|
|
673
|
+
quoted = self._quoted_at(reference, 0, start)
|
|
674
|
+
if quoted is not None:
|
|
675
|
+
return quoted[0], self.source[start : start + quoted[1]]
|
|
676
|
+
# Unquoted
|
|
677
|
+
return reference, self.source[start:end]
|
|
666
678
|
|
|
667
679
|
def _with_raw_id(self, link: Link, item: Dict) -> Link:
|
|
668
680
|
"""
|
|
@@ -36,9 +36,10 @@ def _is_substantive_body(content: str) -> bool:
|
|
|
36
36
|
return has_visible and depth == 0
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
|
|
39
|
+
def _parse_quoted_string_at(text: str, start: int, stop: Optional[int] = None) -> Optional[tuple]:
|
|
40
40
|
"""
|
|
41
|
-
Parse the delimited reference that starts at ``start
|
|
41
|
+
Parse the delimited reference that starts at ``start``, reading no further
|
|
42
|
+
than ``stop`` (the end of ``text`` by default).
|
|
42
43
|
|
|
43
44
|
Any number N of quotes opens and closes the string, 2*N quotes are an
|
|
44
45
|
escaped quote sequence. A run of an even number of delimiters that does not
|
|
@@ -50,7 +51,8 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
|
|
|
50
51
|
right after the closing quotes, or ``None`` when ``text`` does not start a
|
|
51
52
|
delimited reference.
|
|
52
53
|
"""
|
|
53
|
-
|
|
54
|
+
limit = len(text) if stop is None else stop
|
|
55
|
+
if start >= limit:
|
|
54
56
|
return None
|
|
55
57
|
|
|
56
58
|
quote_char = text[start]
|
|
@@ -59,7 +61,7 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
|
|
|
59
61
|
|
|
60
62
|
quote_count = 0
|
|
61
63
|
pos = start
|
|
62
|
-
while pos <
|
|
64
|
+
while pos < limit and text[pos] == quote_char:
|
|
63
65
|
quote_count += 1
|
|
64
66
|
pos += 1
|
|
65
67
|
|
|
@@ -70,14 +72,14 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
|
|
|
70
72
|
escape_seq = quote_char * (quote_count * 2)
|
|
71
73
|
content = []
|
|
72
74
|
|
|
73
|
-
while pos <
|
|
74
|
-
if text.startswith(escape_seq, pos):
|
|
75
|
+
while pos < limit:
|
|
76
|
+
if text.startswith(escape_seq, pos, limit):
|
|
75
77
|
content.append(open_close)
|
|
76
78
|
pos += len(escape_seq)
|
|
77
79
|
continue
|
|
78
|
-
if text.startswith(open_close, pos):
|
|
80
|
+
if text.startswith(open_close, pos, limit):
|
|
79
81
|
after_close = pos + quote_count
|
|
80
|
-
if after_close >=
|
|
82
|
+
if after_close >= limit or text[after_close] != quote_char:
|
|
81
83
|
value = "".join(content)
|
|
82
84
|
if is_even_run and not _is_substantive_body(value):
|
|
83
85
|
return empty_reference
|
|
@@ -88,6 +90,29 @@ def _parse_quoted_string_at(text: str, start: int) -> Optional[tuple]:
|
|
|
88
90
|
return empty_reference
|
|
89
91
|
|
|
90
92
|
|
|
93
|
+
def _closing_run_follows(text: str, start: int) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Report whether a run of delimiters after the one opening at ``start``
|
|
96
|
+
closes it. An even run nothing closes is the empty reference, until a
|
|
97
|
+
closing run is written after it.
|
|
98
|
+
"""
|
|
99
|
+
quote_char = text[start]
|
|
100
|
+
count = 0
|
|
101
|
+
while start + count < len(text) and text[start + count] == quote_char:
|
|
102
|
+
count += 1
|
|
103
|
+
pos = start + count
|
|
104
|
+
while True:
|
|
105
|
+
pos = text.find(quote_char, pos)
|
|
106
|
+
if pos < 0:
|
|
107
|
+
return False
|
|
108
|
+
length = 0
|
|
109
|
+
while pos + length < len(text) and text[pos + length] == quote_char:
|
|
110
|
+
length += 1
|
|
111
|
+
if (length // count) % 2 == 1:
|
|
112
|
+
return True
|
|
113
|
+
pos += length
|
|
114
|
+
|
|
115
|
+
|
|
91
116
|
def reference_quote(raw: Optional[str]) -> Optional[str]:
|
|
92
117
|
"""
|
|
93
118
|
The delimiter a reference was written between, read from the reference as
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
|
+
#: A versioned literal ``~<digits>{<payload>}``, as the parser reads one.
|
|
6
|
+
_LITERAL = re.compile(r"~[0-9]+\{[^} \t\r\n()]*\}")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def reference_literal_end(text: str, start: int) -> int:
|
|
10
|
+
"""Where the versioned literal that starts at ``start`` ends, or -1 if none starts there."""
|
|
11
|
+
match = _LITERAL.match(text, start)
|
|
12
|
+
return -1 if match is None else match.end()
|
|
13
|
+
|
|
5
14
|
|
|
6
15
|
def encode_reference_literal(text: str) -> str:
|
|
7
16
|
"""Encode exact text as a versioned UTF-8 hexadecimal literal."""
|
|
@@ -5,7 +5,8 @@ from typing import AsyncIterable, AsyncIterator, Callable, Iterable, Iterator, L
|
|
|
5
5
|
|
|
6
6
|
from .link import Link
|
|
7
7
|
from .parser import DEFAULT_MAX_DEPTH, ParseError, Parser
|
|
8
|
-
from .quotes import QUOTE_CHARS, _parse_quoted_string_at
|
|
8
|
+
from .quotes import QUOTE_CHARS, _closing_run_follows, _parse_quoted_string_at
|
|
9
|
+
from .reference_literal import reference_literal_end
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@dataclass(frozen=True)
|
|
@@ -210,30 +211,47 @@ def _leading_spaces(line: str) -> int:
|
|
|
210
211
|
|
|
211
212
|
|
|
212
213
|
def _structurally_complete(document: str, comments: bool) -> bool:
|
|
214
|
+
# Tokens are read as the comment reader and the parser read them: a
|
|
215
|
+
# reference may start after a separator, and right after a delimited
|
|
216
|
+
# reference or a literal (issue #377).
|
|
213
217
|
depth = 0
|
|
214
218
|
position = 0
|
|
215
|
-
|
|
219
|
+
token_start = True
|
|
220
|
+
separators = " \t\n\r(:)"
|
|
216
221
|
before_comment = " \t\n\r"
|
|
217
222
|
|
|
218
223
|
while position < len(document):
|
|
219
224
|
character = document[position]
|
|
220
225
|
previous = None if position == 0 else document[position - 1]
|
|
221
|
-
if character in QUOTE_CHARS and (previous is None or previous in before_reference):
|
|
222
|
-
parsed = _parse_quoted_string_at(document, position)
|
|
223
|
-
if parsed is None:
|
|
224
|
-
return False
|
|
225
|
-
position = parsed[1]
|
|
226
|
-
continue
|
|
227
226
|
if comments and character == "#" and (previous is None or previous in before_comment):
|
|
228
227
|
newline = document.find("\n", position)
|
|
229
228
|
if newline < 0:
|
|
230
229
|
break
|
|
231
|
-
position = newline
|
|
230
|
+
position = newline
|
|
231
|
+
continue
|
|
232
|
+
if character in separators:
|
|
233
|
+
if character == "(":
|
|
234
|
+
depth += 1
|
|
235
|
+
elif character == ")":
|
|
236
|
+
depth -= 1
|
|
237
|
+
token_start = True
|
|
238
|
+
position += 1
|
|
239
|
+
continue
|
|
240
|
+
if token_start and character in QUOTE_CHARS:
|
|
241
|
+
# A reference nothing closes yet may be closed by a later line,
|
|
242
|
+
# which changes what it holds: an even run is the empty reference
|
|
243
|
+
# only when no run in the whole document closes it.
|
|
244
|
+
parsed = _parse_quoted_string_at(document, position)
|
|
245
|
+
if parsed is None or not _closing_run_follows(document, position):
|
|
246
|
+
return False
|
|
247
|
+
position = parsed[1]
|
|
232
248
|
continue
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
if token_start:
|
|
250
|
+
end = reference_literal_end(document, position)
|
|
251
|
+
if end != -1:
|
|
252
|
+
position = end
|
|
253
|
+
continue
|
|
254
|
+
token_start = False
|
|
237
255
|
position += 1
|
|
238
256
|
|
|
239
257
|
return depth == 0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: links-notation
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.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,12 +22,12 @@ Requires-Python: >=3.11
|
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
24
|
Provides-Extra: test
|
|
25
|
-
Requires-Dist: pytest>=9.
|
|
25
|
+
Requires-Dist: pytest>=9.2.0; extra == "test"
|
|
26
26
|
Requires-Dist: pytest-timeout>=2.4.0; extra == "test"
|
|
27
27
|
Provides-Extra: lint
|
|
28
28
|
Requires-Dist: black>=26.10.1; extra == "lint"
|
|
29
29
|
Requires-Dist: isort>=9.0.2; extra == "lint"
|
|
30
|
-
Requires-Dist: flake8>=7.
|
|
30
|
+
Requires-Dist: flake8>=7.5.0; extra == "lint"
|
|
31
31
|
Provides-Extra: dev
|
|
32
32
|
Requires-Dist: links-notation[lint,test]; extra == "dev"
|
|
33
33
|
Dynamic: license-file
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=84.
|
|
2
|
+
requires = ["setuptools>=84.2.1"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "links-notation"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.30.0"
|
|
8
8
|
description = "Python implementation of the Links Notation parser"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Unlicense"
|
|
@@ -29,8 +29,8 @@ requires-python = ">=3.11"
|
|
|
29
29
|
dependencies = []
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
|
-
test = ["pytest>=9.
|
|
33
|
-
lint = ["black>=26.10.1", "isort>=9.0.2", "flake8>=7.
|
|
32
|
+
test = ["pytest>=9.2.0", "pytest-timeout>=2.4.0"]
|
|
33
|
+
lint = ["black>=26.10.1", "isort>=9.0.2", "flake8>=7.5.0"]
|
|
34
34
|
dev = ["links-notation[test,lint]"]
|
|
35
35
|
|
|
36
36
|
[project.urls]
|
|
@@ -122,3 +122,32 @@ class TestFormattingAReferenceThatBeginsWithAHash:
|
|
|
122
122
|
assert Link.escape_reference("issue#1047") == "issue#1047"
|
|
123
123
|
assert Link.escape_reference("#") == "'#'"
|
|
124
124
|
assert Link.escape_reference("#ff0000") == "'#ff0000'"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestReferencesTheCommentReaderSkips:
|
|
128
|
+
"""
|
|
129
|
+
Issue #377: the comment reader and the parser agree on where every
|
|
130
|
+
delimited reference starts and how far it reaches.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def test_a_hash_inside_a_reference_that_follows_a_group_is_content(self):
|
|
134
|
+
assert rendered('(a)" #b"\n') == "((<a>) < #b>)"
|
|
135
|
+
|
|
136
|
+
def test_a_hash_inside_a_reference_that_follows_another_reference_is_content(self):
|
|
137
|
+
assert rendered("\"x\"' #y'\n") == "(<x> < #y>)"
|
|
138
|
+
assert rendered('```q```"" #c\n"""\n') == '(<q> < #c\n">)'
|
|
139
|
+
|
|
140
|
+
def test_a_hash_inside_a_reference_that_follows_a_literal_is_content(self):
|
|
141
|
+
assert rendered('~1{62}" #b"\n') == "(<b> < #b>)"
|
|
142
|
+
|
|
143
|
+
def test_a_reference_is_read_in_the_document_as_written(self):
|
|
144
|
+
# The body of the first ``""`` straddles a parenthesis, so it is the
|
|
145
|
+
# empty reference and the ``#`` after it opens a comment. Blanking that
|
|
146
|
+
# comment must not turn the ``""`` into a reference holding the blanks.
|
|
147
|
+
assert rendered('"" a #)b\n""\n') == "(<> <a>)\n(<>)"
|
|
148
|
+
|
|
149
|
+
def test_the_comment_reader_skips_the_references_the_parser_reads(self):
|
|
150
|
+
assert strip_comments('(a)" #b"\n') == '(a)" #b"\n'
|
|
151
|
+
assert strip_comments("\"x\"' #y'\n") == "\"x\"' #y'\n"
|
|
152
|
+
assert strip_comments('~1{62}" #b"\n') == '~1{62}" #b"\n'
|
|
153
|
+
assert strip_comments('a"b #c"\n') == 'a"b \n'
|
|
@@ -81,3 +81,13 @@ def test_rejects_writes_after_finish():
|
|
|
81
81
|
stream.finish("one")
|
|
82
82
|
with pytest.raises(RuntimeError, match="after finish"):
|
|
83
83
|
stream.write("two")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_waits_for_the_line_that_closes_an_even_run_of_quotes():
|
|
87
|
+
# ``""`` is the empty reference only when nothing closes it, and here the
|
|
88
|
+
# next line does, so the first line cannot be emitted on its own (#377).
|
|
89
|
+
document = 'a "" b\n"" c\n'
|
|
90
|
+
stream = StreamParser()
|
|
91
|
+
for character in document:
|
|
92
|
+
assert stream.write(character) == []
|
|
93
|
+
assert format_links(stream.finish()) == format_links(Parser().parse(document))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{links_notation-0.28.0 → links_notation-0.30.0}/links_notation.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|