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}]