backrefs 8.2.1__tar.gz → 8.3__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 (69) hide show
  1. {backrefs-8.2.1 → backrefs-8.3}/PKG-INFO +1 -1
  2. {backrefs-8.2.1 → backrefs-8.3}/backrefs/__meta__.py +1 -1
  3. {backrefs-8.2.1 → backrefs-8.3}/backrefs/_bre_parse.py +50 -9
  4. {backrefs-8.2.1 → backrefs-8.3}/backrefs/uniprops/__init__.py +99 -62
  5. {backrefs-8.2.1 → backrefs-8.3}/docs/src/dictionary/en-custom.txt +3 -0
  6. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/about/changelog.md +13 -0
  7. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/refs.md +16 -3
  8. {backrefs-8.2.1 → backrefs-8.3}/tests/test_binary.py +41 -0
  9. {backrefs-8.2.1 → backrefs-8.3}/tests/test_block.py +53 -0
  10. {backrefs-8.2.1 → backrefs-8.3}/tests/test_bre.py +111 -0
  11. {backrefs-8.2.1 → backrefs-8.3}/tests/test_generalcategory.py +45 -0
  12. {backrefs-8.2.1 → backrefs-8.3}/tools/unipropgen.py +11 -0
  13. {backrefs-8.2.1 → backrefs-8.3}/.coveragerc +0 -0
  14. {backrefs-8.2.1 → backrefs-8.3}/.gitignore +0 -0
  15. {backrefs-8.2.1 → backrefs-8.3}/.pyspelling.yml +0 -0
  16. {backrefs-8.2.1 → backrefs-8.3}/LICENSE.md +0 -0
  17. {backrefs-8.2.1 → backrefs-8.3}/README.md +0 -0
  18. {backrefs-8.2.1 → backrefs-8.3}/backrefs/__init__.py +0 -0
  19. {backrefs-8.2.1 → backrefs-8.3}/backrefs/_bregex_parse.py +0 -0
  20. {backrefs-8.2.1 → backrefs-8.3}/backrefs/_bregex_typing.py +0 -0
  21. {backrefs-8.2.1 → backrefs-8.3}/backrefs/bre.py +0 -0
  22. {backrefs-8.2.1 → backrefs-8.3}/backrefs/bregex.py +0 -0
  23. {backrefs-8.2.1 → backrefs-8.3}/backrefs/py.typed +0 -0
  24. {backrefs-8.2.1 → backrefs-8.3}/backrefs/util.py +0 -0
  25. {backrefs-8.2.1 → backrefs-8.3}/docs/src/dictionary/en-python.txt +0 -0
  26. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/.snippets/links.md +0 -0
  27. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/about/contributing.md +0 -0
  28. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/about/license.md +0 -0
  29. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/about/security.md +0 -0
  30. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/index.md +0 -0
  31. {backrefs-8.2.1 → backrefs-8.3}/docs/src/markdown/usage.md +0 -0
  32. {backrefs-8.2.1 → backrefs-8.3}/docs/theme/announce.html +0 -0
  33. {backrefs-8.2.1 → backrefs-8.3}/hatch_build.py +0 -0
  34. {backrefs-8.2.1 → backrefs-8.3}/pyproject.toml +0 -0
  35. {backrefs-8.2.1 → backrefs-8.3}/tests/__init__.py +0 -0
  36. {backrefs-8.2.1 → backrefs-8.3}/tests/test_age.py +0 -0
  37. {backrefs-8.2.1 → backrefs-8.3}/tests/test_bidiclass.py +0 -0
  38. {backrefs-8.2.1 → backrefs-8.3}/tests/test_bidipairedbrackettype.py +0 -0
  39. {backrefs-8.2.1 → backrefs-8.3}/tests/test_bregex.py +0 -0
  40. {backrefs-8.2.1 → backrefs-8.3}/tests/test_canonicalcombiningclass.py +0 -0
  41. {backrefs-8.2.1 → backrefs-8.3}/tests/test_decompositiontype.py +0 -0
  42. {backrefs-8.2.1 → backrefs-8.3}/tests/test_eastasianwidth.py +0 -0
  43. {backrefs-8.2.1 → backrefs-8.3}/tests/test_graphemeclusterbreak.py +0 -0
  44. {backrefs-8.2.1 → backrefs-8.3}/tests/test_hangulsyllabletype.py +0 -0
  45. {backrefs-8.2.1 → backrefs-8.3}/tests/test_indicpositionalcategory.py +0 -0
  46. {backrefs-8.2.1 → backrefs-8.3}/tests/test_indicsylabiccategory.py +0 -0
  47. {backrefs-8.2.1 → backrefs-8.3}/tests/test_joininggroup.py +0 -0
  48. {backrefs-8.2.1 → backrefs-8.3}/tests/test_joiningtype.py +0 -0
  49. {backrefs-8.2.1 → backrefs-8.3}/tests/test_linebreak.py +0 -0
  50. {backrefs-8.2.1 → backrefs-8.3}/tests/test_numerictype.py +0 -0
  51. {backrefs-8.2.1 → backrefs-8.3}/tests/test_numericvalue.py +0 -0
  52. {backrefs-8.2.1 → backrefs-8.3}/tests/test_quickcheck.py +0 -0
  53. {backrefs-8.2.1 → backrefs-8.3}/tests/test_script.py +0 -0
  54. {backrefs-8.2.1 → backrefs-8.3}/tests/test_scriptextensions.py +0 -0
  55. {backrefs-8.2.1 → backrefs-8.3}/tests/test_sentencebreak.py +0 -0
  56. {backrefs-8.2.1 → backrefs-8.3}/tests/test_uniprops.py +0 -0
  57. {backrefs-8.2.1 → backrefs-8.3}/tests/test_versions.py +0 -0
  58. {backrefs-8.2.1 → backrefs-8.3}/tests/test_verticalorientation.py +0 -0
  59. {backrefs-8.2.1 → backrefs-8.3}/tests/test_wordbreak.py +0 -0
  60. {backrefs-8.2.1 → backrefs-8.3}/tools/__init__.py +0 -0
  61. {backrefs-8.2.1 → backrefs-8.3}/tools/pycon.py +0 -0
  62. {backrefs-8.2.1 → backrefs-8.3}/tools/unicodedata/14.0.0.zip +0 -0
  63. {backrefs-8.2.1 → backrefs-8.3}/tools/unicodedata/15.0.0.zip +0 -0
  64. {backrefs-8.2.1 → backrefs-8.3}/tools/unicodedata/15.1.0.zip +0 -0
  65. {backrefs-8.2.1 → backrefs-8.3}/tools/unicodedata/16.0.0.zip +0 -0
  66. {backrefs-8.2.1 → backrefs-8.3}/tools/unicodedata/17.0.0.zip +0 -0
  67. {backrefs-8.2.1 → backrefs-8.3}/tools/unicodedata/LICENSE +0 -0
  68. {backrefs-8.2.1 → backrefs-8.3}/tools/unidatadownload.py +0 -0
  69. {backrefs-8.2.1 → backrefs-8.3}/zensical.yml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backrefs
