Refactor and Update python-markdown-oembed-extension

- **Removed legacy build files**:
  - Deleted obsolete `.travis.yml`, `flake.lock`, and `flake.nix` files that were no longer needed for the current build and dependency management setup.

- **Updated versioning**:
  - Incremented the package version from `0.4.0` to `0.5.0` in `version.py` and made adjustments in `pyproject.toml` to reflect the new versioning mechanism.

- **Refined package structure**:
  - Moved source files from `src/python_markdown_oembed_extension` to `mdx_oembed` and renamed references accordingly for better clarity and organization of the codebase.

- **Enhanced OEmbed functionality**:
  - Added dedicated endpoint handling in the new `endpoints.py`.
  - Refactored the `oembed.py` file to implement a minimal oEmbed consumer, replacing the earlier dependency on `python-oembed`.

- **Improved test coverage**:
  - Transitioned tests from `unittest` to `pytest` framework for better maintainability.
  - Expanded unit tests, including better error handling and validation for various media types.

- **Updated dependency requirements**:
  - Raised minimum Python version from `3.9` to `3.12` in `pyproject.toml`.
  - Removed non-essential dependencies and restructured the dependency declarations to streamline package management.

These changes focus on modernizing the codebase, improving adherence to current Python standards, and enhancing overall functionality and maintainability.
This commit is contained in:
Benedikt Willi 2026-03-03 14:26:52 +01:00
parent 6864af69fb
commit 9e48edcfdf
19 changed files with 687 additions and 573 deletions

View file

@ -1,9 +0,0 @@
language: python
python:
- "2.7"
- "3.2"
- "3.3"
- "3.4"
- "3.5"
install: "pip install . nose mock"
script: nosetests

View file

@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1698611440,
"narHash": "sha256-jPjHjrerhYDy3q9+s5EAsuhyhuknNfowY6yt6pjn9pc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0cbe9f69c234a7700596e943bfae7ef27a31b735",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,29 +0,0 @@
{
description = "Oembed plugin flake";
inputs = {
nixpkgs.url = github:NixOS/nixpkgs/nixos-unstable;
};
outputs = { self, nixpkgs }:
let
pkgs = import nixpkgs {
inherit system;
overlays = [];
};
pythonPackages = pkgs.python3Packages;
system = "x86_64-linux";
in rec {
devShell.x86_64-linux = pkgs.mkShell {
buildInputs = [
pkgs.python3
pkgs.python3Packages.pip
];
shellHook = ''
export PS1='\u@md-oembed \$ '
export PIP_PREFIX=$(pwd)/venv/pip_packages
export PYTHONPATH="$PIP_PREFIX/${pkgs.python3.sitePackages}:$PYTHONPATH"
export PATH="$PIP_PREFIX/bin:$PATH"
unset SOURCE_DATE_EPOCH
'';
};
};
}

View file

@ -1,8 +1,12 @@
from __future__ import annotations
from mdx_oembed.extension import OEmbedExtension
from mdx_oembed.version import __version__
VERSION = __version__
__all__ = ["OEmbedExtension", "VERSION", "__version__", "makeExtension"]
def makeExtension(**kwargs):
def makeExtension(**kwargs: object) -> OEmbedExtension: # noqa: N802
return OEmbedExtension(**kwargs)

34
mdx_oembed/endpoints.py Normal file
View file

@ -0,0 +1,34 @@
from __future__ import annotations
from mdx_oembed.oembed import OEmbedEndpoint
# URL patterns use shell-style globs with an "https?://" shorthand
# that matches both http and https schemes.
YOUTUBE = OEmbedEndpoint('https://www.youtube.com/oembed', [
'https?://*.youtube.com/*',
'https?://youtu.be/*',
])
SLIDESHARE = OEmbedEndpoint('https://www.slideshare.net/api/oembed/2', [
'https?://www.slideshare.net/*/*',
'https?://fr.slideshare.net/*/*',
'https?://de.slideshare.net/*/*',
'https?://es.slideshare.net/*/*',
'https?://pt.slideshare.net/*/*',
])
FLICKR = OEmbedEndpoint('https://www.flickr.com/services/oembed/', [
'https?://*.flickr.com/*',
])
VIMEO = OEmbedEndpoint('https://vimeo.com/api/oembed.json', [
'https?://vimeo.com/*',
])
DEFAULT_ENDPOINTS = [
YOUTUBE,
FLICKR,
VIMEO,
SLIDESHARE,
]

View file

