unstd 2.0.0__tar.gz → 2.1.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 (113) hide show
  1. {unstd-2.0.0 → unstd-2.1.1}/.github/workflows/ci.yml +12 -8
  2. {unstd-2.0.0 → unstd-2.1.1}/.github/workflows/release.yml +6 -1
  3. {unstd-2.0.0 → unstd-2.1.1}/CHANGELOG.md +49 -0
  4. {unstd-2.0.0 → unstd-2.1.1}/PKG-INFO +2 -2
  5. unstd-2.1.1/changelog.d/README.md +14 -0
  6. {unstd-2.0.0 → unstd-2.1.1}/pyproject.toml +11 -2
  7. unstd-2.1.1/src/unstd/rex/README.md +86 -0
  8. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/rex/__init__.py +76 -113
  9. unstd-2.1.1/src/unstd/rex/_faithful.py +304 -0
  10. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/serde/structs.py +7 -1
  11. unstd-2.1.1/tests/rex/test_fidelity.py +330 -0
  12. {unstd-2.0.0 → unstd-2.1.1}/tests/serde/test_structs.py +54 -0
  13. {unstd-2.0.0 → unstd-2.1.1}/uv.lock +602 -549
  14. unstd-2.0.0/src/unstd/rex/README.md +0 -132
  15. {unstd-2.0.0 → unstd-2.1.1}/.gitignore +0 -0
  16. {unstd-2.0.0 → unstd-2.1.1}/LICENSE +0 -0
  17. {unstd-2.0.0 → unstd-2.1.1}/NOTICE +0 -0
  18. {unstd-2.0.0 → unstd-2.1.1}/README.md +0 -0
  19. {unstd-2.0.0 → unstd-2.1.1}/bench/README.md +0 -0
  20. {unstd-2.0.0 → unstd-2.1.1}/bench/__init__.py +0 -0
  21. {unstd-2.0.0 → unstd-2.1.1}/bench/__main__.py +0 -0
  22. {unstd-2.0.0 → unstd-2.1.1}/bench/baseline.json +0 -0
  23. {unstd-2.0.0 → unstd-2.1.1}/bench/cases.py +0 -0
  24. {unstd-2.0.0 → unstd-2.1.1}/bench/report.py +0 -0
  25. {unstd-2.0.0 → unstd-2.1.1}/bench/timing.py +0 -0
  26. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/__init__.py +0 -0
  27. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/_extra.py +0 -0
  28. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/audio/README.md +0 -0
  29. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/audio/__init__.py +0 -0
  30. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/audio/wavx.py +0 -0
  31. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/clone/README.md +0 -0
  32. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/clone/__init__.py +0 -0
  33. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/clone/structural.py +0 -0
  34. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/containers/README.md +0 -0
  35. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/containers/__init__.py +0 -0
  36. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/containers/persistent.py +0 -0
  37. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/containers/sorted.py +0 -0
  38. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/crypto/README.md +0 -0
  39. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/crypto/__init__.py +0 -0
  40. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/crypto/_blake3.py +0 -0
  41. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/crypto/digest.py +0 -0
  42. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/crypto/tls.py +0 -0
  43. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/crypto/token.py +0 -0
  44. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/fs/README.md +0 -0
  45. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/fs/__init__.py +0 -0
  46. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/ids/README.md +0 -0
  47. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/ids/__init__.py +0 -0
  48. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/ids/uid.py +0 -0
  49. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/iters/README.md +0 -0
  50. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/iters/__init__.py +0 -0
  51. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/iters/consecutive.py +0 -0
  52. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/iters/distinct.py +0 -0
  53. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/iters/reshape.py +0 -0
  54. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/iters/select.py +0 -0
  55. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/model/README.md +0 -0
  56. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/model/__init__.py +0 -0
  57. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/model/base.py +0 -0
  58. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/model/settings.py +0 -0
  59. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/pack/README.md +0 -0
  60. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/pack/__init__.py +0 -0
  61. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/pack/binpack.py +0 -0
  62. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/proc/README.md +0 -0
  63. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/proc/__init__.py +0 -0
  64. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/py.typed +0 -0
  65. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/rand/README.md +0 -0
  66. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/rand/__init__.py +0 -0
  67. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/rand/crypto.py +0 -0
  68. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/rand/draw.py +0 -0
  69. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/serde/README.md +0 -0
  70. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/serde/__init__.py +0 -0
  71. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/serde/b64.py +0 -0
  72. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/serde/jsonx.py +0 -0
  73. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/serde/ndjson.py +0 -0
  74. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/text/README.md +0 -0
  75. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/text/__init__.py +0 -0
  76. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/text/fuzz.py +0 -0
  77. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/time/README.md +0 -0
  78. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/time/__init__.py +0 -0
  79. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/time/timeutil.py +0 -0
  80. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/time/zoned.py +0 -0
  81. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/toml/README.md +0 -0
  82. {unstd-2.0.0 → unstd-2.1.1}/src/unstd/toml/__init__.py +0 -0
  83. {unstd-2.0.0 → unstd-2.1.1}/tests/audio/test_audio.py +0 -0
  84. {unstd-2.0.0 → unstd-2.1.1}/tests/clone/test_clone.py +0 -0
  85. {unstd-2.0.0 → unstd-2.1.1}/tests/conftest.py +0 -0
  86. {unstd-2.0.0 → unstd-2.1.1}/tests/containers/test_containers.py +0 -0
  87. {unstd-2.0.0 → unstd-2.1.1}/tests/crypto/blake3_reference_vectors.json +0 -0
  88. {unstd-2.0.0 → unstd-2.1.1}/tests/crypto/test_adverse.py +0 -0
  89. {unstd-2.0.0 → unstd-2.1.1}/tests/crypto/test_vectors.py +0 -0
  90. {unstd-2.0.0 → unstd-2.1.1}/tests/fs/test_fs.py +0 -0
  91. {unstd-2.0.0 → unstd-2.1.1}/tests/ids/test_uid.py +0 -0
  92. {unstd-2.0.0 → unstd-2.1.1}/tests/iters/test_iters.py +0 -0
  93. {unstd-2.0.0 → unstd-2.1.1}/tests/model/test_model.py +0 -0
  94. {unstd-2.0.0 → unstd-2.1.1}/tests/pack/test_pack.py +0 -0
  95. {unstd-2.0.0 → unstd-2.1.1}/tests/proc/test_proc.py +0 -0
  96. {unstd-2.0.0 → unstd-2.1.1}/tests/properties/README.md +0 -0
  97. {unstd-2.0.0 → unstd-2.1.1}/tests/properties/test_clone_invariants.py +0 -0
  98. {unstd-2.0.0 → unstd-2.1.1}/tests/properties/test_pack_invariants.py +0 -0
  99. {unstd-2.0.0 → unstd-2.1.1}/tests/properties/test_serde_parity.py +0 -0
  100. {unstd-2.0.0 → unstd-2.1.1}/tests/properties/test_toml_invariants.py +0 -0
  101. {unstd-2.0.0 → unstd-2.1.1}/tests/properties/test_uuid7.py +0 -0
  102. {unstd-2.0.0 → unstd-2.1.1}/tests/properties/test_wav_quant.py +0 -0
  103. {unstd-2.0.0 → unstd-2.1.1}/tests/rand/test_rand.py +0 -0
  104. {unstd-2.0.0 → unstd-2.1.1}/tests/rex/test_rex.py +0 -0
  105. {unstd-2.0.0 → unstd-2.1.1}/tests/serde/test_b64.py +0 -0
  106. {unstd-2.0.0 → unstd-2.1.1}/tests/serde/test_jsonx.py +0 -0
  107. {unstd-2.0.0 → unstd-2.1.1}/tests/serde/test_ndjson.py +0 -0
  108. {unstd-2.0.0 → unstd-2.1.1}/tests/test_bench.py +0 -0
  109. {unstd-2.0.0 → unstd-2.1.1}/tests/test_package.py +0 -0
  110. {unstd-2.0.0 → unstd-2.1.1}/tests/text/test_text.py +0 -0
  111. {unstd-2.0.0 → unstd-2.1.1}/tests/time/test_timeutil.py +0 -0
  112. {unstd-2.0.0 → unstd-2.1.1}/tests/time/test_zoned.py +0 -0
  113. {unstd-2.0.0 → unstd-2.1.1}/tests/toml/test_toml.py +0 -0
