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.
Files changed (70) hide show
  1. {backrefs-8.2 → backrefs-8.2.1}/PKG-INFO +1 -1
  2. {backrefs-8.2 → backrefs-8.2.1}/backrefs/__meta__.py +1 -1
  3. {backrefs-8.2 → backrefs-8.2.1}/backrefs/_bre_parse.py +129 -19
  4. {backrefs-8.2 → backrefs-8.2.1}/backrefs/_bregex_parse.py +176 -91
  5. {backrefs-8.2 → backrefs-8.2.1}/backrefs/bre.py +3 -2
  6. {backrefs-8.2 → backrefs-8.2.1}/backrefs/bregex.py +3 -2
  7. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/changelog.md +21 -0
  8. {backrefs-8.2 → backrefs-8.2.1}/tests/test_bre.py +137 -8
  9. {backrefs-8.2 → backrefs-8.2.1}/tests/test_bregex.py +108 -4
  10. backrefs-8.2/tools/unicodedata/13.0.0.zip +0 -0
  11. {backrefs-8.2 → backrefs-8.2.1}/.coveragerc +0 -0
  12. {backrefs-8.2 → backrefs-8.2.1}/.gitignore +0 -0
  13. {backrefs-8.2 → backrefs-8.2.1}/.pyspelling.yml +0 -0
  14. {backrefs-8.2 → backrefs-8.2.1}/LICENSE.md +0 -0
  15. {backrefs-8.2 → backrefs-8.2.1}/README.md +0 -0
  16. {backrefs-8.2 → backrefs-8.2.1}/backrefs/__init__.py +0 -0
  17. {backrefs-8.2 → backrefs-8.2.1}/backrefs/_bregex_typing.py +0 -0
  18. {backrefs-8.2 → backrefs-8.2.1}/backrefs/py.typed +0 -0
  19. {backrefs-8.2 → backrefs-8.2.1}/backrefs/uniprops/__init__.py +0 -0
  20. {backrefs-8.2 → backrefs-8.2.1}/backrefs/util.py +0 -0
  21. {backrefs-8.2 → backrefs-8.2.1}/docs/src/dictionary/en-custom.txt +0 -0
  22. {backrefs-8.2 → backrefs-8.2.1}/docs/src/dictionary/en-python.txt +0 -0
  23. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/.snippets/links.md +0 -0
  24. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/contributing.md +0 -0
  25. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/license.md +0 -0
  26. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/about/security.md +0 -0
  27. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/index.md +0 -0
  28. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/refs.md +0 -0
  29. {backrefs-8.2 → backrefs-8.2.1}/docs/src/markdown/usage.md +0 -0
  30. {backrefs-8.2 → backrefs-8.2.1}/docs/theme/announce.html +0 -0
  31. {backrefs-8.2 → backrefs-8.2.1}/hatch_build.py +0 -0
  32. {backrefs-8.2 → backrefs-8.2.1}/pyproject.toml +0 -0
  33. {backrefs-8.2 → backrefs-8.2.1}/tests/__init__.py +0 -0
  34. {backrefs-8.2 → backrefs-8.2.1}/tests/test_age.py +0 -0
  35. {backrefs-8.2 → backrefs-8.2.1}/tests/test_bidiclass.py +0 -0
  36. {backrefs-8.2 → backrefs-8.2.1}/tests/test_bidipairedbrackettype.py +0 -0
  37. {backrefs-8.2 → backrefs-8.2.1}/tests/test_binary.py +0 -0
  38. {backrefs-8.2 → backrefs-8.2.1}/tests/test_block.py +0 -0
  39. {backrefs-8.2 → backrefs-8.2.1}/tests/test_canonicalcombiningclass.py +0 -0
  40. {backrefs-8.2 → backrefs-8.2.1}/tests/test_decompositiontype.py +0 -0
  41. {backrefs-8.2 → backrefs-8.2.1}/tests/test_eastasianwidth.py +0 -0
  42. {backrefs-8.2 → backrefs-8.2.1}/tests/test_generalcategory.py +0 -0
  43. {backrefs-8.2 → backrefs-8.2.1}/tests/test_graphemeclusterbreak.py +0 -0
  44. {backrefs-8.2 → backrefs-8.2.1}/tests/test_hangulsyllabletype.py +0 -0
  45. {backrefs-8.2 → backrefs-8.2.1}/tests/test_indicpositionalcategory.py +0 -0
  46. {backrefs-8.2 → backrefs-8.2.1}/tests/test_indicsylabiccategory.py +0 -0
  47. {backrefs-8.2 → backrefs-8.2.1}/tests/test_joininggroup.py +0 -0
  48. {backrefs-8.2 → backrefs-8.2.1}/tests/test_joiningtype.py +0 -0
  49. {backrefs-8.2 → backrefs-8.2.1}/tests/test_linebreak.py +0 -0
  50. {backrefs-8.2 → backrefs-8.2.1}/tests/test_numerictype.py +0 -0
  51. {backrefs-8.2 → backrefs-8.2.1}/tests/test_numericvalue.py +0 -0
  52. {backrefs-8.2 → backrefs-8.2.1}/tests/test_quickcheck.py +0 -0
  53. {backrefs-8.2 → backrefs-8.2.1}/tests/test_script.py +0 -0
  54. {backrefs-8.2 → backrefs-8.2.1}/tests/test_scriptextensions.py +0 -0
  55. {backrefs-8.2 → backrefs-8.2.1}/tests/test_sentencebreak.py +0 -0
  56. {backrefs-8.2 → backrefs-8.2.1}/tests/test_uniprops.py +0 -0
  57. {backrefs-8.2 → backrefs-8.2.1}/tests/test_versions.py +0 -0
  58. {backrefs-8.2 → backrefs-8.2.1}/tests/test_verticalorientation.py +0 -0
  59. {backrefs-8.2 → backrefs-8.2.1}/tests/test_wordbreak.py +0 -0
  60. {backrefs-8.2 → backrefs-8.2.1}/tools/__init__.py +0 -0
  61. {backrefs-8.2 → backrefs-8.2.1}/tools/pycon.py +0 -0
  62. {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/14.0.0.zip +0 -0
  63. {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/15.0.0.zip +0 -0
  64. {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/15.1.0.zip +0 -0
  65. {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/16.0.0.zip +0 -0
  66. {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/17.0.0.zip +0 -0
  67. {backrefs-8.2 → backrefs-8.2.1}/tools/unicodedata/LICENSE +0 -0
  68. {backrefs-8.2 → backrefs-8.2.1}/tools/unidatadownload.py +0 -0
  69. {backrefs-8.2 → backrefs-8.2.1}/tools/unipropgen.py +0 -0
  70. {backrefs-8.2 → backrefs-8.2.1}/zensical.yml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backrefs
3
- Version: 8.2
3
+ Version: 8.2.1
4
4
  Summary: A wrapper around re and regex that adds additional back references.
5
5
  Project-URL: Homepage, https://github.com/facelessuser/backrefs
6
6
  Author-email: Isaac Muse <Isaac.Muse@gmail.com>
@@ -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, 0, "final")
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 sys
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])' if _util.PY311 else r'(?:\r\n|(?!\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
- if t == ")":
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 >= 0 and current[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
- if current[-1] == '-':
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
- elif t == "^" and found and (pos == first + 1):
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
- else:
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 not self.end_found:
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
- elif self.single_stack:
1240
- this_case = self.get_single_stack()
1241
- if this_case is not None:
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
- _PROPERTY_STRIP = frozenset((' ', '-', '_'))
37
- _PROPERTY = _WORD | _DIGIT | _PROPERTY_STRIP
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
- current = []
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
- while True:
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 {pos}") from e
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
- return current
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
- i = _util.StringIter(self.process_quotes(search))
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
- elif isinstance(template, ReplaceTemplate) or (isinstance(template, (str, bytes)) and self.auto_compile):
271
- return self.compile(template, (FORMAT if use_format and not isinstance(template, ReplaceTemplate) else 0))
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
- elif isinstance(template, ReplaceTemplate) or (isinstance(template, (str, bytes)) and self.auto_compile):
286
- return self.compile(template, (FORMAT if use_format and not isinstance(template, ReplaceTemplate) else 0))
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