@ -1,7 +1,10 @@
from __future__ import annotations
from markdown import Extension
import oembed
from python_markdown_oembed_extension.endpoints import DEFAULT_ENDPOINTS
from python_markdown_oembed_extension.inlinepatterns import OEmbedLinkPattern, OEMBED_LINK_RE
from mdx_oembed.endpoints import DEFAULT_ENDPOINTS
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, OEmbedLinkPattern
from mdx_oembed.oembed import OEmbedConsumer
class OEmbedExtension(Extension):
@ -21,7 +24,7 @@ class OEmbedExtension(Extension):
}
super().__init__(**kwargs)
def extendMarkdown(self, md):
def extendMarkdown(self, md): # noqa: N802
consumer = self._prepare_oembed_consumer()
wrapper_class = self.getConfig('wrapper_class', 'oembed')
link_pattern = OEmbedLinkPattern(
@ -32,8 +35,7 @@ class OEmbedExtension(Extension):
def _prepare_oembed_consumer(self):
allowed_endpoints = self.getConfig('allowed_endpoints', DEFAULT_ENDPOINTS)
consumer = oembed.OEmbedConsumer()
consumer = OEmbedConsumer()
for endpoint in (allowed_endpoints or []):
consumer.addEndpoint(endpoint)
consumer.add_endpoint(endpoint)
return consumer

View file

@ -1,12 +1,17 @@
import logging
from posixpath import splitext
from urllib.parse import urlparse
from __future__ import annotations
import nh3
import oembed
from markdown.inlinepatterns import InlineProcessor
import html as _html
import logging
from os.path import splitext
from urllib.parse import urlparse
from xml.etree.ElementTree import Element
import markdown
import nh3
from markdown.inlinepatterns import InlineProcessor
from mdx_oembed.oembed import OEmbedConsumer, OEmbedNoEndpoint
LOG = logging.getLogger(__name__)
# Image extensions to exclude from oEmbed processing
@ -19,12 +24,23 @@ _IMAGE_EXTENSIONS = frozenset({
OEMBED_LINK_RE = r"!\[([^\]]*)\]\(((?:https?:)?//[^\)]+)\)"
# Allowed HTML tags and attributes for sanitizing oEmbed responses
_SANITIZE_TAGS = {"iframe", "video", "audio", "source", "img", "blockquote", "div", "p", "a", "span", "figure"}
_SANITIZE_TAGS = {
"iframe", "video", "audio", "source", "img",
"blockquote", "div", "p", "a", "span", "figure",
}
_SANITIZE_ATTRS = {
"*": {"class", "style", "title"},
"iframe": {"src", "width", "height", "frameborder", "allowfullscreen", "allow", "referrerpolicy", "sandbox"},
"video": {"src", "width", "height", "controls", "autoplay", "loop", "muted", "poster", "preload"},
"audio": {"src", "controls", "autoplay", "loop", "muted", "preload"},
"iframe": {
"src", "width", "height", "frameborder",
"allowfullscreen", "allow", "referrerpolicy", "sandbox",
},
"video": {
"src", "width", "height", "controls",
"autoplay", "loop", "muted", "poster", "preload",
},
"audio": {
"src", "controls", "autoplay", "loop", "muted", "preload",
},
"source": {"src", "type"},
"img": {"src", "alt", "width", "height", "loading"},
"a": {"href", "target"},
@ -49,12 +65,18 @@ def _sanitize_html(html: str) -> str:
class OEmbedLinkPattern(InlineProcessor):
"""Inline processor that replaces Markdown image links with oEmbed content."""
def __init__(self, pattern, md=None, oembed_consumer=None, wrapper_class="oembed"):
def __init__(
self,
pattern: str,
md: markdown.Markdown | None = None,
oembed_consumer: OEmbedConsumer | None = None,
wrapper_class: str = "oembed",
) -> None:
super().__init__(pattern, md)
self.consumer = oembed_consumer
self.wrapper_class = wrapper_class
def handleMatch(self, m, data):
def handleMatch(self, m, data): # noqa: N802
url = m.group(2).strip()
alt = m.group(1)
@ -80,9 +102,12 @@ class OEmbedLinkPattern(InlineProcessor):
def _get_oembed_html(self, url: str, alt: str = "") -> str | None:
"""Fetch oEmbed HTML for a URL, handling different response types."""
if self.consumer is None:
LOG.warning("No oEmbed consumer configured")
return None
try:
response = self.consumer.embed(url)
except oembed.OEmbedNoEndpoint:
except OEmbedNoEndpoint:
LOG.warning("No oEmbed endpoint for URL: %s", url)
return None
except Exception:
@ -99,10 +124,11 @@ class OEmbedLinkPattern(InlineProcessor):
if photo_url:
width = response.get("width", "")
height = response.get("height", "")
escaped_alt = alt.replace('"', """)
return (
f'<img src="{photo_url}" alt="{escaped_alt}"'
f' width="{width}" height="{height}" />'
f'<img src="{_html.escape(str(photo_url), quote=True)}"'
f' alt="{_html.escape(alt, quote=True)}"'
f' width="{_html.escape(str(width), quote=True)}"'
f' height="{_html.escape(str(height), quote=True)}" />'
)
LOG.warning("oEmbed response for %s has no 'html' or 'url' field", url)

181
mdx_oembed/oembed.py Normal file
View file

@ -0,0 +1,181 @@
"""Minimal oEmbed consumer — replaces the python-oembed dependency.
Implements just the subset used by this extension:
- OEmbedEndpoint: pairs an API URL with URL-glob patterns
- OEmbedConsumer: resolves a URL against registered endpoints and
fetches the oEmbed JSON response
- OEmbedError / OEmbedNoEndpoint: exception hierarchy
"""
from __future__ import annotations
import fnmatch
import json
import logging
import re
import warnings
from typing import Any
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from mdx_oembed.version import __version__
__all__ = [
"OEmbedEndpoint",
"OEmbedConsumer",
"OEmbedError",
"OEmbedNoEndpoint",
"REQUEST_TIMEOUT",
]
LOG = logging.getLogger(__name__)
# Default timeout (seconds) for outbound oEmbed HTTP requests.
REQUEST_TIMEOUT = 10
_USER_AGENT = f"python-markdown-oembed/{__version__}"
# Pre-compiled regex for the ``https?://`` scheme shorthand used in oEmbed
# URL patterns. Kept at module level to avoid re-creation on every call.
_SCHEME_RE = re.compile(r"https\?://")
_SCHEME_PLACEHOLDER = "__SCHEME__"
# -- Exceptions -------------------------------------------------------------
class OEmbedError(Exception):
"""Base exception for oEmbed errors."""
class OEmbedNoEndpoint(OEmbedError): # noqa: N818
"""Raised when no registered endpoint matches the requested URL."""
# -- Endpoint ---------------------------------------------------------------
class OEmbedEndpoint:
"""An oEmbed provider endpoint.
Parameters
----------
api_url:
The provider's oEmbed API URL (e.g. ``https://www.youtube.com/oembed``).
url_patterns:
Shell-style glob patterns (with ``https?://`` shorthand) that describe
which content URLs this endpoint handles. The ``?`` in ``https?``
is treated specially: it makes the preceding ``s`` optional so a single
pattern can match both ``http`` and ``https``.
"""
def __init__(self, api_url: str, url_patterns: list[str]) -> None:
self.api_url = api_url
self.url_patterns = url_patterns
self._regexes: list[re.Pattern[str]] = [
self._compile(p) for p in url_patterns
]
def __repr__(self) -> str:
return f"OEmbedEndpoint({self.api_url!r}, {self.url_patterns!r})"
# -- internal helpers ----------------------------------------------------
@staticmethod
def _compile(pattern: str) -> re.Pattern[str]:
"""Convert a URL-glob pattern to a compiled regex.
Handles the ``https?://`` convention used by oEmbed providers:
the ``s`` before ``?`` is made optional *before* the rest of the
pattern is translated via `fnmatch`.
"""
converted = _SCHEME_RE.sub(_SCHEME_PLACEHOLDER, pattern)
# fnmatch.translate anchors with \\A … \\Z and handles */?/[] globs.
regex = fnmatch.translate(converted)
# Put the scheme alternation back.
regex = regex.replace(_SCHEME_PLACEHOLDER, r"https?://")
return re.compile(regex, re.IGNORECASE)
def matches(self, url: str) -> bool:
"""Return True if *url* matches any of this endpoint's patterns."""
return any(r.match(url) for r in self._regexes)
# -- Consumer ---------------------------------------------------------------
class OEmbedConsumer:
"""Registry of `OEmbedEndpoint` objects that can resolve arbitrary URLs.
Parameters
----------
timeout:
HTTP request timeout in seconds. Defaults to :data:`REQUEST_TIMEOUT`.
"""
def __init__(self, timeout: int = REQUEST_TIMEOUT) -> None:
self._endpoints: list[OEmbedEndpoint] = []
self.timeout = timeout
def __repr__(self) -> str:
names = [ep.api_url for ep in self._endpoints]
return f"OEmbedConsumer(endpoints={names!r})"
def add_endpoint(self, endpoint: OEmbedEndpoint) -> None:
"""Register an oEmbed endpoint."""
self._endpoints.append(endpoint)
def addEndpoint(self, endpoint: OEmbedEndpoint) -> None: # noqa: N802
"""Deprecated alias for :meth:`add_endpoint`."""
warnings.warn(
"addEndpoint() is deprecated, use add_endpoint() instead",
DeprecationWarning,
stacklevel=2,
)
self.add_endpoint(endpoint)
def embed(self, url: str) -> dict[str, Any]:
"""Fetch the oEmbed response for *url*.
Returns the parsed JSON as a ``dict``.
Raises
------
OEmbedNoEndpoint
If none of the registered endpoints match *url*.
OEmbedError
On HTTP or JSON-parsing failures.
"""
endpoint = self._find_endpoint(url)
if endpoint is None:
raise OEmbedNoEndpoint(f"No oEmbed endpoint registered for {url}")
return self._fetch(endpoint, url)
# -- internal helpers ----------------------------------------------------
def _find_endpoint(self, url: str) -> OEmbedEndpoint | None:
for ep in self._endpoints:
if ep.matches(url):
return ep
return None
def _fetch(self, endpoint: OEmbedEndpoint, content_url: str) -> dict[str, Any]:
params = urlencode({"url": content_url, "format": "json"})
api_url = f"{endpoint.api_url}?{params}"
request = Request(api_url, headers={ # noqa: S310
"Accept": "application/json",
"User-Agent": _USER_AGENT,
})
LOG.debug("Fetching oEmbed: %s", api_url)
try:
with urlopen(request, timeout=self.timeout) as resp: # noqa: S310
if resp.status is not None and not (200 <= resp.status < 300):
raise OEmbedError(
f"oEmbed request for {content_url} returned HTTP {resp.status}"
)
charset = resp.headers.get_content_charset() or "utf-8"
data: dict[str, Any] = json.loads(resp.read().decode(charset))
except OEmbedError:
raise
except Exception as exc:
raise OEmbedError(
f"Failed to fetch oEmbed for {content_url}: {exc}"
) from exc
return data

0
mdx_oembed/py.typed Normal file
View file

View file

@ -1 +1 @@
__version__ = "0.4.0"
__version__ = "0.5.0"

View file

@ -1,10 +1,10 @@
[project]
name = "python-markdown-oembed-extension"
version = "0.4.0"
dynamic = ["version"]
description = "Markdown extension to allow media embedding using the oEmbed standard."
readme = {file = "README.md", content-type = "text/markdown"}
license = "Unlicense"
requires-python = ">=3.9"
requires-python = ">=3.12"
authors = [
{ name = "Benedikt Willi", email = "ben.willi@gmail.com" },
{ name = "Tanner Netterville", email = "tannern@gmail.com" },
@ -15,14 +15,11 @@ classifiers = [
"License :: Public Domain",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"python-oembed>=0.2.1",
"Markdown>=3.2",
"nh3>=0.2",
]
@ -37,6 +34,9 @@ oembed = "mdx_oembed:makeExtension"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "mdx_oembed/version.py"
[tool.hatch.build.targets.sdist]
include = [
"mdx_oembed/",
@ -52,8 +52,26 @@ packages = ["mdx_oembed"]
dev = [
"pytest>=7.0",
"pytest-mock>=3.0",
"ruff>=0.4",
"pyright>=1.1",
]
[tool.pytest.ini_options]
testpaths = ["."]
python_files = ["tests.py"]
[tool.ruff]
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "N", "S", "B"]
ignore = [
"S101", # assert used — standard in pytest
]
[tool.ruff.lint.per-file-ignores]
"tests.py" = ["S106"]
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "standard"

View file

@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
from python_markdown_oembed_extension.oembedextension import OEmbedExtension
VERSION = '0.2.2'
def makeExtension(**kwargs):
return OEmbedExtension(**kwargs)

View file

@ -1,31 +0,0 @@
import oembed
# URL patterns use python-oembed's glob-like syntax, not standard regex.
YOUTUBE = oembed.OEmbedEndpoint('https://www.youtube.com/oembed', [
'https?://(*.)?youtube.com/*',
'https?://youtu.be/*',
])
SLIDESHARE = oembed.OEmbedEndpoint('https://www.slideshare.net/api/oembed/2', [
'https?://www.slideshare.net/*/*',
'https?://fr.slideshare.net/*/*',
'https?://de.slideshare.net/*/*',
'https?://es.slideshare.net/*/*',
'https?://pt.slideshare.net/*/*',
])
FLICKR = oembed.OEmbedEndpoint('https://www.flickr.com/services/oembed/', [
'https?://*.flickr.com/*',
])
VIMEO = oembed.OEmbedEndpoint('https://vimeo.com/api/oembed.json', [
'https?://vimeo.com/*',
])
DEFAULT_ENDPOINTS = [
YOUTUBE,
FLICKR,
VIMEO,
SLIDESHARE,
]

View file

@ -1,9 +0,0 @@
<p>In this video Jakob Zinsstag introduces the topic of the course. You will
discover that the relationship between humans and animals is manifold.
{.lead}</p>
<p><figure class="oembed ratio ratio-16x9"><iframe src="https://player.vimeo.com/video/734276368?h=f29c542352&amp;app_id=122963" width="426" height="240" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" title="One-Health_Tales_EN_1-02"></iframe></figure> </p>
<p>Have a look at the farm of Jakob Zinsstags cousin in the Canton of Jura,
Switzerland. Different animals create different feelings: there are those we
love, some provoke fears and others will be eaten. Jakob Zinsstag shares the
personal experiences he has had with animals. </p>
<p><strong>How do you categorise your own experience with animals?</strong></p>

View file

@ -1,12 +0,0 @@
In this video Jakob Zinsstag introduces the topic of the course. You will
discover that the relationship between humans and animals is manifold.
{.lead}
![embed](https://vimeo.com/734276368/f29c542352)
Have a look at the farm of Jakob Zinsstags cousin in the Canton of Jura,
Switzerland. Different animals create different feelings: there are those we
love, some provoke fears and others will be eaten. Jakob Zinsstag shares the
personal experiences he has had with animals.
**How do you categorise your own experience with animals?**

View file

@ -1,21 +0,0 @@
import markdown, yaml, requests_mock
from python_markdown_oembed_extension.oembedextension import OEmbedExtension
from python_markdown_oembed_extension.endpoints import VIMEO
def test_full():
with ( requests_mock.Mocker() as m
, open('./src/python_markdown_oembed_extension/tests/vimeoMock.yaml', 'r') as vm
, open('./src/python_markdown_oembed_extension/tests/test_markdown.md', 'r') as md
, open('./src/python_markdown_oembed_extension/tests/test_expectedHtml.html', 'r') as expectedHtml
):
yml = yaml.safe_load(vm)
m.get(yml['request'], json=yml['response'])
mdString = md.read()
htmlString = markdown.markdown(mdString, extensions=[OEmbedExtension()])
print(htmlString)
assert htmlString == expectedHtml.read().rstrip()

View file

@ -1,30 +0,0 @@
---
request: 'https://vimeo.com/734276368/f29c542352'
response:
account_type: 'live_premium'
author_name: 'NMC Universität Basel'
author_url: 'https://vimeo.com/newmediacenterunibasel'
description: ''
duration: 282
height: 240
html: >-
<iframe
src="https://player.vimeo.com/video/734276368?h=f29c542352&amp;app_id=122963"
width="426" height="240" frameborder="0" allow="autoplay; fullscreen; picture-in-picture"
title="One-Health_Tales_EN_1-02"
>
</iframe>
is_plus: '0'
provider_name: 'Vimeo'
provider_url: 'https://vimeo.com/'
thumbnail_height: 166
thumbnail_url: 'https://i.vimeocdn.com/video/1480489232-5ca2d723cadc09ae077c8b437581e84bd0485049780c60e218986fda60881110-d_295x166'
thumbnail_url_with_play_button: 'https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F1480489232-5ca2d723cadc09ae077c8b437581e84bd0485049780c60e218986fda60881110-d_295x166&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png'
thumbnail_width: 295
title: 'One-Health_Tales_EN_1-02'
type: 'video'
upload_date: '2022-07-28 04:16:03'
uri: '/videos/734276368:f29c542352'
version: '1.0'
video_id: 734276368
width: 426

547
tests.py
View file

@ -1,118 +1,220 @@
"""Tests for python-markdown-oembed extension."""
from __future__ import annotations
import json
import re
import unittest
import warnings
from unittest.mock import MagicMock, patch
import markdown
import pytest
from mdx_oembed import endpoints
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, _is_image_url, _sanitize_html
from mdx_oembed.oembed import (
OEmbedConsumer,
OEmbedEndpoint,
OEmbedError,
OEmbedNoEndpoint,
)
# ---------------------------------------------------------------------------
# Regex tests
# ---------------------------------------------------------------------------
class TestOEmbedRegex(unittest.TestCase):
"""Tests for the raw OEMBED_LINK_RE pattern."""
_OEMBED_RE = re.compile(OEMBED_LINK_RE)
def setUp(self):
self.re = re.compile(OEMBED_LINK_RE)
# --- should NOT match (relative URLs) ---
def test_ignore_relative_image_link():
assert _OEMBED_RE.search("![image](/image.png)") is None
def test_ignore_relative_image_link(self):
assert self.re.search("![image](/image.png)") is None
# --- should match (absolute URLs — image filtering is in Python now) ---
def test_match_absolute_url():
m = _OEMBED_RE.search("![img](http://example.com/photo.png)")
assert m is not None
def test_match_absolute_url(self):
m = self.re.search("![img](http://example.com/photo.png)")
assert m is not None
def test_match_youtube_link(self):
m = self.re.search("![video](http://www.youtube.com/watch?v=ABC)")
assert m is not None
assert m.group(2) == "http://www.youtube.com/watch?v=ABC"
def test_match_youtube_link():
m = _OEMBED_RE.search("![video](http://www.youtube.com/watch?v=ABC)")
assert m is not None
assert m.group(2) == "http://www.youtube.com/watch?v=ABC"
def test_match_youtube_short_link(self):
m = self.re.search("![video](http://youtu.be/ABC)")
assert m is not None
def test_match_https(self):
m = self.re.search("![video](https://youtu.be/ABC)")
assert m is not None
def test_match_youtube_short_link():
m = _OEMBED_RE.search("![video](http://youtu.be/ABC)")
assert m is not None
def test_match_protocol_relative(self):
m = self.re.search("![video](//youtu.be/ABC)")
assert m is not None
def test_alt_text_captured(self):
m = self.re.search("![my alt text](https://example.com/embed)")
assert m is not None
assert m.group(1) == "my alt text"
def test_match_https():
m = _OEMBED_RE.search("![video](https://youtu.be/ABC)")
assert m is not None
def test_match_protocol_relative():
m = _OEMBED_RE.search("![video](//youtu.be/ABC)")
assert m is not None
def test_alt_text_captured():
m = _OEMBED_RE.search("![my alt text](https://example.com/embed)")
assert m is not None
assert m.group(1) == "my alt text"
# ---------------------------------------------------------------------------
# Image URL detection
# ---------------------------------------------------------------------------
class TestIsImageUrl(unittest.TestCase):
def test_common_extensions(self):
for ext in ("png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "tiff", "ico"):
assert _is_image_url(f"http://example.com/photo.{ext}") is True, ext
@pytest.mark.parametrize(
"ext",
["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "tiff", "ico"],
)
def test_common_image_extensions(ext: str):
assert _is_image_url(f"http://example.com/photo.{ext}") is True
def test_case_insensitive(self):
assert _is_image_url("http://example.com/Photo.PNG") is True
assert _is_image_url("http://example.com/photo.JpEg") is True
def test_query_string_ignored(self):
assert _is_image_url("http://example.com/photo.jpg?size=large") is True
def test_image_url_case_insensitive():
assert _is_image_url("http://example.com/Photo.PNG") is True
assert _is_image_url("http://example.com/photo.JpEg") is True
def test_non_image(self):
assert _is_image_url("http://www.youtube.com/watch?v=ABC") is False
def test_no_extension(self):
assert _is_image_url("http://example.com/embed") is False
def test_image_url_query_string_ignored():
assert _is_image_url("http://example.com/photo.jpg?size=large") is True
def test_non_image_url():
assert _is_image_url("http://www.youtube.com/watch?v=ABC") is False
def test_no_extension_url():
assert _is_image_url("http://example.com/embed") is False
# ---------------------------------------------------------------------------
# HTML sanitization
# ---------------------------------------------------------------------------
class TestSanitizeHtml(unittest.TestCase):
def test_allows_iframe(self):
html = '<iframe src="https://youtube.com/embed/x" width="560" height="315" allowfullscreen></iframe>'
result = _sanitize_html(html)
assert "<iframe" in result
assert 'src="https://youtube.com/embed/x"' in result
def test_sanitize_allows_iframe():
html = (
'<iframe src="https://youtube.com/embed/x"'
' width="560" height="315" allowfullscreen></iframe>'
)
result = _sanitize_html(html)
assert "<iframe" in result
assert 'src="https://youtube.com/embed/x"' in result
def test_strips_script(self):
html = '<script>alert("xss")</script><iframe src="https://safe.com"></iframe>'
result = _sanitize_html(html)
assert "<script" not in result
assert "<iframe" in result
def test_strips_onerror(self):
html = '<img src="x" onerror="alert(1)" />'
result = _sanitize_html(html)
assert "onerror" not in result
def test_sanitize_strips_script():
html = '<script>alert("xss")</script><iframe src="https://safe.com"></iframe>'
result = _sanitize_html(html)
assert "<script" not in result
assert "<iframe" in result
def test_sanitize_strips_onerror():
html = '<img src="x" onerror="alert(1)" />'
result = _sanitize_html(html)
assert "onerror" not in result
# ---------------------------------------------------------------------------
# OEmbedConsumer / OEmbedEndpoint unit tests
# ---------------------------------------------------------------------------
def test_endpoint_matches_http_and_https():
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
assert ep.matches("http://example.com/video/1")
assert ep.matches("https://example.com/video/1")
assert not ep.matches("http://other.com/video/1")
def test_consumer_add_endpoint():
consumer = OEmbedConsumer()
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
consumer.add_endpoint(ep)
assert ep in consumer._endpoints # noqa: SLF001
def test_consumer_add_endpoint_deprecated_alias():
consumer = OEmbedConsumer()
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
consumer.addEndpoint(ep)
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "addEndpoint" in str(w[0].message)
assert ep in consumer._endpoints # noqa: SLF001
def test_consumer_embed_no_endpoint():
consumer = OEmbedConsumer()
with pytest.raises(OEmbedNoEndpoint):
consumer.embed("http://unknown.example.com/video")
def test_consumer_http_status_error():
"""Non-2xx HTTP responses should raise OEmbedError."""
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
consumer = OEmbedConsumer()
consumer.add_endpoint(ep)
mock_resp = MagicMock()
mock_resp.status = 404
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("mdx_oembed.oembed.urlopen", return_value=mock_resp):
with pytest.raises(OEmbedError, match="HTTP 404"):
consumer.embed("http://example.com/video/1")
def test_consumer_successful_fetch():
"""Successful 200 response should return parsed JSON."""
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
consumer = OEmbedConsumer()
consumer.add_endpoint(ep)
body = json.dumps({"html": "<iframe></iframe>", "type": "video"}).encode()
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = body
mock_resp.headers.get_content_charset.return_value = "utf-8"
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("mdx_oembed.oembed.urlopen", return_value=mock_resp):
data = consumer.embed("http://example.com/video/1")
assert data["html"] == "<iframe></iframe>"
# ---------------------------------------------------------------------------
# Extension integration tests (mocked HTTP)
# ---------------------------------------------------------------------------
def _make_mock_consumer(html_response="<iframe src='https://embed.example.com'></iframe>"):
def _make_mock_consumer(
html_response: str = "<iframe src='https://embed.example.com'></iframe>",
) -> MagicMock:
"""Create a mock OEmbedConsumer that returns the given HTML."""
consumer = MagicMock()
data = {"html": html_response, "type": "video"}
response = MagicMock()
response.get = lambda key, default=None: {"html": html_response, "type": "video"}.get(key, default)
response.__getitem__ = lambda self_inner, key: {"html": html_response, "type": "video"}[key]
response.get = lambda key, default=None: data.get(key, default)
response.__getitem__ = lambda self_inner, key: data[key]
consumer.embed.return_value = response
return consumer
def _make_photo_consumer(photo_url="https://example.com/photo.jpg", width=640, height=480):
def _make_photo_consumer(
photo_url: str = "https://example.com/photo.jpg",
width: int = 640,
height: int = 480,
) -> MagicMock:
consumer = MagicMock()
data = {"type": "photo", "url": photo_url, "width": width, "height": height}
response = MagicMock()
@ -122,157 +224,192 @@ def _make_photo_consumer(photo_url="https://example.com/photo.jpg", width=640, h
return consumer
def _make_failing_consumer(exc_class=Exception, msg="fail"):
def _make_failing_consumer(
exc_class: type[Exception] = Exception, msg: str = "fail"
) -> MagicMock:
consumer = MagicMock()
consumer.embed.side_effect = exc_class(msg)
return consumer
class TestOEmbedExtension(unittest.TestCase):
"""Integration tests with mocked oEmbed consumer."""
def _convert(
text: str,
consumer: MagicMock | None = None,
**ext_config: object,
) -> str:
"""Helper: convert markdown with a mocked consumer."""
if consumer is None:
consumer = _make_mock_consumer()
def _convert(self, text, consumer=None, **ext_config):
"""Helper: convert markdown with a mocked consumer."""
if consumer is None:
consumer = _make_mock_consumer()
with patch("mdx_oembed.extension.oembed.OEmbedConsumer", return_value=consumer):
md = markdown.Markdown(
extensions=["oembed"],
extension_configs={"oembed": ext_config} if ext_config else {},
)
return md.convert(text)
# --- basic embedding ---
def test_youtube_embed(self):
output = self._convert("![video](http://www.youtube.com/watch?v=ABC)")
assert "<iframe" in output
assert "oembed" in output # wrapper class
def test_vimeo_embed(self):
output = self._convert("![vid](https://vimeo.com/12345)")
assert "<iframe" in output
# --- images pass through ---
def test_image_png_passthrough(self):
output = self._convert("![alt](http://example.com/img.png)")
assert "<img" in output
def test_image_jpg_passthrough(self):
output = self._convert("![alt](http://example.com/img.jpg)")
assert "<img" in output
def test_image_with_query_passthrough(self):
output = self._convert("![alt](http://example.com/img.jpg?v=1)")
assert "<img" in output
def test_image_uppercase_passthrough(self):
output = self._convert("![alt](http://example.com/img.PNG)")
assert "<img" in output
# --- relative images are unaffected ---
def test_relative_image(self):
output = self._convert("![alt](image.png)")
assert '<img alt="alt" src="image.png"' in output
def test_slash_relative_image(self):
output = self._convert("![alt](/image.png)")
assert '<img alt="alt" src="/image.png"' in output
# --- photo type response ---
def test_photo_type_response(self):
consumer = _make_photo_consumer()
output = self._convert("![photo](https://flickr.com/photos/1234)", consumer)
assert "<img" in output
assert "https://example.com/photo.jpg" in output
# --- error handling ---
def test_no_endpoint_falls_through(self):
import oembed as _oembed
consumer = _make_failing_consumer(_oembed.OEmbedNoEndpoint)
output = self._convert("![video](http://unknown.example.com/abc)", consumer)
assert "<iframe" not in output
def test_network_error_falls_through(self):
consumer = _make_failing_consumer(Exception, "timeout")
output = self._convert("![video](http://www.youtube.com/watch?v=ABC)", consumer)
assert "<iframe" not in output
# --- configuration ---
def test_custom_wrapper_class(self):
output = self._convert(
"![v](http://www.youtube.com/watch?v=ABC)",
wrapper_class="embed-responsive",
with patch("mdx_oembed.extension.OEmbedConsumer", return_value=consumer):
md = markdown.Markdown(
extensions=["oembed"],
extension_configs={"oembed": ext_config} if ext_config else {},
)
assert "embed-responsive" in output
return md.convert(text)
def test_empty_wrapper_class(self):
output = self._convert(
"![v](http://www.youtube.com/watch?v=ABC)",
wrapper_class="",
# --- basic embedding ---
def test_youtube_embed():
output = _convert("![video](http://www.youtube.com/watch?v=ABC)")
assert "<iframe" in output
assert "oembed" in output # wrapper class
def test_vimeo_embed():
output = _convert("![vid](https://vimeo.com/12345)")
assert "<iframe" in output
# --- images pass through ---
def test_image_png_passthrough():
output = _convert("![alt](http://example.com/img.png)")
assert "<img" in output
def test_image_jpg_passthrough():
output = _convert("![alt](http://example.com/img.jpg)")
assert "<img" in output
def test_image_with_query_passthrough():
output = _convert("![alt](http://example.com/img.jpg?v=1)")
assert "<img" in output
def test_image_uppercase_passthrough():
output = _convert("![alt](http://example.com/img.PNG)")
assert "<img" in output
# --- relative images are unaffected ---
def test_relative_image():
output = _convert("![alt](image.png)")
assert '<img alt="alt" src="image.png"' in output
def test_slash_relative_image():
output = _convert("![alt](/image.png)")
assert '<img alt="alt" src="/image.png"' in output
# --- photo type response ---
def test_photo_type_response():
consumer = _make_photo_consumer()
output = _convert("![photo](https://flickr.com/photos/1234)", consumer)
assert "<img" in output
assert "https://example.com/photo.jpg" in output
def test_photo_type_escapes_html():
"""Photo URLs with special chars are properly escaped."""
consumer = _make_photo_consumer(
photo_url='https://example.com/photo.jpg?a=1&b=2"'
)
output = _convert(
"![alt text](https://flickr.com/photos/1234)", consumer
)
# The & in the photo URL must be escaped as &amp; in the src attribute
assert "&amp;" in output
# The " in the photo URL must be escaped (nh3 may use &quot; or &#34;)
assert 'b=2"' not in output
# --- error handling ---
def test_no_endpoint_falls_through():
consumer = _make_failing_consumer(OEmbedNoEndpoint)
output = _convert("![video](http://unknown.example.com/abc)", consumer)
assert "<iframe" not in output
def test_network_error_falls_through():
consumer = _make_failing_consumer(Exception, "timeout")
output = _convert("![video](http://www.youtube.com/watch?v=ABC)", consumer)
assert "<iframe" not in output
# --- configuration ---
def test_custom_wrapper_class():
output = _convert(
"![v](http://www.youtube.com/watch?v=ABC)",
wrapper_class="embed-responsive",
)
assert "embed-responsive" in output
def test_empty_wrapper_class():
output = _convert(
"![v](http://www.youtube.com/watch?v=ABC)",
wrapper_class="",
)
assert "<figure" not in output
assert "<iframe" in output
# --- XSS protection ---
def test_script_stripped_from_response():
evil_consumer = _make_mock_consumer(
'<script>alert("xss")</script><iframe src="https://ok.com"></iframe>'
)
output = _convert("![v](http://www.youtube.com/watch?v=ABC)", evil_consumer)
assert "<script" not in output
assert "<iframe" in output
# --- multiple links ---
def test_multiple_embeds():
text = (
"![a](http://www.youtube.com/watch?v=A)\n\n"
"![b](http://www.youtube.com/watch?v=B)"
)
output = _convert(text)
assert output.count("<iframe") == 2
# ---------------------------------------------------------------------------
# Limited endpoints configuration
# ---------------------------------------------------------------------------
def test_youtube_only_endpoint():
def side_effect(url: str) -> MagicMock:
if "youtube" in url:
resp = MagicMock()
data = {"html": "<iframe src='yt'></iframe>", "type": "video"}
resp.get = lambda key, default=None: data.get(key, default)
resp.__getitem__ = lambda self_inner, key: data[key]
return resp
raise OEmbedNoEndpoint("nope")
consumer = MagicMock()
consumer.embed.side_effect = side_effect
with patch("mdx_oembed.extension.OEmbedConsumer", return_value=consumer):
md = markdown.Markdown(
extensions=["oembed"],
extension_configs={
"oembed": {"allowed_endpoints": [endpoints.YOUTUBE]},
},
)
assert "<figure" not in output
assert "<iframe" in output
yt_output = md.convert("![v](http://www.youtube.com/watch?v=A)")
assert "<iframe" in yt_output
# --- XSS protection ---
def test_script_stripped_from_response(self):
evil_consumer = _make_mock_consumer(
'<script>alert("xss")</script><iframe src="https://ok.com"></iframe>'
)
output = self._convert("![v](http://www.youtube.com/watch?v=ABC)", evil_consumer)
assert "<script" not in output
assert "<iframe" in output
# --- multiple links ---
def test_multiple_embeds(self):
text = (
"![a](http://www.youtube.com/watch?v=A)\n\n"
"![b](http://www.youtube.com/watch?v=B)"
)
output = self._convert(text)
assert output.count("<iframe") == 2
class TestLimitedEndpoints(unittest.TestCase):
"""Test allowed_endpoints configuration."""
def test_youtube_only(self):
import oembed as _oembed
def side_effect(url):
if "youtube" in url:
resp = MagicMock()
data = {"html": "<iframe src='yt'></iframe>", "type": "video"}
resp.get = lambda key, default=None: data.get(key, default)
resp.__getitem__ = lambda self_inner, key: data[key]
return resp
raise _oembed.OEmbedNoEndpoint("nope")
consumer = MagicMock()
consumer.embed.side_effect = side_effect
with patch("mdx_oembed.extension.oembed.OEmbedConsumer", return_value=consumer):
md = markdown.Markdown(
extensions=["oembed"],
extension_configs={
"oembed": {"allowed_endpoints": [endpoints.YOUTUBE]},
},
)
yt_output = md.convert("![v](http://www.youtube.com/watch?v=A)")
assert "<iframe" in yt_output
md.reset()
vim_output = md.convert("![v](http://vimeo.com/12345)")
assert "<iframe" not in vim_output
if __name__ == "__main__":
unittest.main()
md.reset()
vim_output = md.convert("![v](http://vimeo.com/12345)")
assert "<iframe" not in vim_output

209
uv.lock
View file

@ -1,10 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.10'",
"python_full_version < '3.10'",
]
requires-python = ">=3.12"
[[package]]
name = "colorama"
@ -15,76 +11,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown"
version = "3.9"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" },
]
[[package]]
name = "markdown"
version = "3.10.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
@ -124,6 +63,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/31/f1/b4835dbde4fb06f29db89db027576d6014081cd278d9b6751facc3e69e43/nh3-0.3.3-cp38-abi3-win_arm64.whl", hash = "sha256:b838e619f483531483d26d889438e53a880510e832d2aafe73f93b7b1ac2bce2", size = 616645, upload-time = "2026-02-14T09:35:14.062Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -152,41 +100,28 @@ wheels = [
]
[[package]]
name = "pytest"
version = "8.4.2"
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "packaging", marker = "python_full_version < '3.10'" },
{ name = "pluggy", marker = "python_full_version < '3.10'" },
{ name = "pygments", marker = "python_full_version < '3.10'" },
{ name = "tomli", marker = "python_full_version < '3.10'" },
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "packaging", marker = "python_full_version >= '3.10'" },
{ name = "pluggy", marker = "python_full_version >= '3.10'" },
{ name = "pygments", marker = "python_full_version >= '3.10'" },
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
@ -198,8 +133,7 @@ name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
@ -207,94 +141,58 @@ wheels = [
]
[[package]]
name = "python-markdown-oembed"
version = "0.3.0"
name = "python-markdown-oembed-extension"
source = { editable = "." }
dependencies = [
{ name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "markdown" },
{ name = "nh3" },
{ name = "python-oembed" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-mock" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "markdown", specifier = ">=3.2" },
{ name = "nh3", specifier = ">=0.2" },
{ name = "python-oembed", specifier = ">=0.2.1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pyright", specifier = ">=1.1" },
{ name = "pytest", specifier = ">=7.0" },
{ name = "pytest-mock", specifier = ">=3.0" },
{ name = "ruff", specifier = ">=0.4" },
]
[[package]]
name = "python-oembed"
version = "0.2.4"
name = "ruff"
version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/e2/8effcd118f659af206ff74b64b4ab2145b3d399969199254aeacdf2c424c/python-oembed-0.2.4.tar.gz", hash = "sha256:e0804ea3bd7ab8ec1460d139b7a92b6a9e4e3cddd83012dfe30cc67e314716ea", size = 7981, upload-time = "2016-01-02T00:38:26.229Z" }
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" },
{ url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" },
{ url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" },
]
[[package]]
@ -305,12 +203,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]