@@ -14,7 +14,7 @@ permissions:
14
14
  contents: read
15
15
 
16
16
  concurrency:
17
- group: ${{ github.workflow }}-${{ github.ref }}
17
+ group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}
18
18
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
19
19
 
20
20
  jobs:
@@ -29,11 +29,12 @@ jobs:
29
29
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
30
30
  with:
31
31
  persist-credentials: false
32
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
32
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
33
33
  with:
34
34
  enable-cache: false
35
+ version: "latest-known"
35
36
  python-version: "3.13"
36
- - run: uv sync --all-extras --group dev --python 3.13
37
+ - run: uv sync --locked --all-extras --group dev --python 3.13
37
38
  - run: uv run --python 3.13 ruff check .
38
39
  - run: uv run --python 3.13 ruff format --check .
39
40
  - run: uv run --python 3.13 mypy
@@ -52,11 +53,12 @@ jobs:
52
53
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
53
54
  with:
54
55
  persist-credentials: false
55
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
56
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
56
57
  with:
57
58
  enable-cache: false
59
+ version: "latest-known"
58
60
  python-version: ${{ matrix.python-version }}
59
- - run: uv sync --all-extras --group dev --python ${{ matrix.python-version }}
61
+ - run: uv sync --locked --all-extras --group dev --python ${{ matrix.python-version }}
60
62
  - run: uv run --python ${{ matrix.python-version }} pytest -q