3
- Version: 8.2.1
3
+ Version: 8.3
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, 1, "final")
196
+ __version_info__ = Version(8, 3, 0, "final")
197
197
  __version__ = __version_info__._get_canonical()
@@ -77,6 +77,7 @@ class _SearchParser(Generic[AnyStr]):
77
77
  verbose: bool
78
78
  unicode: bool
79
79
  named_unicode: tuple[str, int]
80
+ negated_members: list[str]
80
81
  ascii: bool # noqa: A003
81
82
  is_bytes: bool
82
83
  search: AnyStr
@@ -437,6 +438,8 @@ class _SearchParser(Generic[AnyStr]):
437
438
  start = ('', 0, None) # type: tuple[str, int, int | None]
438
439
  self.found_property = False
439
440
  self.found_named_unicode = False
441
+ self.negated_members = []
442
+ negated_set = False
440
443
 
441
444
  def add_member(raw: str, index: int, value: int | None) -> None:
442
445
  """Track a member that was just added, which may start or end a range."""
@@ -522,6 +525,7 @@ class _SearchParser(Generic[AnyStr]):
522
525
  # Only a `^` directly after the opening `[` negates the set.
523
526
  first = pos
524
527
  current.append(t)
528
+ negated_set = True
525
529
  elif t == "]" and found and (pos != first + 1):
526
530
  found = False
527
531
  current.append(t)
@@ -552,7 +556,9 @@ class _SearchParser(Generic[AnyStr]):
552
556
  # This will occur when a property's values exceed
553
557
  # either the Unicode char limit on a narrow system,
554
558
  # or the ASCII limit in a byte string pattern.
555
- if found_property or self.found_named_unicode:
559
+ if self.negated_members:
560
+ current = [self.negated_set_text(''.join(current), negated_set)]
561
+ elif found_property or self.found_named_unicode:
556
562
  temp = "".join(current)
557
563
  if temp == '[]':
558
564
  # We specified some properties, but they are all
@@ -565,6 +571,32 @@ class _SearchParser(Generic[AnyStr]):
565
571
 
566
572
  return current
567
573
 
574
+ def negated_set_text(self, text: str, negated: bool) -> str:
575
+ r"""
576
+ Get a character set that contains negated properties, such as `[a\P{Lu}]`.
577
+
578
+ Under case-insensitive matching, a negated property must be a negated set of the property's characters
579
+ (`[^...]`), as a set of the characters outside the property would also match the other case of the
580
+ characters in it. A set can't contain a negated set, so each part of the set is matched separately:
581
+ `[R\P{A}\P{B}]` becomes `(?:[R]|[^A]|[^B])`, and `[^R\P{A}\P{B}]` becomes `(?![R])(?=[A])[B]`.
582
+ """
583
+
584
+ members = text[2 if negated else 1:-1]
585
+ if members.startswith('^'):
586
+ # A literal `^` after the negating `^` would negate the members' own set.
587
+ members = '\\' + members
588
+
589
+ if not negated:
590
+ alternatives = [f'[^{m}]' for m in self.negated_members]
591
+ if members:
592
+ alternatives.insert(0, f'[{members}]')
593
+ return alternatives[0] if len(alternatives) == 1 else f"(?:{'|'.join(alternatives)})"
594
+
595
+ conditions = [f'(?=[{m}])' for m in self.negated_members[:-1]]
596
+ if members:
597
+ conditions.insert(0, f'(?![{members}])')
598
+ return ''.join(conditions) + f'[{self.negated_members[-1]}]'
599
+
568
600
  def normal(self, t: str, i: _util.StringIter) -> list[str]:
569
601
  """Handle normal chars."""
570
602
 
@@ -680,14 +712,23 @@ class _SearchParser(Generic[AnyStr]):
680
712
  else:
681
713
  mode = _uniprops.MODE_UNICODE
682
714
 
