backrefs 8.2__tar.gz → 8.2.1__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.
- {backrefs-8.2 → backrefs-8.2.1}/PKG-INFO +1 -1
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/__meta__.py +1 -1
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/_bre_parse.py +129 -19
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/_bregex_parse.py +176 -91
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/bre.py +3 -2
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/bregex.py +3 -2
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/changelog.md +21 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_bre.py +137 -8
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_bregex.py +108 -4
- backrefs-8.2/tools/unicodedata/13.0.0.zip +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/.coveragerc +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/.gitignore +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/.pyspelling.yml +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/LICENSE.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/README.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/__init__.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/_bregex_typing.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/py.typed +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/uniprops/__init__.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/backrefs/util.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/dictionary/en-custom.txt +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/dictionary/en-python.txt +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/.snippets/links.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/contributing.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/license.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/security.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/index.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/refs.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/usage.md +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/docs/theme/announce.html +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/hatch_build.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/pyproject.toml +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/__init__.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_age.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_bidiclass.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_bidipairedbrackettype.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_binary.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_block.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_canonicalcombiningclass.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_decompositiontype.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_eastasianwidth.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_generalcategory.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_graphemeclusterbreak.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_hangulsyllabletype.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_indicpositionalcategory.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_indicsylabiccategory.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_joininggroup.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_joiningtype.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_linebreak.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_numerictype.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_numericvalue.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_quickcheck.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_script.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_scriptextensions.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_sentencebreak.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_uniprops.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_versions.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_verticalorientation.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tests/test_wordbreak.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/__init__.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/pycon.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/14.0.0.zip +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/15.0.0.zip +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/15.1.0.zip +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/16.0.0.zip +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/17.0.0.zip +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/LICENSE +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unidatadownload.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/tools/unipropgen.py +0 -0
- {backrefs-8.2 → backrefs-8.2.1}/zensical.yml +0 -0
|
@@ -193,5 +193,5 @@ def parse_version(ver: str) -> Version:
|
|
|
193
193
|
return Version(major, minor, micro, release, pre, post, dev)
|
|
194
194
|
|
|
195
195
|
|
|
196
|
-
__version_info__ = Version(8, 2,
|
|
196
|
+
__version_info__ = Version(8, 2, 1, "final")
|
|
197
197
|
__version__ = __version_info__._get_canonical()
|
|
@@ -6,18 +6,13 @@ Copyright (c) 2011 - 2020 Isaac Muse <isaacmuse@gmail.com>
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
import re as _re
|
|
9
|
-
import
|
|
9
|
+
import re._parser as _parser # type: ignore[import]
|
|
10
10
|
import copyreg as _copyreg
|
|
11
11
|
from . import util as _util
|
|
12
12
|
from .util import PatternError
|
|
13
13
|
from . import uniprops as _uniprops
|
|
14
14
|
from typing import Generic, AnyStr, Match, Any, Pattern, cast
|
|
15
15
|
|
|
16
|
-
if sys.version_info >= (3, 11):
|
|
17
|
-
import re._parser as _parser # type: ignore[import]
|
|
18
|
-
else:
|
|
19
|
-
import sre_parse as _parser
|
|
20
|
-
|
|
21
16
|
__all__ = ("ReplaceTemplate", "PatternError")
|
|
22
17
|
|
|
23
18
|
_ASCII_LETTERS = frozenset(
|
|
@@ -76,11 +71,12 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
76
71
|
_re_start_wb = r"\b(?=\w)"
|
|
77
72
|
_re_end_wb = r"\b(?<=\w)"
|
|
78
73
|
_line_break = r'(?:\r\n|(?!\r\n)[\n\v\f\r\x85\u2028\u2029])'
|
|
79
|
-
_bytes_line_break = r'(?>\r\n|[\n\v\f\r\x85])'
|
|
74
|
+
_bytes_line_break = r'(?>\r\n|[\n\v\f\r\x85])'
|
|
80
75
|
_grapheme_cluster = r'(?:{}{}*(?!{}))'
|
|
81
76
|
|
|
82
77
|
verbose: bool
|
|
83
78
|
unicode: bool
|
|
79
|
+
named_unicode: tuple[str, int]
|
|
84
80
|
ascii: bool # noqa: A003
|
|
85
81
|
is_bytes: bool
|
|
86
82
|
search: AnyStr
|
|
@@ -283,6 +279,7 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
283
279
|
current.extend(self.unicode_name(text, in_group))
|
|
284
280
|
if in_group:
|
|
285
281
|
self.found_named_unicode = True
|
|
282
|
+
self.named_unicode = (f'\\N{{{text}}}', ord(_util.unicode_char(text)))
|
|
286
283
|
else:
|
|
287
284
|
current.extend(["\\", t])
|
|
288
285
|
return current
|
|
@@ -418,27 +415,55 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
418
415
|
self.verbose = verbose
|
|
419
416
|
self.unicode = unicode_flag
|
|
420
417
|
|
|
421
|
-
|
|
422
|
-
current.append(t)
|
|
418
|
+
current.append(t)
|
|
423
419
|
return current
|
|
424
420
|
|
|
425
421
|
def char_groups(self, t: str, i: _util.StringIter) -> list[str]:
|
|
426
422
|
"""Handle character groups."""
|
|
427
423
|
|
|
428
|
-
current = []
|
|
424
|
+
current = [] # type: list[str]
|
|
429
425
|
pos = i.index - 1
|
|
430
426
|
found = False
|
|
431
427
|
escaped = False
|
|
428
|
+
escape_index = 0
|
|
432
429
|
first = 0
|
|
433
430
|
found_property = False
|
|
431
|
+
# Index of an unescaped `-` in `current` that is acting as a range operator.
|
|
432
|
+
range_dash = -1
|
|
433
|
+
# Whether the last member could start a range if followed by `-`.
|
|
434
|
+
range_start = False
|
|
435
|
+
# The last member's original text, position, and code point if it is a named character
|
|
436
|
+
# beyond the byte range of a byte string.
|
|
437
|
+
start = ('', 0, None) # type: tuple[str, int, int | None]
|
|
434
438
|
self.found_property = False
|
|
435
439
|
self.found_named_unicode = False
|
|
436
440
|
|
|
441
|
+
def add_member(raw: str, index: int, value: int | None) -> None:
|
|
442
|
+
"""Track a member that was just added, which may start or end a range."""
|
|
443
|
+
|
|
444
|
+
nonlocal range_dash, range_start, start
|
|
445
|
+
|
|
446
|
+
if range_dash != -1:
|
|
447
|
+
# This member ends a range.
|
|
448
|
+
start_raw, start_index, start_value = start
|
|
449
|
+
if start_value is not None or value is not None:
|
|
450
|
+
end = self.get_named_byte_range(start_raw, raw, start_value, value, start_index)
|
|
451
|
+
if end is None:
|
|
452
|
+
current[range_dash] = ''
|
|
453
|
+
else:
|
|
454
|
+
current[-1] = end
|
|
455
|
+
range_dash = -1
|
|
456
|
+
range_start = False
|
|
457
|
+
else:
|
|
458
|
+
start = (raw, index, value)
|
|
459
|
+
range_start = True
|
|
460
|
+
|
|
437
461
|
try:
|
|
438
462
|
while True:
|
|
439
463
|
# Prevent POSIX/Unicode class from being part of a range.
|
|
440
464
|
if self.found_property and t == '-':
|
|
441
465
|
current.append(_re.escape(t))
|
|
466
|
+
add_member(t, i.index - 1, None)
|
|
442
467
|
pos += 1
|
|
443
468
|
t = next(i)
|
|
444
469
|
self.found_property = False
|
|
@@ -448,15 +473,26 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
448
473
|
|
|
449
474
|
if not escaped and t == "\\":
|
|
450
475
|
escaped = True
|
|
476
|
+
escape_index = i.index - 1
|
|
451
477
|
elif escaped:
|
|
452
478
|
escaped = False
|
|
453
479
|
idx = len(current) - 1
|
|
454
480
|
current.extend(self.reference(t, i, True))
|
|
455
481
|
if self.found_property:
|
|
456
482
|
# Prevent Unicode class from being part of a range.
|
|
457
|
-
if idx
|
|
483
|
+
if idx == range_dash:
|
|
458
484
|
current[idx] = _re.escape('-')
|
|
459
485
|
found_property = True
|
|
486
|
+
range_dash = -1
|
|
487
|
+
range_start = False
|
|
488
|
+
elif t == 'N':
|
|
489
|
+
raw, code = self.named_unicode
|
|
490
|
+
add_member(raw, escape_index, code if self.is_bytes and code > 0xFF else None)
|
|
491
|
+
else:
|
|
492
|
+
digits = self.get_escape_digits(i, t)
|
|
493
|
+
if digits:
|
|
494
|
+
current.append(digits)
|
|
495
|
+
add_member(f'\\{t}{digits}', escape_index, None)
|
|
460
496
|
elif t == "[" and not found:
|
|
461
497
|
found = True
|
|
462
498
|
first = pos
|
|
@@ -470,22 +506,43 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
470
506
|
i.rewind(i.index - index)
|
|
471
507
|
if prop is not None:
|
|
472
508
|
value = self.unicode_props(prop[0], prop[1], in_group=True)
|
|
473
|
-
|
|
509
|
+
# Prevent POSIX class from being part of a range.
|
|
510
|
+
if len(current) - 1 == range_dash:
|
|
474
511
|
current[-1] = _re.escape('-')
|
|
475
512
|
current.extend(value)
|
|
476
513
|
found_property = True
|
|
514
|
+
self.found_property = True
|
|
477
515
|
pos = i.index - 2
|
|
516
|
+
range_dash = -1
|
|
517
|
+
range_start = False
|
|
478
518
|
else:
|
|
479
519
|
current.append(t)
|
|
480
|
-
|
|
520
|
+
add_member(t, index - 1, None)
|
|
521
|
+
elif t == "^" and found and (pos == first + 1) and len(current) == 1:
|
|
522
|
+
# Only a `^` directly after the opening `[` negates the set.
|
|
481
523
|
first = pos
|
|
482
524
|
current.append(t)
|
|
483
525
|
elif t == "]" and found and (pos != first + 1):
|
|
484
526
|
found = False
|
|
485
527
|
current.append(t)
|
|
486
528
|
break
|
|
487
|
-
|
|
529
|
+
elif t == '-' and range_start:
|
|
530
|
+
# A `-` after a member starts a range, unless it is followed by `]`.
|
|
488
531
|
current.append(t)
|
|
532
|
+
range_dash = len(current) - 1
|
|
533
|
+
range_start = False
|
|
534
|
+
else:
|
|
535
|
+
if (
|
|
536
|
+
t == '^' and
|
|
537
|
+
(found_property or self.found_named_unicode) and
|
|
538
|
+
''.join(current) in ('[', '[^')
|
|
539
|
+
):
|
|
540
|
+
# Everything before this was a property that resolved to nothing,
|
|
541
|
+
# so escape `^` to prevent it from being treated as a negation.
|
|
542
|
+
current.append(_re.escape(t))
|
|
543
|
+
else:
|
|
544
|
+
current.append(t)
|
|
545
|
+
add_member(t, i.index - 1, None)
|
|
489
546
|
pos += 1
|
|
490
547
|
t = next(i)
|
|
491
548
|
except StopIteration as e:
|
|
@@ -540,6 +597,60 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
540
597
|
return ['']
|
|
541
598
|
return [f'\\{value:03o}' if value <= 0xFF else chr(value)]
|
|
542
599
|
|
|
600
|
+
def get_escape_digits(self, i: _util.StringIter, e: str) -> str:
|
|
601
|
+
r"""
|
|
602
|
+
Get the digits that are part of an escape in a character set (`\x`, `\u`, `\U`, or octal).
|
|
603
|
+
|
|
604
|
+
This allows an escape to be treated as a single member of the character set.
|
|
605
|
+
"""
|
|
606
|
+
|
|
607
|
+
if e == 'x':
|
|
608
|
+
count, valid = 2, _HEX
|
|
609
|
+
elif e == 'u' and not self.is_bytes:
|
|
610
|
+
count, valid = 4, _HEX
|
|
611
|
+
elif e == 'U' and not self.is_bytes:
|
|
612
|
+
count, valid = 8, _HEX
|
|
613
|
+
elif e in _OCTAL:
|
|
614
|
+
count, valid = 2, _OCTAL
|
|
615
|
+
else:
|
|
616
|
+
return ''
|
|
617
|
+
|
|
618
|
+
digits = []
|
|
619
|
+
while count:
|
|
620
|
+
try:
|
|
621
|
+
c = next(i)
|
|
622
|
+
except StopIteration:
|
|
623
|
+
break
|
|
624
|
+
if c.lower() not in valid:
|
|
625
|
+
i.rewind(1)
|
|
626
|
+
break
|
|
627
|
+
digits.append(c)
|
|
628
|
+
count -= 1
|
|
629
|
+
return ''.join(digits)
|
|
630
|
+
|
|
631
|
+
@staticmethod
|
|
632
|
+
def get_named_byte_range(
|
|
633
|
+
start: str,
|
|
634
|
+
end: str,
|
|
635
|
+
start_value: int | None,
|
|
636
|
+
end_value: int | None,
|
|
637
|
+
index: int
|
|
638
|
+
) -> str | None:
|
|
639
|
+
"""
|
|
640
|
+
Get the end of a range in a byte string where either end is a named character beyond the byte range.
|
|
641
|
+
|
|
642
|
+
`start_value` and `end_value` are the code points of the ends that are named characters beyond the byte
|
|
643
|
+
range, and `None` for ends within the byte range. `start` and `end` are the original text of each end,
|
|
644
|
+
and `index` is where the range starts, both used for errors.
|
|
645
|
+
|
|
646
|
+
A range that ends beyond the byte range includes everything up to the end of the byte range, so return
|
|
647
|
+
the end to use in its place. A range that starts beyond the byte range matches nothing, so return `None`.
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
if start_value is not None and (end_value is None or end_value < start_value):
|
|
651
|
+
raise PatternError(f'bad character range {start}-{end} at position {index}')
|
|
652
|
+
return None if start_value is not None else '\\377'
|
|
653
|
+
|
|
543
654
|
def unicode_props(
|
|
544
655
|
self,
|
|
545
656
|
props: str,
|
|
@@ -1183,7 +1294,7 @@ class _ReplaceParser(Generic[AnyStr]):
|
|
|
1183
1294
|
count = len(self.span_stack)
|
|
1184
1295
|
self.end_found = False
|
|
1185
1296
|
try:
|
|
1186
|
-
while
|
|
1297
|
+
while True:
|
|
1187
1298
|
t = next(i)
|
|
1188
1299
|
if self.use_format and t in _CURLY_BRACKETS:
|
|
1189
1300
|
self.handle_format(t, i)
|
|
@@ -1236,10 +1347,9 @@ class _ReplaceParser(Generic[AnyStr]):
|
|
|
1236
1347
|
except StopIteration:
|
|
1237
1348
|
self.result.append(t)
|
|
1238
1349
|
raise
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
self.result.append(self.convert_case(t, this_case))
|
|
1350
|
+
else:
|
|
1351
|
+
# The stack always has the case just added.
|
|
1352
|
+
self.result.append(self.convert_case(t, cast(int, self.get_single_stack())))
|
|
1243
1353
|
except StopIteration:
|
|
1244
1354
|
pass
|
|
1245
1355
|
|
|
@@ -33,8 +33,16 @@ _LETTERS_UNDERSCORE = _ASCII_LETTERS | frozenset(('_',))
|
|
|
33
33
|
_WORD = _LETTERS_UNDERSCORE | _DIGIT
|
|
34
34
|
_STANDARD_ESCAPES = frozenset(('a', 'b', 'f', 'n', 'r', 't', 'v'))
|
|
35
35
|
_CURLY_BRACKETS = frozenset(('{', '}'))
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
# Character set grammar, as defined by Regex.
|
|
37
|
+
_ALNUM = _ASCII_LETTERS | _DIGIT
|
|
38
|
+
_PROPERTY_NAME = _ALNUM | frozenset((' ', '&', '_', '-', '.'))
|
|
39
|
+
_PROPERTY_VALUE = _ALNUM | frozenset((' ', '&', '_', '-', '.', '/'))
|
|
40
|
+
_NAMED_CHAR = _ALNUM | frozenset((' ', '-'))
|
|
41
|
+
_HEX_DIGITS = _HEX | frozenset(c.upper() for c in _HEX)
|
|
42
|
+
_HEX_ESCAPES = {'x': 2, 'u': 4, 'U': 8}
|
|
43
|
+
_SHORT_PROPERTIES = frozenset(('C', 'L', 'M', 'N', 'P', 'S', 'Z'))
|
|
44
|
+
_CLASS_ESCAPES = frozenset(('d', 'D', 'h', 's', 'S', 'w', 'W'))
|
|
45
|
+
_SET_OPS = frozenset(('||', '~~', '&&', '--'))
|
|
38
46
|
_GLOBAL_FLAGS = frozenset(('b', 'e', 'p', 'r', 'V'))
|
|
39
47
|
_SCOPED_FLAGS = frozenset(('a', 'f', 'i', 'L', 'm', 's', 'u', 'w', 'x'))
|
|
40
48
|
_VERSIONS = frozenset(('0', '1'))
|
|
@@ -72,6 +80,7 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
72
80
|
version: int
|
|
73
81
|
is_bytes: bool
|
|
74
82
|
search: AnyStr
|
|
83
|
+
text: str
|
|
75
84
|
|
|
76
85
|
def __init__(self, search: AnyStr, re_verbose: bool = False, re_version: int = 0) -> None:
|
|
77
86
|
"""Initialize."""
|
|
@@ -167,37 +176,6 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
167
176
|
current.extend(["\\", t])
|
|
168
177
|
return current
|
|
169
178
|
|
|
170
|
-
def get_posix(self, i: _util.StringIter) -> str | None:
|
|
171
|
-
"""Get POSIX."""
|
|
172
|
-
|
|
173
|
-
index = i.index
|
|
174
|
-
value = ['[']
|
|
175
|
-
try:
|
|
176
|
-
c = next(i)
|
|
177
|
-
if c != ':':
|
|
178
|
-
raise PatternError('Not a valid property!')
|
|
179
|
-
else:
|
|
180
|
-
value.append(c)
|
|
181
|
-
c = next(i)
|
|
182
|
-
if c == '^':
|
|
183
|
-
value.append(c)
|
|
184
|
-
c = next(i)
|
|
185
|
-
while c != ':':
|
|
186
|
-
if c not in _PROPERTY:
|
|
187
|
-
raise PatternError('Not a valid property!')
|
|
188
|
-
if c not in _PROPERTY_STRIP:
|
|
189
|
-
value.append(c)
|
|
190
|
-
c = next(i)
|
|
191
|
-
value.append(c)
|
|
192
|
-
c = next(i)
|
|
193
|
-
if c != ']' or not value:
|
|
194
|
-
raise PatternError('Unmatched ]')
|
|
195
|
-
value.append(c)
|
|
196
|
-
except Exception:
|
|
197
|
-
i.rewind(i.index - index)
|
|
198
|
-
value = []
|
|
199
|
-
return ''.join(value) if value else None
|
|
200
|
-
|
|
201
179
|
def get_comments(self, i: _util.StringIter) -> str | None:
|
|
202
180
|
"""Get comments."""
|
|
203
181
|
|
|
@@ -342,69 +320,175 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
342
320
|
current.append(t)
|
|
343
321
|
return current
|
|
344
322
|
|
|
323
|
+
@staticmethod
|
|
324
|
+
def peek(i: _util.StringIter, count: int = 1) -> str:
|
|
325
|
+
"""Get up to the next `count` characters without consuming them."""
|
|
326
|
+
|
|
327
|
+
chars = []
|
|
328
|
+
for _ in range(count):
|
|
329
|
+
try:
|
|
330
|
+
chars.append(next(i))
|
|
331
|
+
except StopIteration:
|
|
332
|
+
break
|
|
333
|
+
i.rewind(len(chars))
|
|
334
|
+
return ''.join(chars)
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def match(i: _util.StringIter, text: str) -> bool:
|
|
338
|
+
"""Consume `text` if it is next."""
|
|
339
|
+
|
|
340
|
+
index = i.index
|
|
341
|
+
for c in text:
|
|
342
|
+
try:
|
|
343
|
+
if next(i) == c:
|
|
344
|
+
continue
|
|
345
|
+
except StopIteration:
|
|
346
|
+
pass
|
|
347
|
+
i.rewind(i.index - index)
|
|
348
|
+
return False
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
@staticmethod
|
|
352
|
+
def consume(i: _util.StringIter, chars: frozenset[str], limit: int = -1) -> str:
|
|
353
|
+
"""Consume characters while they are in `chars`, up to `limit` characters if given."""
|
|
354
|
+
|
|
355
|
+
value = []
|
|
356
|
+
while limit:
|
|
357
|
+
try:
|
|
358
|
+
c = next(i)
|
|
359
|
+
except StopIteration:
|
|
360
|
+
break
|
|
361
|
+
if c not in chars:
|
|
362
|
+
i.rewind(1)
|
|
363
|
+
break
|
|
364
|
+
value.append(c)
|
|
365
|
+
limit -= 1
|
|
366
|
+
return ''.join(value)
|
|
367
|
+
|
|
345
368
|
def char_groups(self, t: str, i: _util.StringIter) -> list[str]:
|
|
346
369
|
"""Handle character groups."""
|
|
347
370
|
|
|
348
|
-
|
|
349
|
-
pos = i.index - 1
|
|
350
|
-
found = 0
|
|
351
|
-
sub_first = 0
|
|
352
|
-
escaped = False
|
|
353
|
-
first = 0
|
|
354
|
-
|
|
371
|
+
start = i.index - 1
|
|
355
372
|
try:
|
|
356
|
-
|
|
357
|
-
if not escaped and t == "\\":
|
|
358
|
-
escaped = True
|
|
359
|
-
elif escaped:
|
|
360
|
-
escaped = False
|
|
361
|
-
current.extend(self.reference(t, i, True))
|
|
362
|
-
elif t == "[" and not found:
|
|
363
|
-
found += 1
|
|
364
|
-
first = pos
|
|
365
|
-
current.append(t)
|
|
366
|
-
elif t == "[" and found and self.version == _regex.V1:
|
|
367
|
-
# Start of sub char set found
|
|
368
|
-
posix = None if self.is_bytes else self.get_posix(i)
|
|
369
|
-
if posix:
|
|
370
|
-
current.append(posix)
|
|
371
|
-
pos = i.index - 2
|
|
372
|
-
else:
|
|
373
|
-
found += 1
|
|
374
|
-
sub_first = pos
|
|
375
|
-
current.append(t)
|
|
376
|
-
elif t == "[":
|
|
377
|
-
posix = None if self.is_bytes else self.get_posix(i)
|
|
378
|
-
if posix:
|
|
379
|
-
current.append(posix)
|
|
380
|
-
pos = i.index - 2
|
|
381
|
-
else:
|
|
382
|
-
current.append(t)
|
|
383
|
-
elif t == "^" and found == 1 and (pos == first + 1):
|
|
384
|
-
# Found ^ at start of first char set; adjust 1st char position
|
|
385
|
-
current.append(t)
|
|
386
|
-
first = pos
|
|
387
|
-
elif self.version == _regex.V1 and t == "^" and found > 1 and (pos == sub_first + 1):
|
|
388
|
-
# Found ^ at start of sub char set; adjust 1st char sub position
|
|
389
|
-
current.append(t)
|
|
390
|
-
sub_first = pos
|
|
391
|
-
elif t == "]" and found == 1 and (pos != first + 1):
|
|
392
|
-
# First char set closed; log range
|
|
393
|
-
current.append(t)
|
|
394
|
-
found = 0
|
|
395
|
-
break
|
|
396
|
-
elif self.version == _regex.V1 and t == "]" and found > 1 and (pos != sub_first + 1):
|
|
397
|
-
# Sub char set closed; decrement depth counter
|
|
398
|
-
found -= 1
|
|
399
|
-
current.append(t)
|
|
400
|
-
else:
|
|
401
|
-
current.append(t)
|
|
402
|
-
pos += 1
|
|
403
|
-
t = next(i)
|
|
373
|
+
self.parse_set(i)
|
|
404
374
|
except StopIteration as e:
|
|
405
|
-
raise PatternError(f"Missing ']', unterminated character set at position {
|
|
375
|
+
raise PatternError(f"Missing ']', unterminated character set at position {start}") from e
|
|
376
|
+
|
|
377
|
+
return [self.text[start:i.index]]
|
|
378
|
+
|
|
379
|
+
def parse_set(self, i: _util.StringIter) -> None:
|
|
380
|
+
"""Parse a character set, starting after its opening `[`, through its closing `]`."""
|
|
381
|
+
|
|
382
|
+
self.match(i, '^')
|
|
383
|
+
while True:
|
|
384
|
+
self.parse_set_operand(i)
|
|
385
|
+
if self.version == _regex.V1 and self.peek(i, 2) in _SET_OPS:
|
|
386
|
+
next(i)
|
|
387
|
+
next(i)
|
|
388
|
+
continue
|
|
389
|
+
# The operand only ends at an operator or `]`.
|
|
390
|
+
next(i)
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
def parse_set_operand(self, i: _util.StringIter) -> None:
|
|
394
|
+
"""Parse the members of a set (or of an operand in a set operation) up to an operator or `]`."""
|
|
395
|
+
|
|
396
|
+
# The first member is always a member, even if it is `]`.
|
|
397
|
+
self.parse_set_member(i)
|
|
398
|
+
while True:
|
|
399
|
+
c = self.peek(i, 2)
|
|
400
|
+
if not c:
|
|
401
|
+
raise StopIteration
|
|
402
|
+
if c[0] == ']' or (self.version == _regex.V1 and c in _SET_OPS):
|
|
403
|
+
return
|
|
404
|
+
self.parse_set_member(i)
|
|
405
|
+
|
|
406
|
+
def parse_set_member(self, i: _util.StringIter) -> None:
|
|
407
|
+
"""Parse a member of a character set, which may be a range."""
|
|
408
|
+
|
|
409
|
+
if not self.parse_set_item(i) or self.peek(i) != '-':
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
after = self.peek(i, 2)
|
|
413
|
+
if self.version == _regex.V1 and after == '--':
|
|
414
|
+
# A set difference, not a range.
|
|
415
|
+
return
|
|
416
|
+
next(i)
|
|
417
|
+
if after == '-]':
|
|
418
|
+
# A literal `-` at the end of the set.
|
|
419
|
+
return
|
|
420
|
+
self.parse_set_item(i)
|
|
421
|
+
|
|
422
|
+
def parse_set_item(self, i: _util.StringIter) -> bool:
|
|
423
|
+
"""Parse a single item of a character set, and return whether it is a single character."""
|
|
406
424
|
|
|
407
|
-
|
|
425
|
+
c = next(i)
|
|
426
|
+
if c == '\\':
|
|
427
|
+
return self.parse_set_escape(i)
|
|
428
|
+
if c == '[':
|
|
429
|
+
if self.parse_posix(i):
|
|
430
|
+
return False
|
|
431
|
+
if self.version == _regex.V1:
|
|
432
|
+
# A nested set.
|
|
433
|
+
self.parse_set(i)
|
|
434
|
+
return False
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
def parse_set_escape(self, i: _util.StringIter) -> bool:
|
|
438
|
+
"""Parse an escape in a character set, and return whether it is a single character."""
|
|
439
|
+
|
|
440
|
+
c = next(i)
|
|
441
|
+
if c in _HEX_ESCAPES:
|
|
442
|
+
self.consume(i, _HEX_DIGITS, _HEX_ESCAPES[c])
|
|
443
|
+
elif c == 'N':
|
|
444
|
+
# A named character, or otherwise a literal `N`.
|
|
445
|
+
index = i.index
|
|
446
|
+
if self.match(i, '{'):
|
|
447
|
+
self.consume(i, _NAMED_CHAR)
|
|
448
|
+
if self.match(i, '}'):
|
|
449
|
+
return True
|
|
450
|
+
i.rewind(i.index - index)
|
|
451
|
+
elif c in ('p', 'P'):
|
|
452
|
+
index = i.index
|
|
453
|
+
if self.match(i, '{'):
|
|
454
|
+
self.match(i, '^')
|
|
455
|
+
self.parse_property_name(i)
|
|
456
|
+
if self.match(i, '}'):
|
|
457
|
+
return False
|
|
458
|
+
# Not a property, so a literal `p` or `P`.
|
|
459
|
+
i.rewind(i.index - index)
|
|
460
|
+
elif self.peek(i) in _SHORT_PROPERTIES:
|
|
461
|
+
next(i)
|
|
462
|
+
return False
|
|
463
|
+
elif c in _CLASS_ESCAPES:
|
|
464
|
+
return False
|
|
465
|
+
elif c in _DIGIT:
|
|
466
|
+
self.consume(i, _OCTAL, 2)
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
def parse_posix(self, i: _util.StringIter) -> bool:
|
|
470
|
+
"""Parse a POSIX class after an opening `[`, or consume nothing if it isn't one."""
|
|
471
|
+
|
|
472
|
+
index = i.index
|
|
473
|
+
if self.match(i, ':'):
|
|
474
|
+
self.match(i, '^')
|
|
475
|
+
self.parse_property_name(i)
|
|
476
|
+
if self.match(i, ':]'):
|
|
477
|
+
return True
|
|
478
|
+
i.rewind(i.index - index)
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
def parse_property_name(self, i: _util.StringIter) -> None:
|
|
482
|
+
"""Parse a property name, which may be qualified with a value: `name`, `name=value`, or `name:value`."""
|
|
483
|
+
|
|
484
|
+
self.consume(i, _PROPERTY_NAME)
|
|
485
|
+
index = i.index
|
|
486
|
+
if self.peek(i) in (':', '='):
|
|
487
|
+
next(i)
|
|
488
|
+
if self.consume(i, _PROPERTY_VALUE).strip():
|
|
489
|
+
return
|
|
490
|
+
# No value, so the name is unqualified.
|
|
491
|
+
i.rewind(i.index - index)
|
|
408
492
|
|
|
409
493
|
def normal(self, t: str, i: _util.StringIter) -> list[str]:
|
|
410
494
|
"""Handle normal chars."""
|
|
@@ -445,7 +529,8 @@ class _SearchParser(Generic[AnyStr]):
|
|
|
445
529
|
self.verbose = bool(self.re_verbose)
|
|
446
530
|
self.version = self.re_version if self.re_version else _regex.DEFAULT_VERSION
|
|
447
531
|
new_pattern = []
|
|
448
|
-
|
|
532
|
+
self.text = self.process_quotes(search)
|
|
533
|
+
i = _util.StringIter(self.text)
|
|
449
534
|
new_pattern = self.main_group(i)
|
|
450
535
|
return "".join(new_pattern)
|
|
451
536
|
|
|
@@ -267,8 +267,9 @@ class Bre(_util.Immutable, Generic[AnyStr]):
|
|
|
267
267
|
if isinstance(template, ReplaceTemplate):
|
|
268
268
|
if use_format != template.use_format:
|
|
269
269
|
raise ValueError("Compiled replace cannot be a format object!")
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
return self.compile(template)
|
|
271
|
+
elif isinstance(template, (str, bytes)) and self.auto_compile:
|
|
272
|
+
return self.compile(template, (FORMAT if use_format else 0))
|
|
272
273
|
elif isinstance(template, (str, bytes)) and use_format:
|
|
273
274
|
# Reject an attempt to run format replace when auto-compiling
|
|
274
275
|
# of template strings has been disabled and we are using a
|
|
@@ -282,8 +282,9 @@ class Bregex(_util.Immutable, Generic[AnyStr]):
|
|
|
282
282
|
if isinstance(template, ReplaceTemplate):
|
|
283
283
|
if use_format != template.use_format:
|
|
284
284
|
raise ValueError("Compiled replace cannot be a format object!")
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
return self.compile(template)
|
|
286
|
+
elif isinstance(template, (str, bytes)) and self.auto_compile:
|
|
287
|
+
return self.compile(template, (FORMAT if use_format else 0))
|
|
287
288
|
elif isinstance(template, (str, bytes)) and use_format:
|
|
288
289
|
# Reject an attempt to run format replace when auto-compiling
|
|
289
290
|
# of template strings has been disabled and we are using a
|
|
@@ -3,6 +3,27 @@ icon: lucide/scroll-text
|
|
|
3
3
|
---
|
|
4
4
|
# Changelog
|
|
5
5
|
|
|
6
|
+
## 8.2.1
|
|
7
|
+
|
|
8
|
+
- **FIX**: Reject compiled replace templates from another search pattern in compiled search objects,
|
|
9
|
+
matching module-level substitutions and preventing incorrect capture references.
|
|
10
|
+
- **FIX**: `bre`: Fix a `-` after a POSIX class in a character set sometimes forming a range (`[[:blank:]-z]`).
|
|
11
|
+
- **FIX**: `bre`: Fix an escaped `-` before a POSIX or Unicode class in a character set being turned into a
|
|
12
|
+
range operator (`[\-\p{N}]`).
|
|
13
|
+
- **FIX**: `bre`: Fix `^` being treated as a negation when it follows properties in a character set that match
|
|
14
|
+
nothing, such as `\p{Han}` in a byte string (`[\p{Han}^a]`).
|
|
15
|
+
- **FIX**: `bre`: Fix ranges in byte string character sets with a named character beyond the byte range
|
|
16
|
+
(`[a-\N{CJK UNIFIED IDEOGRAPH-4E00}]`). They now match Regex: a range that ends beyond the byte range includes
|
|
17
|
+
everything up to `\xff`, and a range that starts beyond it matches nothing, or is an error if it is reversed.
|
|
18
|
+
- **FIX**: `bre`: Fix a literal `^` after the negating `^` in a character set (`[^^]`) causing an unterminated
|
|
19
|
+
character set error.
|
|
20
|
+
- **FIX**: `bregex`: Fix finding the end of character sets, which could cause false errors or wrongly handle what
|
|
21
|
+
follows a set (such as `\R`, groups, or verbose comments). Character sets now follow Regex's own rules:
|
|
22
|
+
- POSIX classes are recognized in byte strings, and with qualified names or values (`[[:script=latin:]]`).
|
|
23
|
+
- In V1, a `]` directly after a set operator is a literal (`[\w--]]`).
|
|
24
|
+
- A literal `^` after the negating `^` (`[^^]`) no longer causes an unterminated character set error.
|
|
25
|
+
- **FIX**: `bregex`: POSIX classes in character sets are no longer rewritten before being passed to Regex.
|
|
26
|
+
|
|
6
27
|
## 8.2
|
|
7
28
|
|
|
8
29
|
- **NEW**: Drop Python 3.10.
|
|
@@ -4,21 +4,15 @@ import unittest
|
|
|
4
4
|
from backrefs import bre
|
|
5
5
|
from backrefs.util import PatternError
|
|
6
6
|
import re
|
|
7
|
+
import re._constants as _constants
|
|
7
8
|
import sys
|
|
8
9
|
import pytest
|
|
9
10
|
import random
|
|
10
11
|
from backrefs import _bre_parse
|
|
11
12
|
import copy
|
|
12
13
|
|
|
13
|
-
PY39_PLUS = (3, 9) <= sys.version_info
|
|
14
|
-
PY311_PLUS = (3, 11) <= sys.version_info
|
|
15
14
|
PY313_PLUS = (3, 13) <= sys.version_info
|
|
16
15
|
|
|
17
|
-
if PY311_PLUS:
|
|
18
|
-
import re._constants as _constants
|
|
19
|
-
else:
|
|
20
|
-
import sre_constants as _constants
|
|
21
|
-
|
|
22
16
|
|
|
23
17
|
class TestSearchTemplate(unittest.TestCase):
|
|
24
18
|
"""Search template tests."""
|
|
@@ -89,6 +83,40 @@ class TestSearchTemplate(unittest.TestCase):
|
|
|
89
83
|
self.assertTrue(bre.match(r'[\p{HorizSpace}-Z]', '\x20') is not None)
|
|
90
84
|
self.assertTrue(bre.match(r'[\p{HorizSpace}-Z]', '-') is not None)
|
|
91
85
|
|
|
86
|
+
def test_posix_followed_by_hyphen(self):
|
|
87
|
+
"""A `-` after a POSIX class whose expansion ends in a single character should not form a range."""
|
|
88
|
+
|
|
89
|
+
for pattern in (r'[[:blank:]-z]', r'[[:space:]-~]'):
|
|
90
|
+
p = bre.compile(pattern)
|
|
91
|
+
self.assertTrue(p.fullmatch(' ') is not None)
|
|
92
|
+
self.assertTrue(p.fullmatch('-') is not None)
|
|
93
|
+
self.assertTrue(p.fullmatch(pattern[-2]) is not None)
|
|
94
|
+
self.assertTrue(p.fullmatch('a') is None)
|
|
95
|
+
|
|
96
|
+
p = bre.compile(r'[[:space:]--]')
|
|
97
|
+
self.assertTrue(p.fullmatch(' ') is not None)
|
|
98
|
+
self.assertTrue(p.fullmatch('-') is not None)
|
|
99
|
+
|
|
100
|
+
def test_escaped_hyphen_before_property(self):
|
|
101
|
+
"""An escaped `-` before a POSIX or Unicode class is a literal, not a range operator."""
|
|
102
|
+
|
|
103
|
+
for pattern in (r'[\-\p{N}]', r'[\-[:digit:]]', r'[\Q-\E\p{N}]'):
|
|
104
|
+
p = bre.compile(pattern)
|
|
105
|
+
self.assertTrue(p.fullmatch('-') is not None)
|
|
106
|
+
self.assertTrue(p.fullmatch('1') is not None)
|
|
107
|
+
self.assertTrue(p.fullmatch('\\') is None)
|
|
108
|
+
self.assertTrue(p.fullmatch('A') is None)
|
|
109
|
+
|
|
110
|
+
def test_empty_property_before_caret(self):
|
|
111
|
+
"""A `^` after a property that resolves to nothing must not negate the set."""
|
|
112
|
+
|
|
113
|
+
for pattern in (rb'[\p{Han}^a]', rb'[\N{CJK UNIFIED IDEOGRAPH-4E00}^a]', rb'[^\p{Han}^a]'):
|
|
114
|
+
p = bre.compile(pattern)
|
|
115
|
+
negated = pattern.startswith(b'[^')
|
|
116
|
+
self.assertEqual(p.fullmatch(b'^') is None, negated)
|
|
117
|
+
self.assertEqual(p.fullmatch(b'a') is None, negated)
|
|
118
|
+
self.assertEqual(p.fullmatch(b'b') is not None, negated)
|
|
119
|
+
|
|
92
120
|
def test_non_raw_string_unicode(self):
|
|
93
121
|
"""Test non raw string Unicode notation."""
|
|
94
122
|
|
|
@@ -332,6 +360,65 @@ class TestSearchTemplate(unittest.TestCase):
|
|
|
332
360
|
)
|
|
333
361
|
self.assertTrue(pattern.match(b'a') is not None)
|
|
334
362
|
|
|
363
|
+
def test_byte_string_named_char_ranges(self):
|
|
364
|
+
"""Test ranges in byte strings with named characters beyond the byte range, matching Regex."""
|
|
365
|
+
|
|
366
|
+
big = r'\N{CJK UNIFIED IDEOGRAPH-4E00}'
|
|
367
|
+
big2 = r'\N{CJK UNIFIED IDEOGRAPH-4E01}'
|
|
368
|
+
|
|
369
|
+
def matches(pattern):
|
|
370
|
+
p = bre.compile(pattern.encode('latin-1'))
|
|
371
|
+
return frozenset(c for c in range(256) if p.fullmatch(bytes([c])))
|
|
372
|
+
|
|
373
|
+
# A range that ends beyond the byte range includes everything up to the end of the byte range.
|
|
374
|
+
self.assertEqual(matches(f'[a-{big}]'), frozenset(range(0x61, 0x100)))
|
|
375
|
+
self.assertEqual(matches(f'[a-{big}c]'), frozenset(range(0x61, 0x100)))
|
|
376
|
+
self.assertEqual(matches(f'[^a-{big}]'), frozenset(range(0x61)))
|
|
377
|
+
self.assertEqual(matches(rf'[\N{{LATIN SMALL LETTER E WITH ACUTE}}-{big}]'), frozenset(range(0xE9, 0x100)))
|
|
378
|
+
|
|
379
|
+
# A range that starts beyond the byte range matches nothing, if it is a valid range.
|
|
380
|
+
self.assertEqual(matches(f'[{big}-{big2}]'), frozenset())
|
|
381
|
+
self.assertEqual(matches(f'[x{big}-{big2}]'), frozenset(b'x'))
|
|
382
|
+
for pattern in (f'[{big}-a]', f'[{big2}-{big}]', rf'[{big}-\x41]'):
|
|
383
|
+
with pytest.raises(PatternError, match='bad character range'):
|
|
384
|
+
bre.compile(pattern.encode('latin-1'))
|
|
385
|
+
|
|
386
|
+
# A `-` that isn't a range operator is a literal, and the named character matches nothing.
|
|
387
|
+
self.assertEqual(matches(f'[{big}-]'), frozenset(b'-'))
|
|
388
|
+
self.assertEqual(matches(f'[-{big}]'), frozenset(b'-'))
|
|
389
|
+
self.assertEqual(matches(f'[x{big}-]'), frozenset(b'-x'))
|
|
390
|
+
self.assertEqual(matches(f'[a-c-{big}]'), frozenset(b'-abc'))
|
|
391
|
+
self.assertEqual(matches(rf'[\x21-\x41-{big}]'), frozenset(range(0x21, 0x42)))
|
|
392
|
+
|
|
393
|
+
def test_escapes_in_char_groups(self):
|
|
394
|
+
"""Escapes in a character set are single members, so ranges and named characters work with them."""
|
|
395
|
+
|
|
396
|
+
esc = '\\'
|
|
397
|
+
self.assertEqual([c for c in 'ABCD' if bre.fullmatch(f'[{esc}u0041-{esc}u0043]', c)], ['A', 'B', 'C'])
|
|
398
|
+
self.assertEqual([c for c in 'ABCD' if bre.fullmatch(r'[\U00000041-\U00000043]', c)], ['A', 'B', 'C'])
|
|
399
|
+
|
|
400
|
+
# An octal escape ends at the first character that isn't an octal digit.
|
|
401
|
+
self.assertEqual([c for c in ('\x01', '\x18', '8', '1') if bre.fullmatch(r'[\18]', c)], ['\x01', '8'])
|
|
402
|
+
|
|
403
|
+
# The `-` after an octal escape that ends a range is a literal.
|
|
404
|
+
p = bre.compile(rb'[\041-\101-\N{CJK UNIFIED IDEOGRAPH-4E00}]')
|
|
405
|
+
self.assertEqual(frozenset(c for c in range(256) if p.fullmatch(bytes([c]))), frozenset(range(0x21, 0x42)))
|
|
406
|
+
|
|
407
|
+
# Escapes with too few digits.
|
|
408
|
+
with pytest.raises(re.error, match='incomplete escape'):
|
|
409
|
+
bre.compile(r'[\x4]')
|
|
410
|
+
for pattern in (r'[\x4', f'[{esc}u004', r'[\10'):
|
|
411
|
+
with pytest.raises(PatternError, match='unterminated character set'):
|
|
412
|
+
bre.compile(pattern)
|
|
413
|
+
|
|
414
|
+
def test_caret_after_negation(self):
|
|
415
|
+
"""A `^` after the negating `^` is a literal."""
|
|
416
|
+
|
|
417
|
+
self.assertEqual([c for c in '^]a' if bre.fullmatch(r'[^^]', c)], [']', 'a'])
|
|
418
|
+
self.assertEqual([c for c in '^]a' if bre.fullmatch(r'[^^^]', c)], [']', 'a'])
|
|
419
|
+
self.assertEqual([c for c in '^]ab-' if bre.fullmatch(r'[^^-a]', c)], [']', 'b', '-'])
|
|
420
|
+
self.assertEqual([c for c in '^]ab' if bre.fullmatch(r'[^^]]', c + ']')], [']', 'a', 'b'])
|
|
421
|
+
|
|
335
422
|
def test_comments(self):
|
|
336
423
|
"""Test comments v0."""
|
|
337
424
|
|
|
@@ -387,6 +474,11 @@ class TestSearchTemplate(unittest.TestCase):
|
|
|
387
474
|
pattern = bre.compile_search(r'(?x)test # \l \p{numbers}', re.UNICODE)
|
|
388
475
|
self.assertEqual(pattern.pattern, r'(?x)test # \\l \\p{numbers}', re.UNICODE)
|
|
389
476
|
|
|
477
|
+
# Other escapes are left alone.
|
|
478
|
+
pattern = bre.compile_search('(?x)test # \\d comment\nok', re.UNICODE)
|
|
479
|
+
self.assertEqual(pattern.pattern, '(?x)test # \\d comment\nok')
|
|
480
|
+
self.assertTrue(pattern.fullmatch('testok') is not None)
|
|
481
|
+
|
|
390
482
|
def test_char_group_nested_opening(self):
|
|
391
483
|
"""Test char group with nested opening [."""
|
|
392
484
|
|
|
@@ -891,7 +983,6 @@ class TestSearchTemplate(unittest.TestCase):
|
|
|
891
983
|
m = pattern2.match('test')
|
|
892
984
|
self.assertTrue(m is not None)
|
|
893
985
|
|
|
894
|
-
@unittest.skipUnless(PY39_PLUS, "Python 3.9 required")
|
|
895
986
|
def test_emoji_property(self):
|
|
896
987
|
"""Test new emoji properties."""
|
|
897
988
|
|
|
@@ -2057,6 +2148,12 @@ class TestReplaceTemplate(unittest.TestCase):
|
|
|
2057
2148
|
results = expand(pattern.match(b'Test'))
|
|
2058
2149
|
self.assertEqual(b'\x00\x00\x00', results)
|
|
2059
2150
|
|
|
2151
|
+
# An octal escape has at most three digits.
|
|
2152
|
+
pattern = re.compile('Test')
|
|
2153
|
+
for template, expected in ((r'\0000', '\x000'), (r'\0123', '\n3'), (r'\1234', 'S4')):
|
|
2154
|
+
self.assertEqual(bre.compile_replace(pattern, template)(pattern.match('Test')), expected)
|
|
2155
|
+
self.assertEqual(re.sub('Test', template, 'Test'), expected)
|
|
2156
|
+
|
|
2060
2157
|
|
|
2061
2158
|
class TestExceptions(unittest.TestCase):
|
|
2062
2159
|
"""Test Exceptions."""
|
|
@@ -2604,3 +2701,35 @@ class TestConvenienceFunctions(unittest.TestCase):
|
|
|
2604
2701
|
# Fail due to `\l` being an invalid escape.
|
|
2605
2702
|
with pytest.raises(re.error):
|
|
2606
2703
|
p.sub(r'\ltest', 'tests')
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
@pytest.mark.parametrize('method', ['sub', 'subn', 'subf', 'subfn'])
|
|
2707
|
+
@pytest.mark.parametrize('auto_compile', [False, True])
|
|
2708
|
+
def test_foreign_compiled_replacement(method, auto_compile):
|
|
2709
|
+
"""Reject templates whose named captures belong to another pattern."""
|
|
2710
|
+
|
|
2711
|
+
first = r'(?P<first>a)(?P<second>b)'
|
|
2712
|
+
second = r'(?P<second>b)(?P<first>a)'
|
|
2713
|
+
text = 'ba'
|
|
2714
|
+
template = '{first}' if method in ('subf', 'subfn') else r'\g<first>'
|
|
2715
|
+
source = bre.compile(first)
|
|
2716
|
+
target = bre.compile(second, auto_compile=auto_compile)
|
|
2717
|
+
replacement = source.compile(template, bre.FORMAT if method in ('subf', 'subfn') else 0)
|
|
2718
|
+
|
|
2719
|
+
with pytest.raises(ValueError, match="Pattern hash doesn't match"):
|
|
2720
|
+
getattr(target, method)(replacement, text)
|
|
2721
|
+
|
|
2722
|
+
|
|
2723
|
+
@pytest.mark.parametrize('method', ['sub', 'subn', 'subf', 'subfn'])
|
|
2724
|
+
@pytest.mark.parametrize('auto_compile', [False, True])
|
|
2725
|
+
def test_matching_compiled_replacement(method, auto_compile):
|
|
2726
|
+
"""Matching templates still work without automatic string compilation."""
|
|
2727
|
+
|
|
2728
|
+
pattern = r'(?P<first>a)(?P<second>b)'
|
|
2729
|
+
text, expected = 'ab', 'a'
|
|
2730
|
+
template = '{first}' if method in ('subf', 'subfn') else r'\g<first>'
|
|
2731
|
+
target = bre.compile(pattern, auto_compile=auto_compile)
|
|
2732
|
+
replacement = target.compile(template, bre.FORMAT if method in ('subf', 'subfn') else 0)
|
|
2733
|
+
if method in ('subn', 'subfn'):
|
|
2734
|
+
expected = (expected, 1)
|
|
2735
|
+
assert getattr(target, method)(replacement, text) == expected
|
|
@@ -9,14 +9,11 @@ import pytest
|
|
|
9
9
|
import random
|
|
10
10
|
import copy
|
|
11
11
|
import time
|
|
12
|
-
import sys
|
|
13
12
|
try:
|
|
14
13
|
import _regex_core
|
|
15
14
|
except ImportError:
|
|
16
15
|
from regex import _regex_core
|
|
17
16
|
|
|
18
|
-
PY39_PLUS = (3, 9) <= sys.version_info
|
|
19
|
-
|
|
20
17
|
|
|
21
18
|
class TestSearchTemplate(unittest.TestCase):
|
|
22
19
|
"""Search template tests."""
|
|
@@ -214,6 +211,11 @@ class TestSearchTemplate(unittest.TestCase):
|
|
|
214
211
|
with pytest.raises(PatternError):
|
|
215
212
|
bregex.compile_search('test[test', regex.UNICODE)
|
|
216
213
|
|
|
214
|
+
# Sets that end within an escape or POSIX class.
|
|
215
|
+
for pattern in (r'[\x4', r'[\N{LATIN', r'[[:al', r'[[:alpha:', r'[\p{L', '[\\'):
|
|
216
|
+
with pytest.raises(PatternError, match='unterminated character set'):
|
|
217
|
+
bregex.compile_search(pattern)
|
|
218
|
+
|
|
217
219
|
pattern = bregex.compile_search('\\Qtest[test', regex.UNICODE)
|
|
218
220
|
self.assertEqual(pattern.pattern, 'test\\[test')
|
|
219
221
|
|
|
@@ -256,6 +258,77 @@ class TestSearchTemplate(unittest.TestCase):
|
|
|
256
258
|
pattern = bregex.compile_search(r'test [[:graph:]] \R', regex.V1)
|
|
257
259
|
self.assertEqual(pattern.pattern, r'test [[:graph:]] (?>\r\n|[\n\v\f\r\x85\u2028\u2029])')
|
|
258
260
|
|
|
261
|
+
def test_char_set_end(self):
|
|
262
|
+
"""Character sets should end exactly where Regex ends them."""
|
|
263
|
+
|
|
264
|
+
sets = [
|
|
265
|
+
# POSIX classes, including in byte strings and with qualified names or values.
|
|
266
|
+
'[[:digit:]]', '[^[:^alpha:]]', '[[:script=latin:]]', '[[:script:latin:]]', '[[:nv=-1/2:]]',
|
|
267
|
+
'[[:al pha:]]', '[[:a]]', '[[::]]',
|
|
268
|
+
# `]` and `^` at the start of a set.
|
|
269
|
+
'[]]', '[^]]', '[^^]', '[^^^]', '[[]]', '[[^]]]',
|
|
270
|
+
# Set operations: `]` is a literal at the start of an operand in V1.
|
|
271
|
+
'[a--]]]', '[\\w&&]]]', '[a||]]]', '[a~~]]]', '[a--[]]]]', '[[a]--[]]]]',
|
|
272
|
+
# Ranges, which consume the next item even if it looks like an operator.
|
|
273
|
+
'[!-&&]', '[\\x21-&&]', '[\\N{EXCLAMATION MARK}-&&]', '[\\d-&&]]]', '[\\p{L}-&&]]]', '[\\pL-&&]]]',
|
|
274
|
+
'[a-]', '[a--b]', '[\\p{L]', '[\\N{]', '[\\101]]', '[\\x5d]]'
|
|
275
|
+
]
|
|
276
|
+
for version in (regex.V0, regex.V1):
|
|
277
|
+
for is_bytes in (False, True):
|
|
278
|
+
for text in sets:
|
|
279
|
+
if is_bytes and ('\\p' in text or '\\N' in text):
|
|
280
|
+
continue
|
|
281
|
+
pattern = (text + ']]]').encode('latin-1') if is_bytes else text + ']]]'
|
|
282
|
+
|
|
283
|
+
source = _regex_core.Source(pattern)
|
|
284
|
+
source.get()
|
|
285
|
+
try:
|
|
286
|
+
_regex_core.parse_set(source, _regex_core.Info(version))
|
|
287
|
+
except _regex_core.error:
|
|
288
|
+
continue
|
|
289
|
+
expected = pattern[:source.pos]
|
|
290
|
+
|
|
291
|
+
parser = _bregex_parse._SearchParser(pattern, False, version)
|
|
292
|
+
parser.version = version
|
|
293
|
+
parser.text = text + ']]]'
|
|
294
|
+
i = _bregex_parse._util.StringIter(parser.text)
|
|
295
|
+
result = parser.char_groups(next(i), i)[0]
|
|
296
|
+
self.assertEqual(
|
|
297
|
+
result.encode('latin-1') if is_bytes else result,
|
|
298
|
+
expected,
|
|
299
|
+
f'{pattern!r} with {"V1" if version == regex.V1 else "V0"}'
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def test_char_set_end_symptoms(self):
|
|
303
|
+
"""Finding the wrong end of a character set should not affect what follows it."""
|
|
304
|
+
|
|
305
|
+
line_break = r'(?>\r\n|[\n\v\f\r\x85\u2028\u2029])'
|
|
306
|
+
bytes_line_break = rb'(?>\r\n|[\n\v\f\r\x85])'
|
|
307
|
+
cases = [
|
|
308
|
+
(rb'[[:digit:](]', 0, rb'[[:digit:](]', [b'5', b'(', b'a']),
|
|
309
|
+
(rb'[[:digit:]#]\R', bregex.VERBOSE, rb'[[:digit:]#]' + bytes_line_break, [b'5\n', b'#\n', b'5\\R']),
|
|
310
|
+
(r'[[:script=latin:](]', 0, r'[[:script=latin:](]', ['a', '(', '1']),
|
|
311
|
+
(r'[[:script=latin:]#]\R', bregex.VERBOSE, r'[[:script=latin:]#]' + line_break, ['a\n', '#\n', 'a\\R']),
|
|
312
|
+
(r'[\w--](]', bregex.V1, r'[\w--](]', ['a', '(', ']']),
|
|
313
|
+
(r'[\W&&]#]\R', bregex.V1 | bregex.VERBOSE, r'[\W&&]#]' + line_break, [']\n', '#\n', '%\n']),
|
|
314
|
+
(r'[^^]\R', 0, r'[^^]' + line_break, ['a\n', '^\n']),
|
|
315
|
+
(r'[^^](a)', bregex.V1, r'[^^](a)', ['ba', '^a']),
|
|
316
|
+
]
|
|
317
|
+
for pattern, flags, equivalent, tests in cases:
|
|
318
|
+
p = bregex.compile(pattern, flags)
|
|
319
|
+
for s in tests:
|
|
320
|
+
self.assertEqual(
|
|
321
|
+
bool(p.fullmatch(s)),
|
|
322
|
+
bool(regex.fullmatch(equivalent, s, flags=flags)),
|
|
323
|
+
f'{pattern!r} with {s!r}'
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def test_posix_not_normalized(self):
|
|
327
|
+
"""POSIX classes should be passed to Regex unchanged."""
|
|
328
|
+
|
|
329
|
+
self.assertEqual(bregex.compile_search(r'[[:al_pha:]]').pattern, r'[[:al_pha:]]')
|
|
330
|
+
self.assertEqual(bregex.compile_search(r'[[:nv=-1/2:]]').pattern, r'[[:nv=-1/2:]]')
|
|
331
|
+
|
|
259
332
|
def test_inline_comments(self):
|
|
260
333
|
"""Test that we properly find inline comments and avoid them."""
|
|
261
334
|
pattern = bregex.compile_search(r'test(?#\l\p{^IsLatin})', regex.UNICODE)
|
|
@@ -587,7 +660,6 @@ class TestSearchTemplate(unittest.TestCase):
|
|
|
587
660
|
m = pattern2.match('test')
|
|
588
661
|
self.assertTrue(m is not None)
|
|
589
662
|
|
|
590
|
-
@unittest.skipUnless(PY39_PLUS, "Python 3.9 required")
|
|
591
663
|
def test_emoji_property(self):
|
|
592
664
|
"""Test new emoji properties."""
|
|
593
665
|
|
|
@@ -2210,3 +2282,35 @@ class TestConvenienceFunctions(unittest.TestCase):
|
|
|
2210
2282
|
|
|
2211
2283
|
with self.assertRaises(_regex_core.error):
|
|
2212
2284
|
self.assertEqual(p.sub(r'\ltest', 'tests'), r'\ltest')
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
@pytest.mark.parametrize('method', ['sub', 'subn', 'subf', 'subfn'])
|
|
2288
|
+
@pytest.mark.parametrize('auto_compile', [False, True])
|
|
2289
|
+
def test_foreign_compiled_replacement(method, auto_compile):
|
|
2290
|
+
"""Reject templates whose named captures belong to another pattern."""
|
|
2291
|
+
|
|
2292
|
+
first = r'(?P<first>a)(?P<second>b)'
|
|
2293
|
+
second = r'(?P<second>b)(?P<first>a)'
|
|
2294
|
+
text = 'ba'
|
|
2295
|
+
template = '{first}' if method in ('subf', 'subfn') else r'\g<first>'
|
|
2296
|
+
source = bregex.compile(first)
|
|
2297
|
+
target = bregex.compile(second, auto_compile=auto_compile)
|
|
2298
|
+
replacement = source.compile(template, bregex.FORMAT if method in ('subf', 'subfn') else 0)
|
|
2299
|
+
|
|
2300
|
+
with pytest.raises(ValueError, match="Pattern hash doesn't match"):
|
|
2301
|
+
getattr(target, method)(replacement, text)
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
@pytest.mark.parametrize('method', ['sub', 'subn', 'subf', 'subfn'])
|
|
2305
|
+
@pytest.mark.parametrize('auto_compile', [False, True])
|
|
2306
|
+
def test_matching_compiled_replacement(method, auto_compile):
|
|
2307
|
+
"""Matching templates still work without automatic string compilation."""
|
|
2308
|
+
|
|
2309
|
+
pattern = r'(?P<first>a)(?P<second>b)'
|
|
2310
|
+
text, expected = 'ab', 'a'
|
|
2311
|
+
template = '{first}' if method in ('subf', 'subfn') else r'\g<first>'
|
|
2312
|
+
target = bregex.compile(pattern, auto_compile=auto_compile)
|
|
2313
|
+
replacement = target.compile(template, bregex.FORMAT if method in ('subf', 'subfn') else 0)
|
|
2314
|
+
if method in ('subn', 'subfn'):
|
|
2315
|
+
expected = (expected, 1)
|
|
2316
|
+
assert getattr(target, method)(replacement, text) == expected
|
|
Binary file
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|