61
63
 
62
64
  floors:
@@ -75,9 +77,10 @@ jobs:
75
77
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
76
78
  with:
77
79
  persist-credentials: false
78
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
80
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
79
81
  with:
80
82
  enable-cache: false
83
+ version: "latest-known"
81
84
  python-version: "3.13"
82
85
  - run: uv sync --all-extras --group dev --python 3.13 --resolution lowest-direct
83
86
  # Which versions the floors admitted — the first thing anyone reading a
@@ -105,11 +108,12 @@ jobs:
105
108
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
106
109
  with:
107
110
  persist-credentials: false
108
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
111
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
109
112
  with:
110
113
  enable-cache: false
114
+ version: "latest-known"
111
115
  python-version: "3.13"
112
- - run: uv sync --group dev --python 3.13
116
+ - run: uv sync --locked --group dev --python 3.13
113
117
  # A diagnostic, not a gate — the zero-closure claim is asserted offline by
114
118
  # `tests/test_package.py`. This is here because a failure that reproduces
115
119
  # only on the fallback leg is nearly unreadable without knowing which
@@ -36,8 +36,13 @@ jobs:
36
36
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
37
37
  with:
38
38
  persist-credentials: false
39
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
39
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
40
40
  with:
41
41
  enable-cache: false
42
+ version: "latest-known"
43
+ - name: The tag agrees with package metadata
44
+ env:
45
+ TAG: ${{ github.ref_name }}
46
+ run: test "$TAG" = "v$(uv version --short)"
42
47
  - run: uv build
43
48
  - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -1,7 +1,56 @@
1
+ ---
2
+ doc_radar:
3
+ sentinels:
4
+ - file: src/unstd/rex/_faithful.py
5
+ contains: ['_parser.parse(pattern, flags)', 'if started:']
6
+ - file: pyproject.toml
7
+ contains: ['"msgspec>=0.22"', 'exclude-newer = "P2D"']
8
+ - file: .github/workflows/ci.yml
9
+ contains: ['github.sha || github.ref', 'version: "latest-known"', 'uv sync --locked']
10
+ - file: .github/workflows/release.yml
11
+ contains: ['TAG: ${{ github.ref_name }}', 'uv version --short']
12
+ - file: src/unstd/serde/structs.py
13
+ contains: ['Callable[[Decimal], object]', 'decimal_format: _DecimalFormat']
14
+ ---
15
+
1
16
  # Changelog
2
17
 
3
18
  <!-- towncrier release notes start -->
4
19
 