683
- v = _uniprops.get_unicode_property(props, prop_value, mode)
684
- if not in_group:
685
- if not v:
686
- v = f'^{_uniprops.ASCII_RANGE if self.is_bytes else _uniprops.UNICODE_RANGE}'
687
- v = f"[{v}]"
688
- properties = [v]
689
-
690
- return properties
715
+ # Negated properties are expressed as a negated set of the property's characters, so that they work with
716
+ # case-insensitive matching (see `negated_set_text`).
717
+ v, negated = _uniprops.get_unicode_property_parts(props, prop_value, mode)
718
+ everything = _uniprops.ASCII_RANGE if self.is_bytes else _uniprops.UNICODE_RANGE
719
+ if in_group:
720
+ if negated:
721
+ if not v:
722
+ # The inverse of a property that matches nothing is everything, which case-insensitive
723
+ # matching can't extend, so it can be a member of the set.
724
+ return [everything]
725
+ # Added when the set is complete.
726
+ self.negated_members.append(v)
727
+ return ['']
728
+ return [v]
729
+ if negated:
730
+ return [f'[^{v}]' if v else f'[{everything}]']
731
+ return [f'[{v}]' if v else f'[^{everything}]']
691
732
 
692
733
  def main_group(self, i: _util.StringIter) -> list[str]:
693
734
  """The main group: group 0."""
@@ -1,5 +1,6 @@
1
1
  """Unicode Properties."""
2
2
  from __future__ import annotations
3
+ from typing import Callable
3
4
  from .unidata import alias
4
5
 
5
6
  UNICODE_RANGE = '\u0000-\U0010ffff'
@@ -487,7 +488,7 @@ def get_vertical_orientation_property(value: str, mode: int = MODE_UNICODE) -> s
487
488
 
488
489
 
489
490
  def get_is_property(value: str, mode: int = MODE_UNICODE) -> str:
490
- """Get shortcut for `SC` or `Binary` property."""
491
+ """Get shortcut for `GC`, `SC`, or `Binary` property."""
491
492
 
492
493
  from .unidata import scriptextensions as scx
493
494
  from .unidata import binary
@@ -504,6 +505,10 @@ def get_is_property(value: str, mode: int = MODE_UNICODE) -> str:
504
505
  if prefix != 'is':
505
506
  raise ValueError("Does not start with 'is'!")
506
507
 
508
+ category = _general_category_name(temp)
509
+ if category is not None:
510
+ return get_gc_property(negate + category, mode)
511
+
507
512
  script_obj = scx.ascii_script_extensions if mode != MODE_UNICODE else scx.unicode_script_extensions
508
513
  bin_obj = binary.ascii_binary if mode != MODE_UNICODE else binary.unicode_binary
509
514
 
@@ -549,86 +554,100 @@ def _is_binary(name: str) -> bool:
549
554
  return name in prop_table.unicode_binary or name in alias.unicode_alias['binary']
550
555
 
551
556
 
557
+ def _general_category_name(value: str) -> str | None:
558
+ """Get the short name of a General Category value, or `None` if it isn't one."""
559
+
560
+ from .unidata import generalcategory as prop_table
561
+
562
+ name = alias.unicode_alias['generalcategory'].get(value, value)
563
+ table = prop_table.unicode_properties
564
+ if len(name) == 1:
565
+ valid = name in table
566
+ else:
567
+ valid = len(name) == 2 and name[0] in table and name[1] in table[name[0]]
568
+ return name if valid else None
569
+
570
+
571
+ def _strip_is(name: str, is_valid: Callable[[str], bool]) -> str:
572
+ """
573
+ Remove an initial `is` from a name that isn't valid, if it is valid without it.
574
+
575
+ Unicode allows an initial `is` on property names and values (UAX #44, `UAX44-LM3`). The name as written is
576
+ always preferred, and `is` is only removed once. A negated name (`^name`) keeps its negation.
577
+ """
578
+
579
+ negate = '^' if name.startswith('^') else ''
580
+ base = name[len(negate):]
581
+ if not is_valid(base) and base.startswith('is') and is_valid(base[2:]):
582
+ base = base[2:]
583
+ return negate + base
584
+
585
+
586
+ # Properties that have values, by their normalized name.
587
+ _ENUMERATED_PROPERTIES: dict[str, Callable[[str, int], str]] = {
588
+ 'generalcategory': get_gc_property,
589
+ 'script': get_script_property,
590
+ 'scriptextensions': get_script_extension_property,
591
+ 'block': get_block_property,
592
+ 'bidiclass': get_bidi_property,
593
+ 'bidipairedbrackettype': get_bidi_paired_bracket_type_property,
594
+ 'age': get_age_property,
595
+ 'eastasianwidth': get_east_asian_width_property,
596
+ 'indicpositionalcategory': get_indic_positional_category_property,
597
+ 'indicsyllabiccategory': get_indic_syllabic_category_property,
598
+ 'hangulsyllabletype': get_hangul_syllable_type_property,
599
+ 'decompositiontype': get_decomposition_type_property,
600
+ 'canonicalcombiningclass': get_canonical_combining_class_property,
601
+ 'numerictype': get_numeric_type_property,
602
+ 'numericvalue': get_numeric_value_property,
603
+ 'joiningtype': get_joining_type_property,
604
+ 'joininggroup': get_joining_group_property,
605
+ 'graphemeclusterbreak': get_grapheme_cluster_break_property,
606
+ 'linebreak': get_line_break_property,
607
+ 'sentencebreak': get_sentence_break_property,
608
+ 'wordbreak': get_word_break_property,
609
+ 'nfcquickcheck': get_nfc_quick_check_property,
610
+ 'nfdquickcheck': get_nfd_quick_check_property,
611
+ 'nfkcquickcheck': get_nfkc_quick_check_property,
612
+ 'nfkdquickcheck': get_nfkd_quick_check_property,
613
+ 'verticalorientation': get_vertical_orientation_property,
614
+ }
615
+
616
+
617
+ def _is_property_name(name: str) -> bool:
618
+ """Check if a name is a property that can be given a value (`name=value`)."""
619
+
620
+ return _is_binary(name) or alias.unicode_alias['_'].get(name, name) in _ENUMERATED_PROPERTIES
621
+
622
+
552
623
  def get_unicode_property(prop: str, value: str | None = None, mode: int = MODE_UNICODE) -> str:
553
624
  """Retrieve the Unicode category from the table."""
554
625
 
555
626
  if value is not None:
556
627
 
557
628
  negate = prop.startswith('^')
629
+ name = _strip_is(prop[1:] if negate else prop, _is_property_name)
558
630
 
559
631
  # Normalize binary true/false input so we can handle it properly
560
- if _is_binary(prop):
561
- name = prop[1:] if negate else prop
562
-
632
+ if _is_binary(name):
563
633
  if value in ('n', 'no', 'f', 'false'):
564
634
  negate = not negate
565
635
  elif value not in ('y', 'yes', 't', 'true'):
566
636
  raise ValueError(f"'{value}' is not a valid value for the binary property '{prop}'")
567
637
 
568
638
  return get_binary_property('^' + name if negate else name, mode)
569
- else:
570
- if negate:
571
- value = '^' + value
572
- name = prop[1:]
573
- else:
574
- name = prop
639
+
640
+ if negate:
641
+ value = '^' + value
575
642
 
576
643
  name = alias.unicode_alias['_'].get(name, name)
577
644
  try:
578
645
  if name == 'generalcategory':
579
- return get_gc_property(value, mode)
580
- elif name == 'script':
581
- return get_script_property(value, mode)
582
- elif name == 'scriptextensions':
583
- return get_script_extension_property(value, mode)
584
- elif name == 'block':
585
- return get_block_property(value, mode)
586
- elif name == 'bidiclass':
587
- return get_bidi_property(value, mode)
588
- elif name == 'bidipairedbrackettype':
589
- return get_bidi_paired_bracket_type_property(value, mode)
590
- elif name == 'age':
591
- return get_age_property(value, mode)
592
- elif name == 'eastasianwidth':
593
- return get_east_asian_width_property(value, mode)
594
- elif name == 'indicpositionalcategory':
595
- return get_indic_positional_category_property(value, mode)
596
- elif name == 'indicsyllabiccategory':
597
- return get_indic_syllabic_category_property(value, mode)
598
- elif name == 'hangulsyllabletype':
599
- return get_hangul_syllable_type_property(value, mode)
600
- elif name == 'decompositiontype':
601
- return get_decomposition_type_property(value, mode)
602
- elif name == 'canonicalcombiningclass':
603
- return get_canonical_combining_class_property(value, mode)
604
- elif name == 'numerictype':
605
- return get_numeric_type_property(value, mode)
606
- elif name == 'numericvalue':
607
- return get_numeric_value_property(value, mode)
608
- elif name == 'joiningtype':
609
- return get_joining_type_property(value, mode)
610
- elif name == 'joininggroup':
611
- return get_joining_group_property(value, mode)
612
- elif name == 'graphemeclusterbreak':
613
- return get_grapheme_cluster_break_property(value, mode)
614
- elif name == 'linebreak':
615
- return get_line_break_property(value, mode)
616
- elif name == 'sentencebreak':
617
- return get_sentence_break_property(value, mode)
618
- elif name == 'wordbreak':
619
- return get_word_break_property(value, mode)
620
- elif name == 'nfcquickcheck':
621
- return get_nfc_quick_check_property(value, mode)
622
- elif name == 'nfdquickcheck':
623
- return get_nfd_quick_check_property(value, mode)
624
- elif name == 'nfkcquickcheck':
625
- return get_nfkc_quick_check_property(value, mode)
626
- elif name == 'nfkdquickcheck':
627
- return get_nfkd_quick_check_property(value, mode)
628
- elif name == 'verticalorientation':
629
- return get_vertical_orientation_property(value, mode)
630
- else:
646
+ value = _strip_is(value, lambda v: _general_category_name(v) is not None)
647
+ get_property = _ENUMERATED_PROPERTIES.get(name)
648
+ if get_property is None:
631
649
  raise ValueError(f"'{prop}={value}' does not have a valid property name")
650
+ return get_property(value, mode)
632
651
  except Exception as e:
633
652
  raise ValueError(f"'{prop}={value}' does not appear to be a valid property") from e
634
653
 
