Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-linkify-it-py for 
openSUSE:Factory checked in at 2026-09-07 11:28:12
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-linkify-it-py (Old)
 and      /work/SRC/openSUSE:Factory/.python-linkify-it-py.new.1265 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-linkify-it-py"

Mon Sep  7 11:28:12 2026 rev:6 rq:1375229 version:2.2.0

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-linkify-it-py/python-linkify-it-py.changes    
    2026-03-17 19:04:22.820380272 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-linkify-it-py.new.1265/python-linkify-it-py.changes
      2026-09-07 11:29:21.212061156 +0200
@@ -1,0 +2,8 @@
+Tue Sep  1 20:58:25 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 2.2.0:
+  * Fix astral-plane Unicode handling in links, including host
+    names and link terminators
+  * Drop the runtime dependency on `uc-micro-py`
+
+-------------------------------------------------------------------

Old:
----
  python-linkify-it-py-2.1.0.tar.gz

New:
----
  python-linkify-it-py-2.2.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-linkify-it-py.spec ++++++
--- /var/tmp/diff_new_pack.VXEUS0/_old  2026-09-07 11:29:21.725079147 +0200
+++ /var/tmp/diff_new_pack.VXEUS0/_new  2026-09-07 11:29:21.729079287 +0200
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-linkify-it-py
-Version:        2.1.0
+Version:        2.2.0
 Release:        0
 Summary:        Links recognition library with FULL unicode support
 License:        MIT
@@ -26,11 +26,9 @@
 Source:         
https://github.com/tsutsu3/linkify-it-py/archive/refs/tags/v%{version}.tar.gz#/%{name}-%{version}.tar.gz
 BuildRequires:  %{python_module pip}
 BuildRequires:  %{python_module pytest}
-BuildRequires:  %{python_module uc-micro-py}
 BuildRequires:  %{python_module wheel}
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros
-Requires:       python-uc-micro-py
 BuildArch:      noarch
 %python_subpackages
 

++++++ python-linkify-it-py-2.1.0.tar.gz -> python-linkify-it-py-2.2.0.tar.gz 
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/.github/workflows/github-ci.yml 
new/linkify-it-py-2.2.0/.github/workflows/github-ci.yml
--- old/linkify-it-py-2.1.0/.github/workflows/github-ci.yml     2026-03-01 
08:43:05.000000000 +0100
+++ new/linkify-it-py-2.2.0/.github/workflows/github-ci.yml     2026-08-29 
09:04:26.000000000 +0200
@@ -14,9 +14,9 @@
   code-style:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - name: Set up Python
-        uses: actions/setup-python@v6
+        uses: actions/setup-python@v7
         with:
           python-version: 3.12
       - uses: pre-commit/[email protected]
@@ -28,9 +28,9 @@
         python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
     steps:
       - name: Checkout source
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v6
+        uses: actions/setup-python@v7
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install dependencies
@@ -42,7 +42,7 @@
           pytest --cov=linkify_it --cov-branch --cov-report=xml 
--cov-report=term-missing -v
       - name: Upload to Codecov
         if: matrix.python-version == 3.12 && github.repository == 
'tsutsu3/linkify-it-py'
-        uses: codecov/codecov-action@v5
+        uses: codecov/codecov-action@v7
         with:
           name: linkify-py-pytests-py3.12
           flags: pytests
@@ -52,9 +52,9 @@
   test-build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - name: Set up Python
-        uses: actions/setup-python@v6
+        uses: actions/setup-python@v7
         with:
           python-version: 3.12
       - name: Build package test
@@ -70,9 +70,9 @@
   benchmark:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v6
+      - uses: actions/checkout@v7
       - name: Set up Python
-        uses: actions/setup-python@v6
+        uses: actions/setup-python@v7
         with:
           python-version: 3.12
       - name: Install packagtes
@@ -101,9 +101,9 @@
       id-token: write
     steps:
       - name: Checkout source
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
       - name: Set up Python
-        uses: actions/setup-python@v6
+        uses: actions/setup-python@v7
         with:
           python-version: 3.12
       - name: Build package
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/.github/workflows/pypi-test.yml 
new/linkify-it-py-2.2.0/.github/workflows/pypi-test.yml
--- old/linkify-it-py-2.1.0/.github/workflows/pypi-test.yml     2026-03-01 
08:43:05.000000000 +0100
+++ new/linkify-it-py-2.2.0/.github/workflows/pypi-test.yml     2026-08-29 
09:04:26.000000000 +0200
@@ -21,9 +21,9 @@
       id-token: write
     steps:
       - name: Checkout source
-        uses: actions/checkout@v6
+        uses: actions/checkout@v7
       - name: Set up Python
-        uses: actions/setup-python@v6
+        uses: actions/setup-python@v7
         with:
           python-version: 3.12
       - name: Replace version
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/.gitignore 
new/linkify-it-py-2.2.0/.gitignore
--- old/linkify-it-py-2.1.0/.gitignore  2026-03-01 08:43:05.000000000 +0100
+++ new/linkify-it-py-2.2.0/.gitignore  2026-08-29 09:04:26.000000000 +0200
@@ -133,3 +133,5 @@
 .vscode/
 
 .benchmarks/
+
+uv.lock
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/CHANGELOG.md 
new/linkify-it-py-2.2.0/CHANGELOG.md
--- old/linkify-it-py-2.1.0/CHANGELOG.md        2026-03-01 08:43:05.000000000 
+0100
+++ new/linkify-it-py-2.2.0/CHANGELOG.md        2026-08-29 09:04:26.000000000 
+0200
@@ -1,5 +1,19 @@
 # Change Log
 