20
+ ## [2.1.1] - 2026-10-04
21
+
22
+ ### Fixed
23
+
24
+ We keep Python's Unicode classification, scoped flags, empty-match iteration and
25
+ nullable capture behavior across native engine upgrades. Eligible ASCII and byte
26
+ work still run natively; surrogate recovery and typed native refusals preserve
27
+ outputs without retrying replacement callbacks.
28
+
29
+ ## [2.1.0] - 2026-10-04
30
+
31
+ ### Added
32
+
33
+ - `Codec(decimal_format=...)` accepts a callable over each `Decimal`, so exact
34
+ decimal policies stay exact instead of passing through a float. Callback
35
+ errors propagate, and the same encoder remains usable afterward. This uses
36
+ [msgspec 0.22's encoder support](https://github.com/msgspec/msgspec/releases/tag/0.22.0).
37
+
38
+ ### Changed
39
+
40
+ - We refreshed the optional-backend and development lock, including uuid-utils
41
+ 1.0 and msgspec 0.22. Existing UUID generation and codec defaults keep their
42
+ behavior; the serde extra's msgspec floor moves to 0.22.
43
+ - We quarantine new resolver and isolated-build releases for two days. CI uses
44
+ the reviewed lock and checksum-backed uv versions from the pinned
45
+ [setup-uv 10.2 action](https://github.com/astral-sh/setup-uv/releases/tag/v10.2.0).
46
+ The separate floor job still proves the oldest supported direct dependencies.
47
+
48
+ ### Fixed
49
+
50
+ - We keep each main commit's CI run in its own concurrency group. Newer pushes
51
+ cannot replace pending checks for a release commit, and the release tag must
52
+ agree with the package version before publication.
53
+
5
54
  ## [2.0.0] - 2026-09-27
6
55
 
7
56
  ### Breaking
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: unstd
3
- Version: 2.0.0
3
+ Version: 2.1.1
4
4
  Summary: stdlib-faithful accelerated replacements behind optional extras
5
5
  Project-URL: Homepage, https://github.com/The-Billy-Company/unstd
6
6
  Project-URL: Repository, https://github.com/The-Billy-Company/unstd
@@ -43,7 +43,7 @@ Requires-Dist: numpy>=2.5; extra == 'rand'
43
43
  Provides-Extra: rex
44
44
  Requires-Dist: irregex>=2.6.0; extra == 'rex'
45
45
  Provides-Extra: serde
46
- Requires-Dist: msgspec>=0.21; extra == 'serde'
46
+ Requires-Dist: msgspec>=0.22; extra == 'serde'
47
47
  Requires-Dist: orjson>=3.12; extra == 'serde'
48
48
  Requires-Dist: pybase64>=1.5; extra == 'serde'
49
49
  Provides-Extra: text
@@ -0,0 +1,14 @@
1
+ ---
2
+ doc_radar:
3
+ sentinels:
4
+ - file: CHANGELOG.md
5
+ contains: ['## [2.1.1]', 'Codec(decimal_format=...)']
6
+ - file: pyproject.toml
7
+ contains: ['exclude-newer = "P2D"']
8
+ ---
9
+
10
+ # Unreleased changes
11
+
12
+ We keep one fragment per change here; release history stays in the root changelog.
13
+ Fragments describe what changed for a caller. On release, we fold their bodies
14
+ into the root changelog and move their source assertions with them.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "unstd"
7
- version = "2.0.0"
7
+ version = "2.1.1"
8
8
  description = "stdlib-faithful accelerated replacements behind optional extras"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -45,7 +45,7 @@ dependencies = []
45
45
  [project.optional-dependencies]
46
46
  serde = [
47
47
  "orjson>=3.12",
48
- "msgspec>=0.21",
48
+ "msgspec>=0.22",
49
49
  "pybase64>=1.5",
50
50
  ]
51
51
  ids = [
@@ -114,6 +114,15 @@ dev = [
114
114
  "cryptography>=44.0",
115
115
  ]
116
116
 
117
+ [tool.uv]
118
+ # Keep development, optional backends and isolated builds behind the same
119
+ # two-day release quarantine as the applications that consume this package.
120
+ exclude-newer = "P2D"
121
+
122
+ [tool.uv.exclude-newer-package]
123
+ # Our engine is released by the same verified publisher; upstreams keep P2D.
124
+ irregex = false
125
+
117
126
  [tool.pytest.ini_options]
118
127
  testpaths = ["tests"]
119
128
  # The repo root, so `tests/test_bench.py` can import the `bench` harness. `bench`
@@ -0,0 +1,86 @@
1
+ ---
2
+ doc_radar:
3
+ sentinels:
4
+ - file: src/unstd/rex/_faithful.py
5
+ contains: ['_parser.parse(pattern, flags)', 'parsed.getwidth()[0] == 0', 'started = True', 'if started:']
6
+ - file: src/unstd/rex/__init__.py
7
+ contains: ['python = _re.compile(pattern, flags)', 'profile, _irgx.error', '_MAXCACHE = 512']
8
+ - file: pyproject.toml
9
+ contains: ['"irregex>=2.6.0"']
10
+ ---
11
+
12
+ # `unstd.rex`
13
+
14
+ We put our [irregex](https://github.com/The-Billy-Company/irregex) engine behind
15
+ Python's `re` surface. Install `pip install 'unstd[rex]'`, then swap the import:
16
+
17
+ ```python
18
+ from unstd import rex
19
+
20
+ rx = rex.compile(r"(?a:\w+)")
21
+ assert rx.findall("café 123") == ["caf", "123"]
22
+ ```
23
+
24
+ `compile`, `search`, `match`, `fullmatch`, `findall`, `finditer`, `sub`, `subn`,
25
+ `split`, `escape`, `purge`, flags and compile errors keep their familiar shape.
26
+ A base installation has zero dependencies and uses stdlib `re`.
27
+
28
+ ## Python owns the meaning
29
+
30
+ We validate with Python's compiler and inspect its parsed operations, including
31
+ scoped ASCII, Unicode and case flags. The native engine keeps supported regular
32
+ patterns, bytes, faithful ASCII classes and verbose mode; the compiled facade
33
+ retains your source and Python flag metadata.
34
+
35
+ Unicode word/digit classes and boundaries run natively on ASCII subjects and
36
+ use Python on non-ASCII subjects. Unicode whitespace and cased Unicode
37
+ `IGNORECASE` patterns use Python even on ASCII input; their semantics differ
38
+ between the engines, and we use the running interpreter's Unicode version.
39
+
40
+ Empty-match patterns and nullable repeats use Python's iteration and capture
41
+ rules. Backreferences, lookaround, unsupported operations and `LOCALE`/`DEBUG`
42
+ also fall back, as do native operations that decline a requested match shape.
43
+
44
+ ## Safety follows execution
45
+
46
+ Native matching uses finite automata, so hostile patterns cannot trigger
47
+ catastrophic backtracking there. `rex.search(r"(a+)+$", "a" * 40 + "!")` keeps
48
+ that native safety win; the tests prove the same call hangs on stdlib `re` in a
49
+ separate process.
50
+
51
+ Every fallback uses Python's backtracking engine and loses that guarantee.
52
+ Installing the extra alone is insufficient: use supported regular patterns and
53
+ bytes or faithful ASCII expressions over encodable input when linear matching
54
+ is required.
55
+
56
+ ## Recovery keeps side effects intact
57
+
58
+ Python strings can contain lone surrogates that UTF-8 cannot encode. We recover
59
+ on Python before producing a match or starting a replacement callback; a
60
+ callback's own exception propagates unchanged, and callbacks are never retried.
61
+
62
+ `findall` converts absent groups to empty strings for compiled and module calls;
63
+ `groups()` still reports `None`. Negative substitution or split limits leave the
64
+ input unchanged, just as Python does.
65
+
66
+ ## Existing boundaries
67
+
68
+ On the native path, `$` means absolute end rather than the position before a
69
+ final newline. Use `(?m)…$` for line ends or `…\Z` for absolute end; POSIX bracket
70
+ expressions also retain their native class meaning instead of Python's
71
+ nested-set spelling.
72
+
73
+ Native matches provide `group`, `groups`, `groupdict`, `span`, `expand`, `start`
74
+ and `end`; `.pos` and `.endpos` remain stdlib-only. A callable replacement sees
75
+ the active backend's match, whose `.re.flags` uses native bookkeeping.
76
+
77
+ `error` and `escape` are stdlib's own objects, native-only syntax is refused by
78
+ Python, and compiled patterns round-trip through every module verb. Compiles and
79
+ declines share a bounded cache of 512 entries; `purge` clears it and both engines.
80
+
81
+ ## Where it comes from
82
+
83
+ We replaced RE2 once irregex supplied the existing API, retiring that dependency.
84
+ The safety primitive is [Thompson's automaton construction, explained by Russ
85
+ Cox](https://swtch.com/~rsc/regexp/regexp1.html); the language contract comes from
86
+ [Python's regex documentation](https://docs.python.org/3/library/re.html).
@@ -1,102 +1,63 @@
1
- r"""Drop-in linear-time regex — irregex under a stdlib-``re``-faithful surface.
2
-
3
- Stdlib ``re`` is a backtracking engine: a hostile pattern (or a hostile input
4
- against a benign pattern) can trigger *catastrophic backtracking* — exponential
5
- match time that pins a CPU (a ReDoS). That is unacceptable when the pattern or
6
- the text is **untrusted** — LLM-supplied regexes, user filters, matching over
7
- tool output or retrieved documents. ``rex`` is a stdlib-``re``-faithful surface
8
- backed by **irregex**, our own finite-automaton engine, which matches in
9
- time linear in the length of the text and independent of the pattern's shape —
10
- the ReDoS class simply cannot occur on the irgx path. Migrate a call site by
11
- swapping the import::
12
-
13
- import re # →
14
- from unstd import rex # rex.compile / rex.search / … , same surface
15
-
16
- Backend & fallback: the ``rex`` extra provides ``irregex`` (import
17
- name ``irgx``). Its default grammar excludes the two features that force
18
- backtracking — **backreferences** (``\\1``) and **lookaround** (``(?=…)`` /
19
- ``(?<=…)``) — so it *declines* them with ``irgx.UnsupportedPattern`` rather
20
- than failing. ``rex`` degrades gracefully in two ways, mirroring how
21
- ``serde.jsonx`` auto-falls-back for exact-format cases:
22
-
23
- 1. **Backend absent** (a base ``unstd`` install) → every call uses stdlib ``re``.
24
- 2. **Backend present but the pattern needs a PCRE-only feature** → irgx declines
25
- it at compile; ``rex`` transparently re-compiles that one pattern on stdlib
26
- ``re``. Also degrades for the two ``re`` flags irgx has no spelling for
27
- (``LOCALE`` / ``DEBUG``).
28
-
29
- **The linear-time (ReDoS-immune) guarantee holds only on the irgx path.** A
30
- pattern that degrades to stdlib ``re`` — a backref, a lookaround, ``LOCALE`` /
31
- ``DEBUG``, or the absent-backend case — silently loses the guarantee and is once
32
- again backtracking. This is a deliberate trade (faithful behavior over a hard
33
- failure); it is pinned as a regression test in ``tests/rex/test_rex.py``. If
34
- linear-time is a *hard* requirement for a given call, keep the pattern within
35
- irgx's default grammar (no backref/lookaround) and install the extra.
36
-
37
- Faithfulness contract — what stays identical to stdlib ``re``:
38
-
39
- - The module surface — ``compile`` · ``search`` · ``match`` · ``fullmatch`` ·
40
- ``findall`` · ``finditer`` · ``sub`` · ``subn`` · ``split`` · ``escape`` ·
41
- ``purge`` — plus the flag/type vocabulary (``I``/``IGNORECASE`` … ``Pattern``,
42
- ``Match``, ``RegexFlag``). ``escape`` is stdlib ``re.escape`` verbatim (its
43
- output is a literal irgx also accepts).
44
- - ``error`` is re-exported from stdlib ``re``. ``irgx.error`` never escapes: a
45
- pattern irgx refuses — malformed or merely declined — is re-compiled on stdlib
46
- ``re``, which raises ``re.error`` for the malformed case, so ``except
47
- rex.error`` keeps catching every compile failure.
48
- - Compiled objects and match objects: irgx's ``Pattern`` / ``Match`` mirror
49
- ``re.Pattern`` / ``re.Match`` (``.search``/``.match``/``.sub``/``.groups``/
50
- ``.groupdict``/…), so a compiled pattern is drop-in on either backend.
51
- - Flags are honored by translating the stdlib bitmask into irgx's keyword flags:
52
- ``I``/``M``/``S``/``X`` → ``ignore_case``/``multiline``/``dotall``/``verbose``,
53
- and ``A`` → ``unicode=False``, which restricts ``\\w \\d \\s \\b`` and ``.`` to
54
- ASCII exactly as ``A`` does.
55
- - ``bytes`` patterns ride the irgx path, compiled with ``unicode=False`` so
56
- classes are byte-oriented — which *is* stdlib's rule for a ``bytes`` pattern.
57
- Without it irgx would read the subject as UTF-8 and ``\\w+`` would swallow
58
- ``café``'s tail where stdlib stops at ``caf``.
59
- - A compiled pattern from either backend is accepted wherever a pattern string
60
- is, and ``purge`` clears every cache.
61
- - Compiles are cached like stdlib's own ``re._cache`` (bounded, oldest out), so
62
- a module-level call is one dict lookup — including for a pattern irgx
63
- *declined*, which irgx itself does not remember and would re-attempt at the
64
- cost of a full compile plus a raise.
65
- - ``findall``'s non-participating groups are rewritten from irgx's ``None`` to
66
- stdlib's ``""`` (``.groups()`` already reports ``None`` in both libraries, so
67
- only this one projection needed adapting).
68
-
69
- Divergences (deliberate, documented, pinned as tests):
70
-
71
- - **PCRE-only patterns degrade to stdlib** (backref / lookaround) — losing the
72
- linear-time guarantee (above). ``compile`` returns a stdlib ``re.Pattern`` for
73
- these even when the backend is present.
74
- - **``$`` is the end of the text, not the byte before a trailing newline.**
75
- Stdlib inherits Perl's rule where ``$`` also matches just before a final
76
- ``\\n``; irgx follows Rust's ``regex`` and Go's ``regexp`` in reading it as the
77
- absolute end. So ``rex.findall(r"[a-z]+$", "cat\\ndog\\n")`` finds nothing
78
- where stdlib finds ``dog``. Three spellings agree, and one of them is usually
79
- what was meant: ``(?m)[a-z]+$`` (every line's end), ``[a-z]+\\Z`` (the text's
80
- end, said exactly), or strip the newline first. **This divergence predates the
81
- irgx backend** — RE2 read ``$`` the same way — it was simply never written down.
82
- - **POSIX bracket expressions mean different things.** ``[[:alpha:]]`` is a real
83
- character class to irgx and a nested-set typo to stdlib (which reads it as
84
- ``[`` ``:`` ``a`` ``l`` ``p`` ``h`` and warns). Also inherited from the RE2
85
- era, and the reason to keep patterns inside the shared grammar.
86
- - **On the irgx path a compiled pattern's ``.flags`` reflects irgx's own
87
- bookkeeping** rather than the stdlib bitmask. Match *behavior* is identical;
88
- only that cosmetic attribute differs from what stdlib would report.
89
- - **``re.UNICODE`` is a no-op on the irgx path** (irgx is UTF-8-native for
90
- ``str`` input). It is *not* mapped to inline ``(?U)`` — there ``(?U)`` swaps
91
- greediness, a different meaning entirely.
92
-
93
- Prior art: the linear-time automaton construction is Thompson's, popularized for
94
- this use by Russ Cox, "Regular Expression Matching Can Be Simple And Fast"
95
- (2007, https://swtch.com/~rsc/regexp/regexp1.html). ``rex`` rode Google's RE2
96
- implementation of it until the engine we publish ourselves — irregex
97
- (https://github.com/The-Billy-Company/irregex) — grew the ``re``-shaped surface
98
- this shim needs, at which point paying a third-party dependency for a capability
99
- we ship stopped making sense.
1
+ r"""Linear regex where Python's semantics survive the native boundary.
2
+
3
+ Swap ``import re`` for ``from unstd import rex`` to keep the module verbs,
4
+ flags, compile errors and ordinary match interface. The optional ``rex`` extra
5
+ provides ``irregex`` (import ``irgx``); a base installation uses stdlib ``re``.
6
+
7
+ The compiler validates with Python first, then reads its parsed operations,
8
+ including scoped flags. Native execution stays available for supported regular
9
+ patterns, bytes patterns, ASCII classes and verbose mode. Unicode word/digit
10
+ classes and boundaries use native execution on ASCII subjects; non-ASCII input
11
+ uses the running interpreter's Unicode rules. Unicode whitespace and cased
12
+ Unicode IGNORECASE expressions use Python, because their native semantics differ
13
+ even on some ASCII input. This is version-independent: no Unicode table is
14
+ pinned to one Python release.
15
+
16
+ Patterns that can match empty text or repeat a nullable body use Python's
17
+ adjacent-match and capture rules. Backreferences, lookaround, unsupported parsed
18
+ operations and the LOCALE/DEBUG flags also fall back. A native operation that
19
+ cannot express a requested match shape uses its typed refusal to recover; a
20
+ lone-surrogate pattern, subject or replacement uses Python because UTF-8 cannot
21
+ encode it. Recovery occurs before a replacement callback starts; callback
22
+ exceptions propagate unchanged and callbacks are never retried.
23
+
24
+ **Only an operation that actually runs on irgx has the linear-time, ReDoS-immune
25
+ guarantee.** Every fallback uses Python's backtracking engine. Installing the
26
+ extra alone does not establish that guarantee, and native eligibility can depend
27
+ on the subject. Keep untrusted patterns inside the supported regular grammar
28
+ and use bytes or faithful ASCII expressions over encodable input when the
29
+ linear guarantee is required. ``(a+)+$`` retains its native safety win; Python
30
+ hangs on its failing tail, so the adverse test checks Python in a subprocess.
31
+
32
+ Compiled objects retain the caller's source and Python flag metadata, accept
33
+ region bounds and round-trip through the module verbs. Native match objects
34
+ supply ``group``/``groups``/``groupdict``/``span``/``expand``/``start``/``end``;
35
+ ``.pos`` and ``.endpos`` remain stdlib-only. Callable replacements receive the
36
+ active backend's match. ``findall`` projects an absent group as ``""`` for both
37
+ compiled and module calls; ``groups()`` still reports ``None``. Negative
38
+ replacement/split limits follow Python's unchanged-input rule.
39
+
40
+ ``error`` and ``escape`` are stdlib's own objects. Malformed/native-only syntax
41
+ raises Python's error, so ``except rex.error`` remains authoritative. A buffer
42
+ pattern is refused with ``TypeError`` as stdlib refuses it; compiled patterns
43
+ from either backend are accepted, but adding flags to one raises ``ValueError``.
44
+ Compiles, including declines, are cached at 512 entries, oldest out. Without
45
+ that front cache a repeated backreference would pay a native compile and refusal
46
+ every time. ``purge`` clears our cache and both engines' caches. UNICODE never
47
+ becomes inline ``(?U)``: that native spelling changes greediness.
48
+
49
+ Two inherited syntax differences remain deliberate on the native path:
50
+ ``$`` means absolute end rather than also the position before a final newline
51
+ (use ``(?m)…$`` for line ends or ``…\Z`` for absolute end), and POSIX bracket
52
+ expressions are real native classes rather than Python's nested-set spelling.
53
+ Keep patterns inside the shared grammar for Python parity. Native match
54
+ ``.re.flags`` also reflects native bookkeeping; the facade's ``.flags`` is
55
+ Python's bitmask.
56
+
57
+ Prior art: Thompson's finite automata, explained by Russ Cox at
58
+ https://swtch.com/~rsc/regexp/regexp1.html, and the engine we publish at
59
+ https://github.com/The-Billy-Company/irregex. The previous backend was RE2;
60
+ irregex supplied the existing re-shaped API without that extra dependency.
100
61
  """
101
62
 
102
63
  from __future__ import annotations
@@ -128,6 +89,8 @@ from re import ( # faithful re-export of the flag + type vocabulary
128
89
  )
129
90
  from typing import TYPE_CHECKING, Any, Final
130
91
 
92
+ from ._faithful import _LinearPattern, native_profile
93
+
131
94
 
132
95
  if TYPE_CHECKING:
133
96
  from collections.abc import Callable, Iterator, Sequence
@@ -139,7 +102,7 @@ if TYPE_CHECKING:
139
102
  # irgx's compiled/match types (they deliberately mirror `re.Pattern` /
140
103
  # `re.Match`) — named here so the public unions below stay honest without
141
104
  # pretending the irgx objects nominally *are* the stdlib classes.
142
- type _IrgxPattern = _irgx.Pattern
105
+ type _IrgxPattern = _irgx.Pattern | _LinearPattern[Any]
143
106
  type _IrgxMatch = _irgx.Match
144
107
  # What every module-level verb accepts as `pattern`: stdlib's own
145
108
  # `str | Pattern` union, widened by the compiled object this module returns.
@@ -210,18 +173,12 @@ _cache: dict[tuple[type, str | bytes, int], Pattern[Any] | _IrgxPattern] = {}
210
173
 
211
174
 
212
175
  def _irgx_pattern(pattern: str | bytes, flags: int) -> _IrgxPattern | None:
213
- """Compile `pattern` on irgx when it can faithfully express it; else ``None``.
214
-
215
- ``None`` (→ stdlib ``re`` fallback) when the backend is absent, `flags`
216
- carries a bit irgx can't honor, or irgx refuses the pattern — declining it
217
- as outside the linear grammar (backreferences / lookaround) or rejecting it
218
- as malformed. Both refusals route to stdlib, which re-raises the malformed
219
- case as ``re.error``.
220
- """
221
- if not _HAVE_IRGX or flags & ~_HONORED:
176
+ """Compile with Python validation and a parsed native fidelity profile."""
177
+ if not _HAVE_IRGX or type(pattern) not in (str, bytes) or flags & ~_HONORED:
222
178
  return None
179
+ python = _re.compile(pattern, flags)
223
180
  try:
224
- return _irgx.compile(
181
+ native = _irgx.compile(
225
182
  pattern,
226
183
  ignore_case=bool(flags & IGNORECASE),
227
184
  multiline=bool(flags & MULTILINE),
@@ -230,8 +187,14 @@ def _irgx_pattern(pattern: str | bytes, flags: int) -> _IrgxPattern | None:
230
187
  # A `bytes` pattern is byte-oriented by definition; a `str` one only under ASCII.
231
188
  unicode=isinstance(pattern, str) and not flags & ASCII,
232
189
  )
233
- except _irgx.error:
190
+ except (_irgx.error, UnicodeEncodeError):
234
191
  return None
192
+ profile = native_profile(pattern, flags)
193
+ return (
194
+ _LinearPattern(native, python, profile, _irgx.error)
195
+ if profile is not None
196
+ else None
197
+ )
235
198
 
236
199
 
237
200
  # The str/bytes type parameter below is spelled `AnyStr` (typeshed's name for this
@@ -287,7 +250,7 @@ def compile[AnyStr: (str, bytes)]( # shadows the builtin on purpose: `re.compil
287
250
  del _cache[next(iter(_cache))]
288
251
  _cache[key] = rx
289
252
  return rx
290
- if isinstance(pattern, _re.Pattern) or (
253
+ if isinstance(pattern, _re.Pattern | _LinearPattern) or (
291
254
  _HAVE_IRGX and isinstance(pattern, _irgx.Pattern)
292
255
  ):
293
256
  if flags:
@@ -331,7 +294,7 @@ def findall[AnyStr: (str, bytes)](
331
294
  spells the same fact ``list[Any]``; this keeps the element types.)
332
295
  """
333
296
  rx = compile(pattern, flags)
334
- if isinstance(rx, _re.Pattern):
297
+ if isinstance(rx, _re.Pattern | _LinearPattern):
335
298
  found: list[AnyStr | tuple[AnyStr, ...]] = rx.findall(string)
336
299
  return found # stdlib already projects an absent group as ``""``
337
300
  rows = rx.findall(string)