@@ -663,3 +682,21 @@ def get_unicode_property(prop: str, value: str | None = None, mode: int = MODE_U
663
682
  pass
664
683
 
665
684
  raise ValueError(f"'{prop}' does not appear to be a valid property")
685
+
686
+
687
+ def get_unicode_property_parts(prop: str, value: str | None = None, mode: int = MODE_UNICODE) -> tuple[str, bool]:
688
+ """
689
+ Retrieve the characters of a Unicode property without its negation, and whether it is negated.
690
+
691
+ A property is negated when it is an inverse property (`^name`). With the characters of the property itself,
692
+ a negated property can be expressed as a negated character set (`[^...]`), which case-insensitive matching
693
+ handles correctly, unlike a set of the characters outside the property.
694
+
695
+ A binary property with a false value (`name=no`) is not a negation: it is the set of characters with that
696
+ value, as it is in Regex.
697
+ """
698
+
699
+ negate = prop.startswith('^')
700
+ if negate:
701
+ prop = prop[1:]
702
+ return get_unicode_property(prop, value, mode), negate
@@ -27,6 +27,8 @@ Sep
27
27
  Tidelift
28
28
  Tox
29
29
  Twemoji
30
+ UAX
31
+ UTS
30
32
  Zensical
31
33
  accessor
32
34
  backend
@@ -39,6 +41,7 @@ formatter
39
41
  grapheme
40
42
  hashable
41
43
  incrementing
44
+ lookbehinds
42
45
  namespaced
43
46
  natively
44
47
  parsers
@@ -3,6 +3,19 @@ icon: lucide/scroll-text
3
3
  ---
4
4
  # Changelog
5
5
 
6
+ ## 8.3
7
+
8
+ - **NEW**: `bre`: Add the binary properties `Any`, `Assigned`, and `ASCII`, which UTS \#18 requires but the Unicode
9
+ Character Database does not define. `ASCII` was previously only available as an alias of the `Basic_Latin` block.
10
+ - **NEW**: `bre`: Allow an initial `Is` on General Category values (`\p{IsLu}` or `\p{gc=IsLu}`) and on property names
11
+ (`\p{IsGC=Lu}` or `\p{IsAlphabetic=Yes}`), as Unicode allows, per UAX \#44.
12
+ - **FIX**: `bre`: Fix the inverse of the `No_Block` block property (`\P{No_Block}`) not including the
13
+ Supplementary Private Use Area blocks (`U+F0000` to `U+10FFFF`).
14
+ - **FIX**: `bre`: Fix negated Unicode properties and POSIX classes (`\P{Lu}`, `\p{^Lu}`, `[[:^upper:]]`) matching
15
+ both cases of the characters in the property when using `IGNORECASE`. Character sets that contain negated
16
+ properties are now expressed as alternatives (`[a\P{Lu}]` becomes `(?:[a]|[^...])`).
17
+ - **FIX**: `bre`: Fix an error when negating a binary property alias that has a value (`\P{Alpha=No}`).
18
+
6
19
  ## 8.2.1
7
20
 
8
21
  - **FIX**: Reject compiled replace templates from another search pattern in compiled search objects,
@@ -174,6 +174,15 @@ example, to specify characters that are "alphabetic", we can use `\p{Alphabetic:
174
174
  > [!new] New 5.4 Custom Binary properties
175
175
  > In 5.4, the new custom binary properties `Vert_space` and `Horiz_Space` were added.
176
176
 
177
+ The binary properties `Any`, `Assigned`, and `ASCII` are also available. These are not in the Unicode Character
178
+ Database, but are required of regular expression engines by [UTS #18](https://www.unicode.org/reports/tr18/#RL1.2):
179
+
180
+ Property | Matches
181
+ ---------- | -------
182
+ `Any` | Every character, even with the `ASCII` flag or in byte strings, as in Regex.
183
+ `Assigned` | Every character that is assigned (everything not in General Category `Cn`).
184
+ `ASCII` | Every ASCII character (`U+0000` to `U+007F`). This is the same as the `ASCII` alias of the `Basic_Latin` block.
185
+
177
186
  ### Property Short Names
178
187
 
179
188
  General Category, Script Extensions, Blocks, and Binary all can be specified in a short form using just their name or
@@ -185,9 +194,13 @@ that is used in Script may be used in Blocks etc.
185
194
  4. Binary
186
195
  3. Blocks
187
196
 
188
- Script Extensions and Binary properties can also be defined in the format `IsValue`. For instance, if we wanted to
189
- match characters in the `Latin` script, we could use the syntax `\p{IsLatin}`, which would be the same as `\p{Latin}` or
190
- `\p{scx: Latin}`. For Binary properties, `\p{IsAlphabetic}` is the same as `\p{Alphabetic: Y}` or `\p{Alphabetic}`.
197
+ General Category, Script Extensions, and Binary properties can also be defined in the format `IsValue`. For instance,
198
+ if we wanted to match characters in the `Latin` script, we could use the syntax `\p{IsLatin}`, which would be the same as
199
+ `\p{Latin}` or `\p{scx: Latin}`. For Binary properties, `\p{IsAlphabetic}` is the same as `\p{Alphabetic: Y}` or
200
+ `\p{Alphabetic}`, and for General Category, `\p{IsLu}` is the same as `\p{Lu}`.
201
+
202
+ As Unicode allows, an initial `Is` is also accepted on property names (`\p{IsGeneral_Category: Lu}` or
203
+ `\p{IsAlphabetic: Yes}`) and on General Category values (`\p{gc: IsLu}`).
191
204
 
192
205
  Block properties have a similar short form as Script and Binary properties. For Blocks you can use `InValue` to specify
193
206
  a block. If we wanted to match characters in the `Basic_Latin` block, we could use the syntax `\p{InBasic_Latin}`. This
@@ -155,3 +155,44 @@ class TestBinary(unittest.TestCase):
155
155
  result1 = uniprops.get_unicode_property(is_k)
156
156
  result2 = uniprops.get_unicode_property(is_v)
157
157
  self.assertEqual(result1, result2)
158
+
159
+ def test_uts18_properties(self):
160
+ """Test the UTS #18 properties that are not in the Unicode Character Database: `Any`, `Assigned`, `ASCII`."""
161
+
162
+ import re._parser as parser
163
+ from backrefs.uniprops.unidata import generalcategory
164
+
165
+ def code_points(text):
166
+ if not text:
167
+ return set()
168
+ node = parser.parse(f'[{text}]')[0]
169
+ points = set()
170
+ for op, av in [node] if node[0] is parser.LITERAL else node[1]:
171
+ points.update([av] if op is parser.LITERAL else range(av[0], av[1] + 1))
172
+ return points
173
+
174
+ everything = set(range(0x110000))
175
+ ascii_range = set(range(0x80))
176
+ assigned = everything - code_points(generalcategory.unicode_properties['c']['n'])
177
+
178
+ for table, expected in (
179
+ (binary.unicode_binary, {'any': everything, 'assigned': assigned, 'ascii': ascii_range}),
180
+ # `Any` is everything even in ASCII mode, as it is in Regex.
181
+ (binary.ascii_binary, {'any': everything, 'assigned': assigned & ascii_range, 'ascii': ascii_range}),
182
+ ):
183
+ for name, points in expected.items():
184
+ self.assertEqual(code_points(table[name]), points, name)
185
+ self.assertEqual(code_points(table['^' + name]), everything - points, name)
186
+
187
+ def test_uts18_property_forms(self):
188
+ """Test that the UTS #18 properties can be used like other binary properties."""
189
+
190
+ for name in ('any', 'assigned', 'ascii'):
191
+ positive = uniprops.get_unicode_property(name)
192
+ negative = uniprops.get_unicode_property('^' + name)
193
+ for prop, value in (
194
+ (name, 'yes'), (name, 't'), ('^' + name, 'no'), ('is' + name, None), ('is' + name, 'y')
195
+ ):
196
+ self.assertEqual(uniprops.get_unicode_property(prop, value), positive, (prop, value))
197
+ for prop, value in ((name, 'no'), ('^' + name, None), ('^' + name, 'yes'), ('^is' + name, None)):
198
+ self.assertEqual(uniprops.get_unicode_property(prop, value), negative, (prop, value))
@@ -31,6 +31,59 @@ class TestBlock(unittest.TestCase):
31
31
  if not key.startswith('^'):
32
32
  self.assertTrue('^' + key in keys1)
33
33
 
34
+ def test_inverse_is_complement(self):
35
+ """Test that every inverse block is exactly the code points not in the block."""
36
+
37
+ import re._parser as parser
38
+
39
+ def ranges(text):
40
+ """Get the sorted, merged ranges of a property's code points."""
41
+
42
+ if not text:
43
+ return []
44
+ node = parser.parse(f'[{text}]')[0]
45
+ items = []
46
+ for op, av in [node] if node[0] is parser.LITERAL else node[1]:
47
+ items.append((av, av) if op is parser.LITERAL else av)
48
+ merged = [] # type: list[tuple[int, int]]
49
+ for lo, hi in sorted(items):
50
+ if merged and lo <= merged[-1][1] + 1:
51
+ merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
52
+ else:
53
+ merged.append((lo, hi))
54
+ return merged
55
+
56
+ def complement(values):
57
+ """Get the ranges of all code points not in the given ranges."""
58
+
59
+ result = []
60
+ start = 0
61
+ for lo, hi in values:
62
+ if lo > start:
63
+ result.append((start, lo - 1))
64
+ start = hi + 1
65
+ if start <= 0x10FFFF:
66
+ result.append((start, 0x10FFFF))
67
+ return result
68
+
69
+ for table in (block.unicode_blocks, block.ascii_blocks):
70
+ for key, value in table.items():
71
+ if not key.startswith('^'):
72
+ self.assertEqual(ranges(table['^' + key]), complement(ranges(value)), key)
73
+
74
+ def test_no_block_inverse(self):
75
+ """Test that the inverse of `No_Block` includes the last blocks (Supplementary Private Use Areas)."""
76
+
77
+ from backrefs import bre
78
+
79
+ not_in_block = bre.compile(r'\p{No_Block}')
80
+ in_block = bre.compile(r'\P{No_Block}')
81
+ for c in (0xF0000, 0xFFFFD, 0x100000, 0x10FFFF):
82
+ self.assertTrue(in_block.fullmatch(chr(c)) is not None, f'U+{c:04X}')
83
+ self.assertTrue(not_in_block.fullmatch(chr(c)) is None, f'U+{c:04X}')
84
+ self.assertTrue(not_in_block.fullmatch('\U000effff') is not None)
85
+ self.assertTrue(in_block.fullmatch('\U000effff') is None)
86
+
34
87
  def test_block(self):
35
88
  """Test `Block` properties."""
36
89
 
@@ -411,6 +411,117 @@ class TestSearchTemplate(unittest.TestCase):
411
411
  with pytest.raises(PatternError, match='unterminated character set'):
412
412
  bre.compile(pattern)
413
413
 
414
+ def test_uts18_properties(self):
415
+ """Test `Any`, `Assigned`, and `ASCII`, which UTS #18 requires."""
416
+
417
+ unassigned = chr(0x0378)
418
+ for pattern, expected in (
419
+ (r'\p{Any}', ['A', 'é', unassigned]),
420
+ (r'\P{Any}', []),
421
+ (r'\p{Assigned}', ['A', 'é']),
422
+ (r'\P{Assigned}', [unassigned]),
423
+ (r'\p{ASCII}', ['A']),
424
+ (r'\p{ASCII=No}', ['é', unassigned]),
425
+ (r'[[:^ascii:]x]', ['é', unassigned]),
426
+ ):
427
+ p = bre.compile(pattern)
428
+ self.assertEqual([c for c in ('A', 'é', unassigned) if p.fullmatch(c)], expected, pattern)
429
+
430
+ # `ASCII` is also an alias of the `Basic_Latin` block, with the same characters.
431
+ expected = [chr(c) for c in range(0x100) if c < 0x80]
432
+ for pattern in (r'\p{ASCII}', r'\p{InASCII}', r'\p{Block=ASCII}', r'\p{IsASCII}'):
433
+ p = bre.compile(pattern)
434
+ self.assertEqual([chr(c) for c in range(0x100) if p.fullmatch(chr(c))], expected, pattern)
435
+
436
+ # `Any` is every character, even in ASCII mode, as it is in Regex.
437
+ self.assertTrue(bre.fullmatch(r'\p{Any}', 'é', re.ASCII) is not None)
438
+ self.assertTrue(bre.fullmatch(r'\P{Any}', 'é', re.ASCII) is None)
439
+ self.assertTrue(bre.fullmatch(r'\p{Assigned}', 'é', re.ASCII) is None)
440
+ self.assertEqual(len([c for c in range(256) if bre.fullmatch(rb'\p{Any}', bytes([c]))]), 256)
441
+ self.assertEqual(len([c for c in range(256) if bre.fullmatch(rb'\p{ASCII}', bytes([c]))]), 128)
442
+
443
+ def test_is_prefix(self):
444
+ """An initial `is` is allowed on property names and General Category values, as Unicode allows."""
445
+
446
+ for pattern, expected in (
447
+ (r'\p{IsLu}', ['A']),
448
+ (r'\p{Is_Uppercase_Letter}', ['A']),
449
+ (r'\P{IsLu}', ['a', '1']),
450
+ (r'\p{^IsLu}', ['a', '1']),
451
+ (r'[[:IsLu:]]', ['A']),
452
+ (r'[x\p{IsLu}]', ['A']),
453
+ (r'\p{gc=IsLu}', ['A']),
454
+ (r'\p{IsGC=Lu}', ['A']),
455
+ (r'\p{Is_General_Category=IsLu}', ['A']),
456
+ (r'\p{IsAlpha=Yes}', ['A', 'a']),
457
+ (r'\P{IsAlpha=No}', ['A', 'a']),
458
+ (r'\p{IsScript=Latin}', ['A', 'a']),
459
+ # Already supported
460
+ (r'\p{IsLatin}', ['A', 'a']),
461
+ (r'\p{IsAlpha}', ['A', 'a']),
462
+ ):
463
+ p = bre.compile(pattern)
464
+ self.assertEqual([c for c in 'Aa1' if p.fullmatch(c)], expected, pattern)
465
+
466
+ # Only removed once
467
+ for pattern in (r'\p{IsIsLu}', r'\p{gc=IsIsLu}', r'\p{IsIsGC=Lu}'):
468
+ with pytest.raises(ValueError):
469
+ bre.compile(pattern)
470
+
471
+ # Values that begin with `is` are unchanged.
472
+ self.assertTrue(bre.fullmatch(r'\p{lb=IS}', ',') is not None)
473
+ self.assertTrue(bre.fullmatch(r'\p{ccc=IS}', chr(0x0345)) is not None)
474
+ self.assertTrue(bre.fullmatch(r'\p{dt=Iso}', chr(0xFE80)) is not None)
475
+
476
+ def test_negated_property_ignorecase(self):
477
+ """Negated properties should not match the other case of characters in the property."""
478
+
479
+ def matches(pattern, chars='Aa1x', flags=re.IGNORECASE):
480
+ p = bre.compile(pattern, flags)
481
+ return [c for c in chars if p.fullmatch(c)]
482
+
483
+ # On their own
484
+ for pattern in (r'\P{Lu}', r'\p{^Lu}', r'\PL', r'[\P{Lu}]', r'[[:^upper:]]', r'[^\p{Lu}]'):
485
+ self.assertEqual(matches(pattern, 'Aa1'), ['1'], pattern)
486
+ self.assertEqual(matches(r'\P{Lu}', 'Aa1', 0), ['a', '1'])
487
+
488
+ # With other members of a set
489
+ self.assertEqual(matches(r'[\P{Lu}x]'), ['1', 'x'])
490
+ self.assertEqual(matches(r'[x\P{Lu}\d]'), ['1', 'x'])
491
+ self.assertEqual(matches(r'[\P{Lu}\P{Ll}]'), ['1'])
492
+ self.assertEqual(matches(r'[^\P{Lu}x]'), ['A', 'a'])
493
+ self.assertEqual(matches(r'[^\P{Lu}\P{Ll}]'), ['A', 'a', 'x'])
494
+ self.assertEqual(matches(r'[^\P{Lu}\d]'), ['A', 'a', 'x'])
495
+ self.assertEqual(matches(r'[\P{Lu}x]', flags=0), ['a', '1', 'x'])
496
+ self.assertEqual(matches(r'[^\P{Lu}x]', flags=0), ['A'])
497
+
498
+ # A literal `^` after the negating `^`
499
+ self.assertEqual(matches(r'[^^\P{Nd}]', '^1a'), ['1'])
500
+
501
+ # Quantifiers and lookbehinds
502
+ self.assertTrue(bre.fullmatch(r'[\P{Lu}x]{2}', 'X1', re.IGNORECASE) is not None)
503
+ self.assertTrue(bre.fullmatch(r'[\P{Lu}x]{2}', 'A1', re.IGNORECASE) is None)
504
+ self.assertTrue(bre.search(r'(?<=[^\P{Lu}x])b', 'ab', re.IGNORECASE) is not None)
505
+ self.assertTrue(bre.search(r'(?<=[^\P{Lu}x])b', 'xb', re.IGNORECASE) is None)
506
+ self.assertTrue(bre.search(r'(?<![\P{Lu}x])b', 'Ab', re.IGNORECASE) is not None)
507
+
508
+ # Byte strings
509
+ self.assertEqual([c for c in b'Aa1' if bre.fullmatch(rb'[\P{Lu}x]', bytes([c]), re.IGNORECASE)], [ord('1')])
510
+
511
+ def test_binary_property_false_value(self):
512
+ """A binary property with a false value is the set of characters with that value, not a negation."""
513
+
514
+ # So case-insensitive matching applies to that set, as it does in Regex.
515
+ p = bre.compile(r'[\p{Lowercase=No}x]', re.IGNORECASE)
516
+ self.assertEqual([c for c in 'Aa1x' if p.fullmatch(c)], ['A', 'a', '1', 'x'])
517
+
518
+ # Negations of a property alias with a value
519
+ for pattern, expected in (
520
+ (r'\P{Alpha=No}', ['A', 'a']), (r'\p{^Alpha=No}', ['A', 'a']), (r'\P{Alpha=Yes}', ['1', ' ']),
521
+ (r'\P{Upper=F}', ['A']), (r'\P{WSpace=No}', [' '])
522
+ ):
523
+ self.assertEqual([c for c in 'Aa1 ' if bre.fullmatch(pattern, c)], expected, pattern)
524
+
414
525
  def test_caret_after_negation(self):
415
526
  """A `^` after the negating `^` is a literal."""
416
527
 
@@ -170,3 +170,48 @@ class TestGeneralCategory(unittest.TestCase):
170
170
  result1 = uniprops.get_unicode_property(k)
171
171
  result2 = uniprops.get_unicode_property(v)
172
172
  self.assertEqual(result1, result2)
173
+
174
+ def test_is_prefix(self):
175
+ """Test that an initial `is` is allowed on General Category values and on the property name (`UAX44-LM3`)."""
176
+
177
+ table = generalcategory.unicode_properties
178
+ names = set(table) | {a + b for a in table for b in table[a] if not b.startswith('^')}
179
+ names |= set(alias.unicode_alias['generalcategory'])
180
+ for name in names:
181
+ expected = uniprops.get_unicode_property(name)
182
+ negated = uniprops.get_unicode_property('^' + name)
183
+ self.assertEqual(uniprops.get_unicode_property('is' + name), expected, name)
184
+ self.assertEqual(uniprops.get_unicode_property('^is' + name), negated, name)
185
+ for prop in ('gc', 'generalcategory', 'isgc', 'isgeneralcategory'):
186
+ self.assertEqual(uniprops.get_unicode_property(prop, 'is' + name), expected, (prop, name))
187
+ self.assertEqual(uniprops.get_unicode_property(prop, name), expected, (prop, name))
188
+ self.assertEqual(uniprops.get_unicode_property('^' + prop, 'is' + name), negated, (prop, name))
189
+
190
+ # An initial `is` is only removed once.
191
+ for args in (('isislu',), ('gc', 'isislu'), ('isisgc', 'lu'), ('is',), ('gc', 'is')):
192
+ with self.assertRaises(ValueError):
193
+ uniprops.get_unicode_property(*args)
194
+
195
+ def test_is_prefix_unambiguous(self):
196
+ """
197
+ Test that no name begins with `is`.
198
+
199
+ Removing an initial `is` relies on names being unique even when an initial `is` is ignored, which Unicode
200
+ guarantees. As no name begins with `is`, that holds for the names Backrefs supports. If a name ever begins
201
+ with `is`, check that it can't be confused with another name before allowing it here.
202
+ """
203
+
204
+ from backrefs.uniprops.unidata import binary, block, scriptextensions
205
+
206
+ table = generalcategory.unicode_properties
207
+ namespaces = {
208
+ 'General Category': (
209
+ set(table) | {a + b for a in table for b in table[a]} | set(alias.unicode_alias['generalcategory'])
210
+ ),
211
+ 'Script': set(scriptextensions.unicode_script_extensions) | set(alias.unicode_alias['script']),
212
+ 'Binary': set(binary.unicode_binary) | set(alias.unicode_alias['binary']),
213
+ 'Block': set(block.unicode_blocks) | set(alias.unicode_alias['block']),
214
+ 'Property names': set(alias.unicode_alias['_']) | set(alias.unicode_alias['_'].values()),
215
+ }
216
+ for namespace, names in namespaces.items():
217
+ self.assertEqual([n for n in names if n.lstrip('^').startswith('is')], [], namespace)
@@ -316,6 +316,9 @@ def gen_blocks(output, ascii_props=False, append=False, prefix="", aliases=None)
316
316
  if piece[0] > last + 1:
317
317
  no_block_inverse.append((last + 1, piece[0] - 1))
318
318
  last = piece[1]
319
+ # Include everything after the last range that is not in a block.
320
+ if last < max_range:
321
+ no_block_inverse.append((last + 1, max_range))
319
322
  for block, name in ((no_block, 'noblock'), (no_block_inverse, '^noblock')):
320
323
  f.write('\n "%s": "' % name)
321
324
  for piece in block:
@@ -688,6 +691,14 @@ def gen_binary(table, output, ascii_props=False, append=False, prefix="", aliase
688
691
  binary['horizspace'] = [x for x in binary['horizspace'] if x <= max_range]
689
692
  binary['vertspace'] = [x for x in binary['vertspace'] if x <= max_range]
690
693
 
694
+ # Properties required by UTS #18 (`RL1.2`) that are not defined in the Unicode Character Database.
695
+ # `Any` is every character, even in ASCII mode, as it is in Regex. `Assigned` is everything that is not
696
+ # unassigned (General Category `Cn`).
697
+ unassigned = set(table['c']['n'])
698
+ binary['any'] = list(range(0, MAXUNICODE + 1))
699
+ binary['assigned'] = [x for x in range(0, max_range + 1) if x not in unassigned]
700
+ binary['ascii'] = list(range(0, min(MAXVALIDASCII, max_range) + 1))
701
+
691
702
  for filename, include in binary_props:
692
703
  with open(os.path.join(HOME, 'unicodedata', UNIVERSION, filename), 'r', encoding='utf-8') as uf:
693
704
  for line in uf:
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