+## v2.2.0 (2026-08-29)
+
+- Fix astral-plane Unicode handling in links, including host names and link 
terminators ([#87](https://github.com/tsutsu3/linkify-it-py/pull/87))
+- Drop the runtime dependency on `uc-micro-py` 
([#87](https://github.com/tsutsu3/linkify-it-py/pull/87))
+
+## v2.1.1 (2026-08-25)
+
+Security release: `LinkifyIt.match()` was quadratic on untrusted input when 
linkify is enabled 
([GHSA-8m2q-wq3r-6hq8](https://github.com/tsutsu3/linkify-it-py/security/advisories/GHSA-8m2q-wq3r-6hq8)).
+
+- Fix quadratic complexity in `LinkifyIt.match()`, port of linkify-it 5.0.1 
and 5.0.2 (upstream 
[CVE-2026-48801](https://github.com/advisories/GHSA-22p9-wv53-3rq4), 
[CVE-2026-59887](https://github.com/advisories/GHSA-v245-v573-v5vm)) 
([#82](https://github.com/tsutsu3/linkify-it-py/pull/82))
+- Match validators at a position instead of slicing the tail, needed because 
Python 3.10 does not optimize the `^` anchor 
([#82](https://github.com/tsutsu3/linkify-it-py/pull/82))
+- Allow `;` in the email name, matching linkify-it. **Behavior change**: 
`a;[email protected]` is now linkified 
([#82](https://github.com/tsutsu3/linkify-it-py/pull/82))
+- Update port.yml (linkify-it v5.0.2) 
([#82](https://github.com/tsutsu3/linkify-it-py/pull/82))
+
 ## v2.1.0 (2026-03-01)
 
 - Drop support for EOL Python 3.7, 3.8 
([bd78752](https://github.com/tsutsu3/linkify-it-py/commit/bd78752))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/benchmark/bench_quadratic.py 
new/linkify-it-py-2.2.0/benchmark/bench_quadratic.py
--- old/linkify-it-py-2.1.0/benchmark/bench_quadratic.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/linkify-it-py-2.2.0/benchmark/bench_quadratic.py        2026-08-29 
09:04:26.000000000 +0200
@@ -0,0 +1,286 @@
+"""Reproduce the quadratic scan-loop behavior.
+
+``LinkifyIt.match()`` exhibits quadratic slowdown on text containing many fuzzy
+email candidates. The scan loop in ``linkify_it/main.py`` repeatedly slices the
+remaining tail and runs an unanchored ``re.search`` over it on each iteration.
+
+Two upstream JavaScript fixes were never ported to this package:
+
+* linkify-it 5.0.1 -- GHSA-22p9-wv53-3rq4 / CVE-2026-48801
+  "Quadratic algorithmic complexity in LinkifyIt#match scan loop"
+* linkify-it 5.0.2 -- GHSA-v245-v573-v5vm / CVE-2026-59887
+  "Quadratic-complexity DoS via the ``mailto:`` validator scan-loop"
+
+This benchmark covers both cases, along with controls that isolate linkify as
+the source of the slowdown. Run it before and after porting the upstream fixes:
+the ``ratio`` column should fall from roughly 4x per input doubling (quadratic)
+to roughly 2x (linear).
+
+Usage::
+
+    python benchmark/bench_quadratic.py
+    python benchmark/bench_quadratic.py --max-kb 64    # full advisory table
+    python benchmark/bench_quadratic.py --suite render
+"""
+
+import argparse
+import platform
+import time
+from collections.abc import Callable, Iterator
+
+import linkify_it
+from linkify_it import LinkifyIt
+
+try:
+    import markdown_it
+    from markdown_it import MarkdownIt
+except ImportError:  # pragma: no cover - markdown-it-py is an extra
+    markdown_it = None  # type: ignore[assignment]
+    MarkdownIt = None  # type: ignore[assignment, misc]
+
+# Signature shared by everything we time: LinkifyIt.match and
+# MarkdownIt.render both take the payload and return a value we discard.
+TimedCall = Callable[[str], object]
+
+KB = 1024
+
+# Timing the first call would fold in one-off ``re`` compilation, which is
+# large enough at 4 KB to hide the growth curve. Warm up on a small payload.
+WARMUP_KB = 1
+
+# "[email protected] " repeated. Fuzzy email candidates drive the match() scan loop.
+EMAIL_UNIT = "[email protected] "
+
+# "mailto:"; repeated. The ":" is a valid email-name char, so 
"mailto:mailto:...";
+# chains into O(n) schema hits, each running the validator to the end of the 
tail.
+MAILTO_UNIT = "mailto:";
+
+# Consumed by an inline rule first, so this payload should stay linear.
+HTTP_UNIT = "http://a.com "
+
+# Timings published in GHSA-8m2q-wq3r-6hq8, measured by the reporter through
+# MarkdownIt("gfm-like").render() against linkify-it-py 2.1.0.
+REPORTED_RENDER_SECONDS: dict[int, float] = {8: 1.07, 16: 3.90, 32: 15.5, 64: 
62.0}
+
+# Same advisory: the commonmark preset, with linkify off, on a 32 KB payload.
+REPORTED_CONTROL_SECONDS = 0.013
+
+
+def print_environment() -> None:
+    print(f"python        : {platform.python_version()} 
({platform.machine()})")
+    print(f"linkify-it-py : {linkify_it.__version__}")
+    print(f"              : {linkify_it.__file__}")
+    if markdown_it is not None:
+        print(f"markdown-it-py: {markdown_it.__version__}")
+    print()
+
+
+def repeat_to(unit: str, kb: int) -> str:
+    """Build a payload of about ``kb`` kibibytes by repeating ``unit``."""
+    return unit * (kb * KB // len(unit))
+
+
+def measure(fn: TimedCall, text: str) -> float:
+    """Return the wall-clock seconds ``fn(text)`` takes."""
+    start = time.perf_counter()
+    fn(text)
+    return time.perf_counter() - start
+
+
+def warm_up(fn: TimedCall, unit: str) -> None:
+    """Prime ``re`` caches so the first timed size is not an outlier."""
+    fn(repeat_to(unit, WARMUP_KB))
+
+
+def doubling_sizes(max_kb: int, start_kb: int = 4) -> Iterator[int]:
+    """Yield 4, 8, 16, ... kibibytes up to and including ``max_kb``."""
+    kb = start_kb
+    while kb <= max_kb:
+        yield kb
+        kb *= 2
+
+
+def eval(ratios: list[float]) -> str:
+    """Classify growth from the per-doubling time ratios."""
+    if not ratios:
+        return "not enough data points"
+
+    average = sum(ratios) / len(ratios)
+    if average >= 3.0:
+        return f"QUADRATIC: {average:.2f}x per doubling (quadratic is ~4x)"
+    if average <= 2.5:
+        return f"linear: {average:.2f}x per doubling (linear is ~2x)"
+    return f"inconclusive: {average:.2f}x per doubling"
+
+
+def run_scaling(
+    title: str,
+    unit: str,
+    fn: TimedCall,
+    max_kb: int,
+    reported: dict[int, float] | None = None,
+) -> None:
+    """Time ``fn`` over doubling payload sizes and print a scaling table."""
+    print(f"=== {title} ===")
+    print(f'payload: "{unit}" repeated')
+    print()
+
+    header = f"{'size':>7} {'n (bytes)':>11} {'time':>12} {'t/n^2':>14} 
{'ratio':>8}"
+    if reported:
+        header += f" {'reported':>11}"
+    print(header)
+
+    warm_up(fn, unit)
+
+    previous: float | None = None
+    ratios: list[float] = []
+    for kb in doubling_sizes(max_kb):
+        text = repeat_to(unit, kb)
+        elapsed = measure(fn, text)
+        n = len(text)
+
+        if previous is None:
+            ratio = "--"
+        else:
+            ratios.append(elapsed / previous)
+            ratio = f"{ratios[-1]:.2f}x"
+        previous = elapsed
+
+        row = (
+            f"{kb:>4} KB {n:>11} {elapsed:>10.3f} s "
+            f"{elapsed / n**2 * 1e9:>11.3f}e-9 {ratio:>8}"
+        )
+        if reported:
+            expected = reported.get(kb)
+            row += f" {expected:>9.2f} s" if expected else f" {'-':>11}"
+        print(row, flush=True)
+
+    print(f"-> {eval(ratios)}")
+    print()
+
+
+def require_markdown_it() -> bool:
+    """Return ``True`` if markdown-it-py is importable, else explain and 
skip."""
+    if MarkdownIt is not None:
+        return True
+    print("(skipped: markdown-it-py is not installed -- pip install 
markdown-it-py)")
+    print()
+    return False
+
+
+def suite_email(max_kb: int) -> None:
+    linkify = LinkifyIt()
+    run_scaling(
+        "fuzzy email scan loop, LinkifyIt.match() -- CVE-2026-48801",
+        EMAIL_UNIT,
+        linkify.match,
+        max_kb,
+    )
+
+
+def suite_mailto(max_kb: int) -> None:
+    linkify = LinkifyIt()
+    run_scaling(
+        "mailto: validator scan loop, LinkifyIt.match() -- CVE-2026-59887",
+        MAILTO_UNIT,
+        linkify.match,
+        max_kb,
+    )
+
+
+def suite_render(max_kb: int) -> None:
+    """Reproduce the advisory's own configuration, end to end."""
+    if not require_markdown_it():
+        return
+
+    md = MarkdownIt("gfm-like")
+    run_scaling(
+        "MarkdownIt('gfm-like').render(), linkify on -- advisory 
configuration",
+        EMAIL_UNIT,
+        md.render,
+        max_kb,
+        reported=REPORTED_RENDER_SECONDS,
+    )
+
+
+def suite_controls(max_kb: int) -> None:
+    """Show that linkify, not the gfm-like preset, is the trigger."""
+    if not require_markdown_it():
+        return
+
+    print("=== controls ===")
+    print()
+
+    text = repeat_to(EMAIL_UNIT, max_kb)
+
+    commonmark = MarkdownIt("commonmark")
+    warm_up(commonmark.render, EMAIL_UNIT)
+    baseline = measure(commonmark.render, text)
+    print(
+        f"{max_kb} KB email payload, commonmark (linkify off) : "
+        f"{baseline:>9.4f} s   (reported {REPORTED_CONTROL_SECONDS:.3f} s"
+        f" at 32 KB)"
+    )
+
+    gfm_off = MarkdownIt("gfm-like")
+    gfm_off.options["linkify"] = False
+    warm_up(gfm_off.render, EMAIL_UNIT)
+    print(
+        f"{max_kb} KB email payload, gfm-like, linkify=False  : "
+        f"{measure(gfm_off.render, text):>9.4f} s"
+    )
+
+    gfm_on = MarkdownIt("gfm-like")
+    warm_up(gfm_on.render, EMAIL_UNIT)
+    on = measure(gfm_on.render, text)
+    print(
+        f"{max_kb} KB email payload, gfm-like, linkify=True   : "
+        f"{on:>9.4f} s   ({on / baseline:.0f}x slower)"
+    )
+    print()
+
+    run_scaling(
+        "http:// payload -- consumed by an inline rule, expected to stay 
linear",
+        HTTP_UNIT,
+        gfm_on.render,
+        max_kb,
+    )
+
+
+SUITES: dict[str, Callable[[int], None]] = {
+    "email": suite_email,
+    "mailto": suite_mailto,
+    "render": suite_render,
+    "controls": suite_controls,
+}
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description=__doc__.splitlines()[0],
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    parser.add_argument(
+        "--max-kb",
+        type=int,
+        default=32,
+        help="largest payload size in KiB (default: 32; use 64 for the"
+        " full advisory table, which takes about a minute)",
+    )
+    parser.add_argument(
+        "--suite",
+        choices=["all", *SUITES],
+        default="all",
+        help="which measurement to run (default: all)",
+    )
+    args = parser.parse_args()
+
+    print_environment()
+
+    names = list(SUITES) if args.suite == "all" else [args.suite]
+    for name in names:
+        SUITES[name](args.max_kb)
+
+
+if __name__ == "__main__":
+    main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/linkify_it/__init__.py 
new/linkify-it-py-2.2.0/linkify_it/__init__.py
--- old/linkify-it-py-2.1.0/linkify_it/__init__.py      2026-03-01 
08:43:05.000000000 +0100
+++ new/linkify-it-py-2.2.0/linkify_it/__init__.py      2026-08-29 
09:04:26.000000000 +0200
@@ -1,4 +1,4 @@
 from .main import LinkifyIt  # noqa: F401p
 from .main import SchemaError  # noqa: F401p
 
-__version__ = "2.1.0"
+__version__ = "2.2.0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/linkify_it/_ucre_data.py 
new/linkify-it-py-2.2.0/linkify_it/_ucre_data.py
--- old/linkify-it-py-2.1.0/linkify_it/_ucre_data.py    1970-01-01 
01:00:00.000000000 +0100
+++ new/linkify-it-py-2.2.0/linkify_it/_ucre_data.py    2026-08-29 
09:04:26.000000000 +0200
@@ -0,0 +1,22 @@
+"""Unicode character classes for :mod:`linkify_it.ucre`.
+
+Generated by ``support/generate_ucre.py`` from Unicode 16.0.0.
+Do not edit by hand.
+"""
+
+UNIDATA_VERSION = "16.0.0"
+
+# Match any single character, including newlines.
+SRC_ANY = "[\\s\\S]"
+
+# General_Category=Cc
+SRC_CC = "[\x00-\x1f\x7f-\x9f]"  # noqa: E501
+
+# General_Category=Cf
+SRC_CF = 
"[\xad\u0600-\u0605\u061c\u06dd\u070f\u0890-\u0891\u08e2\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff\ufff9-\ufffb\U000110bd\U000110cd\U00013430-\U0001343f\U0001bca0-\U0001bca3\U0001d173-\U0001d17a\U000e0001\U000e0020-\U000e007f]"
  # noqa: E501
+
+# General_Category=P
+SRC_P = 
"[!-\\#%-\\*,-/:-;\\?-@\\[-\\]_\\{\\}\xa1\xa7\xab\xb6-\xb7\xbb\xbf\u037e\u0387\u055a-\u055f\u0589-\u058a\u05be\u05c0\u05c3\u05c6\u05f3-\u05f4\u0609-\u060a\u060c-\u060d\u061b\u061d-\u061f\u066a-\u066d\u06d4\u0700-\u070d\u07f7-\u07f9\u0830-\u083e\u085e\u0964-\u0965\u0970\u09fd\u0a76\u0af0\u0c77\u0c84\u0df4\u0e4f\u0e5a-\u0e5b\u0f04-\u0f12\u0f14\u0f3a-\u0f3d\u0f85\u0fd0-\u0fd4\u0fd9-\u0fda\u104a-\u104f\u10fb\u1360-\u1368\u1400\u166e\u169b-\u169c\u16eb-\u16ed\u1735-\u1736\u17d4-\u17d6\u17d8-\u17da\u1800-\u180a\u1944-\u1945\u1a1e-\u1a1f\u1aa0-\u1aa6\u1aa8-\u1aad\u1b4e-\u1b4f\u1b5a-\u1b60\u1b7d-\u1b7f\u1bfc-\u1bff\u1c3b-\u1c3f\u1c7e-\u1c7f\u1cc0-\u1cc7\u1cd3\u2010-\u2027\u2030-\u2043\u2045-\u2051\u2053-\u205e\u207d-\u207e\u208d-\u208e\u2308-\u230b\u2329-\u232a\u2768-\u2775\u27c5-\u27c6\u27e6-\u27ef\u2983-\u2998\u29d8-\u29db\u29fc-\u29fd\u2cf9-\u2cfc\u2cfe-\u2cff\u2d70\u2e00-\u2e2e\u2e30-\u2e4f\u2e52-\u2e5d\u3001-\u3003\u3008-\u3011\u3014-\u301f\u3030\u303d\u30a0\u30fb\ua4fe-\ua4ff\
 
ua60d-\ua60f\ua673\ua67e\ua6f2-\ua6f7\ua874-\ua877\ua8ce-\ua8cf\ua8f8-\ua8fa\ua8fc\ua92e-\ua92f\ua95f\ua9c1-\ua9cd\ua9de-\ua9df\uaa5c-\uaa5f\uaade-\uaadf\uaaf0-\uaaf1\uabeb\ufd3e-\ufd3f\ufe10-\ufe19\ufe30-\ufe52\ufe54-\ufe61\ufe63\ufe68\ufe6a-\ufe6b\uff01-\uff03\uff05-\uff0a\uff0c-\uff0f\uff1a-\uff1b\uff1f-\uff20\uff3b-\uff3d\uff3f\uff5b\uff5d\uff5f-\uff65\U00010100-\U00010102\U0001039f\U000103d0\U0001056f\U00010857\U0001091f\U0001093f\U00010a50-\U00010a58\U00010a7f\U00010af0-\U00010af6\U00010b39-\U00010b3f\U00010b99-\U00010b9c\U00010d6e\U00010ead\U00010f55-\U00010f59\U00010f86-\U00010f89\U00011047-\U0001104d\U000110bb-\U000110bc\U000110be-\U000110c1\U00011140-\U00011143\U00011174-\U00011175\U000111c5-\U000111c8\U000111cd\U000111db\U000111dd-\U000111df\U00011238-\U0001123d\U000112a9\U000113d4-\U000113d5\U000113d7-\U000113d8\U0001144b-\U0001144f\U0001145a-\U0001145b\U0001145d\U000114c6\U000115c1-\U000115d7\U00011641-\U00011643\U00011660-\U0001166c\U000116b9\U0001173c-\U0001173e\U0001
 
183b\U00011944-\U00011946\U000119e2\U00011a3f-\U00011a46\U00011a9a-\U00011a9c\U00011a9e-\U00011aa2\U00011b00-\U00011b09\U00011be1\U00011c41-\U00011c45\U00011c70-\U00011c71\U00011ef7-\U00011ef8\U00011f43-\U00011f4f\U00011fff\U00012470-\U00012474\U00012ff1-\U00012ff2\U00016a6e-\U00016a6f\U00016af5\U00016b37-\U00016b3b\U00016b44\U00016d6d-\U00016d6f\U00016e97-\U00016e9a\U00016fe2\U0001bc9f\U0001da87-\U0001da8b\U0001e5ff\U0001e95e-\U0001e95f]"
  # noqa: E501
+
+# General_Category=Z
+SRC_Z = "[\\ \xa0\u1680\u2000-\u200a\u2028-\u2029\u202f\u205f\u3000]"  # noqa: 
E501
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/linkify_it/main.py 
new/linkify-it-py-2.2.0/linkify_it/main.py
--- old/linkify-it-py-2.1.0/linkify_it/main.py  2026-03-01 08:43:05.000000000 
+0100
+++ new/linkify-it-py-2.2.0/linkify_it/main.py  2026-08-29 09:04:26.000000000 
+0200
@@ -21,6 +21,27 @@
     return result
 
 
+def _at(candidates, position):
+    """Item at ``position``, or ``None`` once the list is exhausted."""
+    return candidates[position] if position < len(candidates) else None
+
+
+def _choose(a, b):
+    """Return whichever candidate should be emitted first.
+
+    The earlier start wins; on equal starts the longer match wins. A full tie
+    keeps ``a``, so the order the candidates are folded in sets the priority.
+    ``None`` means the corresponding list has no candidate left.
+    """
+    if a is None:
+        return b
+    if b is None:
+        return a
+    if a.index != b.index:
+        return a if a.index < b.index else b
+    return a if a.last_index >= b.last_index else b
+
+
 class SchemaError(Exception):
     """Linkify schema error"""
 
@@ -41,8 +62,10 @@
         url (str): Normalized url of matched string.
 
     Args:
-        linkifyit (:class:`linkify_it.main.LinkifyIt`) LinkifyIt object
-        shift (int): text searh position
+        text (str): text the match was found in
+        schema (str): link schema, empty for fuzzy links
+        index (int): first position of matched string
+        last_index (int): next position after matched string
     """
 
     def __repr__(self):
@@ -50,17 +73,35 @@
             
f"{self.__class__.__module__}.{self.__class__.__name__}({self.__dict__!r})"
         )
 
-    def __init__(self, linkifyit, shift):
-        start = linkifyit._index
-        end = linkifyit._last_index
-        text = linkifyit._text_cache[start:end]
-
-        self.schema = linkifyit._schema.lower()
-        self.index = start + shift
-        self.last_index = end + shift
-        self.raw = text
-        self.text = text
-        self.url = text
+    def __init__(self, text, schema, index, last_index):
+        raw = text[index:last_index]
+
+        self.schema = schema.lower()
+        self.index = index
+        self.last_index = last_index
+        self.raw = raw
+        self.text = raw
+        self.url = raw
+
+
+class _Candidate:
+    """A single hit found by one of the scan passes of :meth:`LinkifyIt.match`.
+
+    Every pass collects all of its hits up front, so no pass ever rescans a 
tail
+    that an earlier one already walked.
+
+    Args:
+        schema (str): link schema, empty for fuzzy links
+        index (int): first position of matched string
+        last_index (int): next position after matched string
+    """
+
+    __slots__ = ("index", "last_index", "schema")
+
+    def __init__(self, schema, index, last_index):
+        self.schema = schema
+        self.index = index
+        self.last_index = last_index
 
 
 class LinkifyIt:
@@ -104,33 +145,38 @@
             Default: {"fuzzy_link": True, "fuzzy_email": True, "fuzzy_ip": 
False}.
     """
 
+    # The built-in validators match at `pos` with a compiled pattern rather 
than
+    # running an `^`-anchored search over `text[pos:]` the way upstream does.
+    # The slice is an O(n) copy, and Python 3.10 does not apply the `^` anchor
+    # optimization, so `re.search` rescans the whole tail -- together that is
+    # quadratic on inputs like `mailto:mailto:...`. Schemas registered through
+    # `add()` keep the sliced form: their patterns are documented to start
+    # with `^`, which cannot match at a non-zero position.
+
     def _validate_http(self, text, pos):
-        tail = text[pos:]
         if not self.re.get("http"):
             # compile lazily, because "host"-containing variables can change on
             # tlds update.
-            self.re["http"] = (
-                "^\\/\\/"
+            self.re["http"] = re.compile(
+                "\\/\\/"
                 + self.re["src_auth"]
                 + self.re["src_host_port_strict"]
-                + self.re["src_path"]
+                + self.re["src_path"],
+                flags=re.IGNORECASE,
             )
 
-        founds = re.search(self.re["http"], tail, flags=re.IGNORECASE)
+        founds = self.re["http"].match(text, pos)
         if founds:
             return len(founds.group())
 
         return 0
 
     def _validate_double_slash(self, text, pos):
-        tail = text[pos:]
-
         if not self.re.get("not_http"):
             # compile lazily, because "host"-containing variables can change on
             # tlds update.
-            self.re["not_http"] = (
-                "^"
-                + self.re["src_auth"]
+            self.re["not_http"] = re.compile(
+                self.re["src_auth"]
                 + "(?:localhost|(?:(?:"
                 + self.re["src_domain"]
                 + ")\\.)+"
@@ -138,10 +184,11 @@
                 + ")"
                 + self.re["src_port"]
                 + self.re["src_host_terminator"]
-                + self.re["src_path"]
+                + self.re["src_path"],
+                flags=re.IGNORECASE,
             )
 
-        founds = re.search(self.re["not_http"], tail, flags=re.IGNORECASE)
+        founds = self.re["not_http"].match(text, pos)
         if founds:
             if pos >= 3 and text[pos - 3] == ":":
                 return 0
@@ -154,23 +201,18 @@
         return 0
 
     def _validate_mailto(self, text, pos):
-        tail = text[pos:]
-
         if not self.re.get("mailto"):
-            self.re["mailto"] = (
-                "^" + self.re["src_email_name"] + "@" + 
self.re["src_host_strict"]
+            self.re["mailto"] = re.compile(
+                self.re["src_email_name"] + "@" + self.re["src_host_strict"],
+                flags=re.IGNORECASE,
             )
 
-        founds = re.search(self.re["mailto"], tail, flags=re.IGNORECASE)
+        founds = self.re["mailto"].match(text, pos)
         if founds:
             return len(founds.group(0))
 
         return 0
 
-    def _reset_scan_cache(self):
-        self._index = -1
-        self._text_cache = ""
-
     def _create_validator(self, regex):
         def func(text, pos):
             tail = text[pos:]
@@ -193,8 +235,8 @@
 
         return func
 
-    def _create_match(self, shift):
-        match = Match(self, shift)
+    def _create_match(self, text, schema, index, last_index):
+        match = Match(text, schema, index, last_index)
         self._compiled[match.schema]["normalize"](match)
         return match
 
@@ -227,12 +269,6 @@
         else:
             self._opts = self.default_options
 
-        # Cache last tested result. Used to skip repeating steps on next 
`match` call.
-        self._index = -1
-        self._last_index = -1  # Next scan position
-        self._schema = ""
-        self._text_cache = ""
-
         if schemas:
             self.default_schemas.update(schemas)
             self._schemas = self.default_schemas
@@ -367,10 +403,6 @@
             "(" + re_schema_test + ")|(" + self.re["host_fuzzy_test"] + ")|@"
         )
 
-        # Cleanup
-
-        self._reset_scan_cache()
-
     def add(self, schema, definition):
         """Add new rule definition. (chainable)
 
@@ -416,51 +448,27 @@
         Returns:
             bool: ``True`` if a linkable pattern was found, otherwise it is 
``False``.
         """
-        self._text_cache = text
-        self._index = -1
-
         if not len(text):
             return False
 
         if re.search(self.re["schema_test"], text, flags=re.IGNORECASE):
-            regex = self.re["schema_search"]
-            last_index = 0
-            matched_iter = re.finditer(regex, text[last_index:], 
flags=re.IGNORECASE)
+            matched_iter = re.finditer(
+                self.re["schema_search"], text, flags=re.IGNORECASE
+            )
             for matched in matched_iter:
-                last_index = matched.end(0)
-                m = (matched.group(), matched.groups()[0], matched.groups()[1])
-                length = self.test_schema_at(text, m[2], last_index)
-                if length:
-                    self._schema = m[2]
-                    self._index = matched.start(0) + len(m[1])
-                    self._last_index = matched.start(0) + len(m[0]) + length
-                    break
+                if self.test_schema_at(text, matched.group(2), matched.end(0)):
+                    return True
 
         if self._opts.get("fuzzy_link") and self._compiled.get("http:"):
             # guess schemaless links
-            matched_tld = re.search(
-                self.re["host_fuzzy_test"], text, flags=re.IGNORECASE
-            )
-            if matched_tld:
-                tld_pos = matched_tld.start(0)
-            else:
-                tld_pos = -1
-            if tld_pos >= 0:
-                # if tld is located after found link - no need to check fuzzy 
pattern
-                if self._index < 0 or tld_pos < self._index:
-                    if self._opts.get("fuzzy_ip"):
-                        pattern = self.re["link_fuzzy"]
-                    else:
-                        pattern = self.re["link_no_ip_fuzzy"]
-
-                    ml = re.search(pattern, text, flags=re.IGNORECASE)
-                    if ml:
-                        shift = ml.start(0) + len(ml.groups()[0])
-
-                        if self._index < 0 or shift < self._index:
-                            self._schema = ""
-                            self._index = shift
-                            self._last_index = ml.start(0) + len(ml.group())
+            if re.search(self.re["host_fuzzy_test"], text, 
flags=re.IGNORECASE):
+                if self._opts.get("fuzzy_ip"):
+                    pattern = self.re["link_fuzzy"]
+                else:
+                    pattern = self.re["link_no_ip_fuzzy"]
+
+                if re.search(pattern, text, flags=re.IGNORECASE):
+                    return True
 
         if self._opts.get("fuzzy_email") and self._compiled.get("mailto:";):
             # guess schemaless emails
@@ -468,21 +476,10 @@
             if at_pos >= 0:
                 # We can't skip this check, because this cases are possible:
                 # [email protected], [email protected]
-                me = re.search(self.re["email_fuzzy"], text, 
flags=re.IGNORECASE)
-                if me:
-                    shift = me.start(0) + len(me.groups()[0])
-                    next_shift = me.start(0) + len(me.group())
-
-                    if (
-                        self._index < 0
-                        or shift < self._index
-                        or (shift == self._index and next_shift > 
self._last_index)
-                    ):
-                        self._schema = "mailto:";
-                        self._index = shift
-                        self._last_index = next_shift
+                if re.search(self.re["email_fuzzy"], text, 
flags=re.IGNORECASE):
+                    return True
 
-        return self._index >= 0
+        return False
 
     def pretest(self, text):
         """Very quick check, that can give false positives.
@@ -537,23 +534,94 @@
                 * **text** - normalized text
                 * **url** - link, generated from matched text
         """
-        shift = 0
+        if not len(text):
+            return None
+
+        # Collect every hit of each pattern in one pass over the whole text.
+        schemed = []
+        fuzzy_link = []
+        fuzzy_email = []
+
+        # scan for links with schema
+        if re.search(self.re["schema_test"], text, flags=re.IGNORECASE):
+            matched_iter = re.finditer(
+                self.re["schema_search"], text, flags=re.IGNORECASE
+            )
+            for matched in matched_iter:
+                length = self.test_schema_at(text, matched.group(2), 
matched.end(0))
+                if length:
+                    schemed.append(
+                        _Candidate(
+                            matched.group(2),
+                            matched.start(0) + len(matched.group(1)),
+                            matched.start(0) + len(matched.group(0)) + length,
+                        )
+                    )
+
+        if self._opts.get("fuzzy_link") and self._compiled.get("http:"):
+            # guess schemaless links
+            if self._opts.get("fuzzy_ip"):
+                pattern = self.re["link_fuzzy"]
+            else:
+                pattern = self.re["link_no_ip_fuzzy"]
+
+            for matched in re.finditer(pattern, text, flags=re.IGNORECASE):
+                fuzzy_link.append(
+                    _Candidate(
+                        "",
+                        matched.start(0) + len(matched.group(1)),
+                        matched.start(0) + len(matched.group(0)),
+                    )
+                )
+
+        if self._opts.get("fuzzy_email") and self._compiled.get("mailto:";):
+            # guess schemaless emails
+            matched_iter = re.finditer(
+                self.re["email_fuzzy"], text, flags=re.IGNORECASE
+            )
+            for matched in matched_iter:
+                fuzzy_email.append(
+                    _Candidate(
+                        "mailto:";,
+                        matched.start(0) + len(matched.group(1)),
+                        matched.start(0) + len(matched.group(0)),
+                    )
+                )
+
+        # Merge the three streams, which are each already sorted by position,
+        # dropping candidates that overlap a match already emitted.
+        indexes = [0, 0, 0]
         result = []
+        last_index = 0
 
-        # try to take previous element from cache, if .test() called before
-        if self._index >= 0 and self._text_cache == text:
-            result.append(self._create_match(shift))
-            shift = self._last_index
-
-        # Cut head if cache was used
-        tail = text[shift:] if shift else text
-
-        # Scan string until end reached
-        while self.test(tail):
-            result.append(self._create_match(shift))
+        while True:
+            candidates = [
+                _at(schemed, indexes[0]),
+                _at(fuzzy_email, indexes[1]),
+                _at(fuzzy_link, indexes[2]),
+            ]
+
+            candidate = _choose(_choose(candidates[0], candidates[1]), 
candidates[2])
 
-            tail = tail[self._last_index :]
-            shift += self._last_index
+            if candidate is None:
+                break
+
+            if candidate is candidates[0]:
+                indexes[0] += 1
+            elif candidate is candidates[1]:
+                indexes[1] += 1
+            else:
+                indexes[2] += 1
+
+            if candidate.index < last_index:
+                continue
+
+            result.append(
+                self._create_match(
+                    text, candidate.schema, candidate.index, 
candidate.last_index
+                )
+            )
+            last_index = candidate.last_index
 
         if len(result):
             return result
@@ -570,10 +638,6 @@
         Retuns:
             ``Match`` or ``None``
         """
-        # Reset scan cache
-        self._text_cache = text
-        self._index = -1
-
         if not len(text):
             return None
 
@@ -581,16 +645,16 @@
         if not founds:
             return None
 
-        m = (founds.group(), founds.groups()[0], founds.groups()[1])
-        length = self.test_schema_at(text, m[2], len(m[0]))
+        length = self.test_schema_at(text, founds.group(2), 
len(founds.group(0)))
         if not length:
             return None
 
-        self._schema = m[2]
-        self._index = founds.start(0) + len(m[1])
-        self._last_index = founds.start(0) + len(m[0]) + length
-
-        return self._create_match(0)
+        return self._create_match(
+            text,
+            founds.group(2),
+            founds.start(0) + len(founds.group(1)),
+            founds.start(0) + len(founds.group(0)) + length,
+        )
 
     def tlds(self, list_tlds, keep_old=False):
         """Load (or merge) new tlds list. (chainable)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/linkify_it/ucre.py 
new/linkify-it-py-2.2.0/linkify_it/ucre.py
--- old/linkify-it-py-2.1.0/linkify_it/ucre.py  2026-03-01 08:43:05.000000000 
+0100
+++ new/linkify-it-py-2.2.0/linkify_it/ucre.py  2026-08-29 09:04:26.000000000 
+0200
@@ -1,11 +1,4 @@
-from uc_micro.categories import Cc, Cf, P, Z
-from uc_micro.properties import Any
-
-SRC_ANY = Any.REGEX
-SRC_CC = Cc.REGEX
-SRC_CF = Cf.REGEX
-SRC_P = P.REGEX
-SRC_Z = Z.REGEX
+from ._ucre_data import SRC_ANY, SRC_CC, SRC_CF, SRC_P, SRC_Z
 
 # \p{\Z\P\Cc\CF} (white spaces + control + format + punctuation)
 SRC_ZPCC = "|".join([SRC_Z, SRC_P, SRC_CC])
@@ -32,7 +25,9 @@
 )
 
 # Prohibit any of "@/[]()" in user/pass to avoid wrong domain fetch.
-SRC_AUTH = "(?:(?:(?!" + SRC_ZCC + "|[@/\\[\\]()]).)+@)?"
+# Length is capped to exclude possible rescans till the end and avoid O(n^2)
+# DoS. No standard limit, just take something reasonable.
+SRC_AUTH = "(?:(?:(?!" + SRC_ZCC + "|[@/\\[\\]()]).){1,50}@)?"
 
 SRC_PORT = (
     
"(?::(?:6(?:[0-4]\\d{3}|5(?:[0-4]\\d{2}|5(?:[0-2]\\d|3[0-5])))|[1-5]?\\d{1,4}))?"
@@ -40,7 +35,9 @@
 
 # Allow anything in markdown spec, forbid quote (") at the first position
 # because emails enclosed in quotes are far more common
-SRC_EMAIL_NAME = '[\\-:&=\\+\\$,\\.a-zA-Z0-9_][\\-:&=\\+\\$,\\"\\.a-zA-Z0-9_]*'
+# Max name length capped to 64 chars (RFC 5321). This also prevents O(n^2)
+# rescans to the end on inputs like `mailto:mailto:...`
+SRC_EMAIL_NAME = 
'[\\-;:&=\\+\\$,\\.a-zA-Z0-9_][\\-;:&=\\+\\$,\\"\\.a-zA-Z0-9_]{0,63}'
 
 SRC_XN = "xn--[a-z0-9\\-]{1,59}"
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/port.yml 
new/linkify-it-py-2.2.0/port.yml
--- old/linkify-it-py-2.1.0/port.yml    2026-03-01 08:43:05.000000000 +0100
+++ new/linkify-it-py-2.2.0/port.yml    2026-08-29 09:04:26.000000000 +0200
@@ -1,5 +1,5 @@
 - package: markdown-it/linkify-it
   url: https://github.com/markdown-it/linkify-it
-  version: 5.0.0
-  commit: 5e79093543092562b4348ea58395110357f3d296
-  date: Dec 2, 2023
+  version: 5.0.2
+  commit: 50a0c914f834b201cab25ff4faefd1f832b37332
+  date: Jul 3, 2026
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/pyproject.toml 
new/linkify-it-py-2.2.0/pyproject.toml
--- old/linkify-it-py-2.1.0/pyproject.toml      2026-03-01 08:43:05.000000000 
+0100
+++ new/linkify-it-py-2.2.0/pyproject.toml      2026-08-29 09:04:26.000000000 
+0200
@@ -10,7 +10,6 @@
 readme = "README.md"
 requires-python = ">=3.10"
 keywords = ["linkify", "linkifier", "autolink", "autolinker"]
-dependencies = ["uc-micro-py"]
 license = { text = "MIT" }
 classifiers = [
     "Development Status :: 5 - Production/Stable",
@@ -28,7 +27,7 @@
 dynamic = ["version"]
 
 [project.optional-dependencies]
-test = ["pytest", "coverage", "pytest-cov"]
+test = ["pytest", "coverage", "pytest-cov", "pytest-timeout"]
 dev = ["pre-commit", "isort", "flake8", "black", "pyproject-flake8"]
 benchmark = ["pytest", "pytest-benchmark"]
 doc = ["sphinx", "sphinx_book_theme", "myst-parser"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/support/generate_ucre.py 
new/linkify-it-py-2.2.0/support/generate_ucre.py
--- old/linkify-it-py-2.1.0/support/generate_ucre.py    1970-01-01 
01:00:00.000000000 +0100
+++ new/linkify-it-py-2.2.0/support/generate_ucre.py    2026-08-29 
09:04:26.000000000 +0200
@@ -0,0 +1,118 @@
+"""Generate Unicode character classes.
+
+Python versions use different Unicode versions, so generating these classes
+keeps linkify behavior consistent across supported Python versions.
+
+Run this script with the newest supported CPython::
+
+    python support/generate_ucre.py
+"""
+
+import re
+import sys
+import unicodedata
+from collections.abc import Iterator
+from pathlib import Path
+
+TARGET = Path(__file__).resolve().parent.parent / "linkify_it" / 
"_ucre_data.py"
+
+MAX_CODE_POINT = 0x110000
+
+# Generated name -> Unicode General_Category prefix.
+CATEGORIES = {
+    "SRC_CC": "Cc",
+    "SRC_CF": "Cf",
+    "SRC_P": "P",
+    "SRC_Z": "Z",
+}
+
+HEADER = '''"""Unicode character classes for :mod:`linkify_it.ucre`.
+
+Generated by ``support/generate_ucre.py`` from Unicode {version}.
+Do not edit by hand.
+"""
+
+UNIDATA_VERSION = "{version}"
+
+# Match any single character, including newlines.
+SRC_ANY = "[\\\\s\\\\S]"
+'''
+
+
+def ranges_for(prefix: str) -> Iterator[tuple[int, int]]:
+    """Yield (first, last) code point ranges whose category starts with 
prefix."""
+    start: int | None = None
+    previous = 0
+
+    for code_point in range(MAX_CODE_POINT):
+        if not unicodedata.category(chr(code_point)).startswith(prefix):
+            continue
+
+        if start is None:
+            start = previous = code_point
+        elif code_point == previous + 1:
+            previous = code_point
+        else:
+            yield start, previous
+            start = previous = code_point
+
+    if start is not None:
+        yield start, previous
+
+
+def escape(code_point: int) -> str:
+    """Escape a code point for use inside a character class.
+
+    `re.escape` is what makes this safe: the class has to survive characters
+    that are meaningful to the regexp engine, `]` and `\\` above all.
+    """
+    return re.escape(chr(code_point))
+
+
+def as_literal(text: str) -> str:
+    """Render text as an ASCII-only, double-quoted Python literal.
+
+    `ascii()` reaches for single quotes, which black would rewrite.
+    """
+    literal = ascii(text)
+    if literal.startswith('"'):
+        return literal
+
+    body = literal[1:-1].replace("\\'", "'").replace('"', '\\"')
+
+    return f'"{body}"'
+
+
+def char_class(prefix: str) -> str:
+    """Build a character class string covering one category prefix."""
+    parts = []
+    for first, last in ranges_for(prefix):
+        if first == last:
+            parts.append(escape(first))
+        else:
+            parts.append(f"{escape(first)}-{escape(last)}")
+
+    return "[" + "".join(parts) + "]"
+
+
+def main() -> int:
+    version = unicodedata.unidata_version
+    print(f"python {sys.version.split()[0]}, Unicode {version}")
+
+    chunks = [HEADER.format(version=version)]
+    for name, prefix in CATEGORIES.items():
+        source = char_class(prefix)
+        chunks.append(
+            f"\n# General_Category={prefix}\n"
+            f"{name} = {as_literal(source)}  # noqa: E501\n"
+        )
+        print(f"  {name:<8} ({prefix}): {len(source)} chars")
+
+    TARGET.write_text("".join(chunks), encoding="utf-8", newline="\n")
+    print(f"wrote {TARGET}")
+
+    return 0
+
+
+if __name__ == "__main__":
+    main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/test/fixtures/port_links.txt 
new/linkify-it-py-2.2.0/test/fixtures/port_links.txt
--- old/linkify-it-py-2.1.0/test/fixtures/port_links.txt        1970-01-01 
01:00:00.000000000 +0100
+++ new/linkify-it-py-2.2.0/test/fixtures/port_links.txt        2026-08-29 
09:04:26.000000000 +0200
@@ -0,0 +1,61 @@
+%
+% Cases that fail only in this port, not in upstream linkify-it.
+%
+
+%
+% `;` in the email name. Upstream's src_email_name has allowed it since 5.0.0,
+% but this port dropped it from both halves of the character class.
+%
+a;[email protected]
+
+foo;bar;[email protected]
+
+;[email protected]
+
+a;@example.com
+
+mailto:a;[email protected]
+
+%
+% Astral-plane characters in host names. uc-micro-py's classes are UTF-16
+% surrogate-pair regexes from JavaScript, so they never fire on Python str and
+% these hosts are not recognized. linkify-it handles them.
+%
+𠮷.com
+
+𠮷野家.com
+
+𝔞𝔟.com
+
+example@𠮷.com
+
+%
+% Punctuation terminates a host. These are BMP controls: they already pass and
+% agree with linkify-it, and they are what makes the astral expectation in the
+% next block the consistent one.
+%
+a、b.com
+b.com
+
+a·b.com
+b.com
+
+a።b.com
+b.com
+
+
+% ===========================================================================
+% Deliberate divergence from linkify-it
+% ===========================================================================
+%
+% Astral punctuation terminating a host. linkify-it returns the whole
+% "a𐩐b.com": there U+10A50 is two UTF-16 code units and only the first is
+% recognized as punctuation, so the second slips through as a letter. Python
+% has no such split, so we terminate the host exactly like the BMP controls
+% above. Expect these to show up as differences when diffing against upstream.
+%
+a𐩐b.com
+b.com
+
+a𑁍b.com
+b.com
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/test/test_apis.py 
new/linkify-it-py-2.2.0/test/test_apis.py
--- old/linkify-it-py-2.1.0/test/test_apis.py   2026-03-01 08:43:05.000000000 
+0100
+++ new/linkify-it-py-2.2.0/test/test_apis.py   2026-08-29 09:04:26.000000000 
+0200
@@ -23,8 +23,7 @@
 
 
 def test_match_class():
-    linkifyit = LinkifyIt()
-    match = Match(linkifyit, 0)
+    match = Match("", "", -1, -1)
     assert (
         match.__repr__()
         == "linkify_it.main.Match({'schema': '', 'index': -1, 'last_index': 
-1, 'raw': '', 'text': '', 'url': ''})"  # noqa: E501
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/test/test_pathological.py 
new/linkify-it-py-2.2.0/test/test_pathological.py
--- old/linkify-it-py-2.1.0/test/test_pathological.py   1970-01-01 
01:00:00.000000000 +0100
+++ new/linkify-it-py-2.2.0/test/test_pathological.py   2026-08-29 
09:04:26.000000000 +0200
@@ -0,0 +1,27 @@
+"""Inputs that used to make the scan loop degrade to O(n^2)."""
+
+import pytest
+
+from linkify_it import LinkifyIt
+
+TIMEOUT_SECONDS = 10
+
+
[email protected](TIMEOUT_SECONDS)
+def test_should_not_hang_on_fuzzy_links_followed_by_an_email_marker():
+    # ~1 MiB input
+    LinkifyIt().match("a.com " * 174762 + "@")
+
+
[email protected](TIMEOUT_SECONDS)
+def test_should_not_hang_on_fuzzy_emails_followed_by_a_link_marker():
+    # ~1 MiB input
+    LinkifyIt().match("[email protected] " * 131071 + ".com")
+
+
[email protected](TIMEOUT_SECONDS)
+def test_should_not_hang_on_repeated_mailto_schema_prefixes():
+    # ~680 KiB input. The ":" in mailto's prefix is also a valid email-name
+    # char, so "mailto:mailto:..."; chains into O(n) schema hits, each running
+    # the mailto validator to the end of the tail => O(n^2) without a fix.
+    LinkifyIt().match("mailto:"; * 100000)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/test/test_port_parity.py 
new/linkify-it-py-2.2.0/test/test_port_parity.py
--- old/linkify-it-py-2.1.0/test/test_port_parity.py    1970-01-01 
01:00:00.000000000 +0100
+++ new/linkify-it-py-2.2.0/test/test_port_parity.py    2026-08-29 
09:04:26.000000000 +0200
@@ -0,0 +1,34 @@
+"""Links that linkify-it recognizes but this port did not, due to port bugs.
+
+Kept apart from test_linkify.py so that upstream's own fixtures stay byte
+identical with linkify-it and remain diffable on the next port.
+"""
+
+from pathlib import Path
+
+import pytest
+
+from linkify_it import LinkifyIt
+
+from .utils import read_fixture_file
+
+FIXTURE_PATH = Path(__file__).parent / "fixtures"
+
+
+def dummy(_):
+    pass
+
+
[email protected](
+    "number,line,expected",
+    read_fixture_file(FIXTURE_PATH.joinpath("port_links.txt")),
+)
+def test_port_links(number, line, expected):
+    linkifyit = LinkifyIt(options={"fuzzy_ip": True})
+
+    linkifyit.normalize = dummy
+
+    assert linkifyit.pretest(line) is True
+    assert linkifyit.test("\n" + line + "\n") is True
+    assert linkifyit.test(line) is True
+    assert linkifyit.match(line)[0].url == expected
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/linkify-it-py-2.1.0/tox.ini 
new/linkify-it-py-2.2.0/tox.ini
--- old/linkify-it-py-2.1.0/tox.ini     2026-03-01 08:43:05.000000000 +0100
+++ new/linkify-it-py-2.2.0/tox.ini     2026-08-29 09:04:26.000000000 +0200
@@ -4,7 +4,6 @@
 [testenv]
 deps =
     pytest
-    uc-micro-py
 usedevelop = true
 
 [testenv:py{310-314}]

Reply via email to