Refactor and modernize codebase.

This commit is contained in:
Benedikt Willi 2026-03-02 17:25:43 +01:00
parent 79c7c2f060
commit 20f2880e07
13 changed files with 833 additions and 295 deletions

View file

@ -1 +0,0 @@
include README.markdown LICENSE

View file

@ -1,41 +0,0 @@
# Python Markdown oEmbed
[![Build Status](https://travis-ci.org/rennat/python-markdown-oembed.svg?branch=master)](https://travis-ci.org/rennat/python-markdown-oembed)
Markdown extension to allow media embedding using the oEmbed standard.
## Installation
pip install python-markdown-oembed
## Usage
>>> import markdown
>>> md = markdown.Markdown(extensions=['oembed'])
>>> md.convert('![video](http://www.youtube.com/watch?v=zqnh_YJBvOI)')
u'<iframe width="459" height="344" src="http://www.youtube.com/embed/zqnh_YJBvOI?fs=1&feature=oembed" frameborder="0" allowfullscreen></iframe>'
## Links
- [python-markdown-oembed](https://github.com/rennat/python-markdown-oembed)
- [Markdown](http://daringfireball.net/projects/markdown/)
- [oEmbed](http://www.oembed.com/)
- [python-oembed](https://github.com/abarmat/python-oembed)
## License
A Public Domain work. Do as you wish.
## Changelog
### 0.2.1
- add Slideshare endpoint (thanks to [anantshri](https://github.com/anantshri))
### 0.2.0
- backwards incompatible changes
- allows arbitrary endpoints ([commit](https://github.com/Wenzil/python-markdown-oembed/commit/1e89de9db5e63677e071c36503e2499bbe0792da))
- works with modern Markdown (>=2.6)
- dropped support for python 2.6
- added support python 3.x

98
README.md Normal file
View file

@ -0,0 +1,98 @@
# Python Markdown oEmbed
Markdown extension to allow media embedding using the oEmbed standard.
## Requirements
- Python >= 3.9
- Markdown >= 3.2
## Installation
pip install python-markdown-oembed
Or with [uv](https://docs.astral.sh/uv/):
uv add python-markdown-oembed
## Usage
```python
import markdown
md = markdown.Markdown(extensions=['oembed'])
md.convert('![video](http://www.youtube.com/watch?v=zqnh_YJBvOI)')
```
Output is wrapped in a `<figure class="oembed">` element by default:
```html
<figure class="oembed"><iframe width="459" height="344" ...></iframe></figure>
```
### Configuration
| Option | Default | Description |
|--------|---------|-------------|
| `allowed_endpoints` | YouTube, Flickr, Vimeo, Slideshare | List of `oembed.OEmbedEndpoint` objects |
| `wrapper_class` | `"oembed"` | CSS class(es) for the `<figure>` wrapper. Set to `""` to disable wrapping |
Example with custom configuration:
```python
from mdx_oembed.endpoints import YOUTUBE, VIMEO
md = markdown.Markdown(
extensions=['oembed'],
extension_configs={
'oembed': {
'allowed_endpoints': [YOUTUBE, VIMEO],
'wrapper_class': 'embed-responsive',
}
}
)
```
## Security
oEmbed HTML responses are sanitized using [nh3](https://github.com/messense/nh3)
to prevent XSS from compromised oEmbed providers. Only safe tags (`iframe`,
`video`, `audio`, `img`, etc.) and attributes are allowed.
## Links
- [python-markdown-oembed](https://github.com/rennat/python-markdown-oembed)
- [Markdown](http://daringfireball.net/projects/markdown/)
- [oEmbed](http://www.oembed.com/)
- [python-oembed](https://github.com/abarmat/python-oembed)
## License
A Public Domain work. Do as you wish.
## Changelog
### 0.3.0
- **Breaking:** requires Python >= 3.9 and Markdown >= 3.2
- Migrated from deprecated `Pattern` to `InlineProcessor` (Markdown 3.2+ compatible)
- Added HTML sanitization of oEmbed responses (XSS protection via nh3)
- Added support for oEmbed `photo` type responses
- Improved image URL detection (case-insensitive, handles query strings)
- All oEmbed API endpoints now use HTTPS
- Slideshare URL patterns now accept both HTTP and HTTPS
- Configurable `<figure>` wrapper class (previously hardcoded Bootstrap classes)
- Migrated to `pyproject.toml` with hatchling build backend
- Tests modernized: uses pytest + unittest.mock, all HTTP calls mocked
- Centralized version management in `mdx_oembed/version.py`
### 0.2.1
- add Slideshare endpoint (thanks to [anantshri](https://github.com/anantshri))
### 0.2.0
- backwards incompatible changes
- allows arbitrary endpoints ([commit](https://github.com/Wenzil/python-markdown-oembed/commit/1e89de9db5e63677e071c36503e2499bbe0792da))
- works with modern Markdown (>=2.6)
- dropped support for python 2.6
- added support python 3.x

View file

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
from mdx_oembed.extension import OEmbedExtension from mdx_oembed.extension import OEmbedExtension
from mdx_oembed.version import __version__
VERSION = __version__
VERSION = '0.2.1'
def makeExtension(**kwargs): def makeExtension(**kwargs):

View file

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

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
from markdown import Extension from markdown import Extension
import oembed import oembed
from mdx_oembed.endpoints import DEFAULT_ENDPOINTS from mdx_oembed.endpoints import DEFAULT_ENDPOINTS
@ -11,24 +10,29 @@ class OEmbedExtension(Extension):
self.config = { self.config = {
'allowed_endpoints': [ 'allowed_endpoints': [
DEFAULT_ENDPOINTS, DEFAULT_ENDPOINTS,
"A list of oEmbed endpoints to allow. Defaults to " "A list of oEmbed endpoints to allow. "
"endpoints.DEFAULT_ENDPOINTS" "Defaults to endpoints.DEFAULT_ENDPOINTS",
],
'wrapper_class': [
'oembed',
"CSS class(es) for the <figure> wrapper element. "
"Set to empty string to disable wrapping.",
], ],
} }
super(OEmbedExtension, self).__init__(**kwargs) super().__init__(**kwargs)
def extendMarkdown(self, md): def extendMarkdown(self, md):
self.oembed_consumer = self.prepare_oembed_consumer() consumer = self._prepare_oembed_consumer()
link_pattern = OEmbedLinkPattern(OEMBED_LINK_RE, md, wrapper_class = self.getConfig('wrapper_class', 'oembed')
self.oembed_consumer) link_pattern = OEmbedLinkPattern(
OEMBED_LINK_RE, md, consumer, wrapper_class=wrapper_class,
)
# Priority 175 — run before the default image pattern (priority 150)
md.inlinePatterns.register(link_pattern, 'oembed_link', 175) md.inlinePatterns.register(link_pattern, 'oembed_link', 175)
def prepare_oembed_consumer(self): def _prepare_oembed_consumer(self):
allowed_endpoints = self.getConfig('allowed_endpoints', DEFAULT_ENDPOINTS) allowed_endpoints = self.getConfig('allowed_endpoints', DEFAULT_ENDPOINTS)
consumer = oembed.OEmbedConsumer() consumer = oembed.OEmbedConsumer()
for endpoint in (allowed_endpoints or []):
if allowed_endpoints: consumer.addEndpoint(endpoint)
for endpoint in allowed_endpoints:
consumer.addEndpoint(endpoint)
return consumer return consumer

View file

@ -1,40 +1,109 @@
# -*- coding: utf-8 -*-
import logging import logging
from markdown.inlinepatterns import Pattern from posixpath import splitext
import oembed from urllib.parse import urlparse
import nh3
import oembed
from markdown.inlinepatterns import InlineProcessor
from xml.etree.ElementTree import Element
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
# Image extensions to exclude from oEmbed processing
_IMAGE_EXTENSIONS = frozenset({
".png", ".jpg", ".jpeg", ".gif", ".avif", ".webp",
".svg", ".bmp", ".tiff", ".ico",
})
OEMBED_LINK_RE = r'\!\[([^\]]*)\]\(((?:https?:)?//[^\)]*)' \ # Matches Markdown image syntax with an absolute URL: ![alt](https://...)
r'(?<!png)(?<!jpg)(?<!jpeg)(?<!gif)(?<!avif)(?<!webp)\)' 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_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"},
"source": {"src", "type"},
"img": {"src", "alt", "width", "height", "loading"},
"a": {"href", "target"},
}
class OEmbedLinkPattern(Pattern): def _is_image_url(url: str) -> bool:
"""Check if a URL points to an image based on its path extension."""
try:
path = urlparse(url).path
_, ext = splitext(path)
return ext.lower() in _IMAGE_EXTENSIONS
except Exception:
return False
def __init__(self, pattern, md=None, oembed_consumer=None):
Pattern.__init__(self, pattern, md=md) def _sanitize_html(html: str) -> str:
"""Sanitize oEmbed HTML to prevent XSS."""
return nh3.clean(html, tags=_SANITIZE_TAGS, attributes=_SANITIZE_ATTRS)
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"):
super().__init__(pattern, md)
self.consumer = oembed_consumer self.consumer = oembed_consumer
self.wrapper_class = wrapper_class
def handleMatch(self, match): def handleMatch(self, m, data):
html = self.get_oembed_html_for_match(match) url = m.group(2).strip()
alt = m.group(1)
# Skip image URLs — let Markdown's default image handler process them
if _is_image_url(url):
return None, None, None
html = self._get_oembed_html(url, alt)
if html is None: if html is None:
return None return None, None, None
else:
html = f'<figure class="oembed ratio ratio-16x9">{ html }</figure>'
placeholder = self.md.htmlStash.store(html)
return placeholder
def get_oembed_html_for_match(self, match): html = _sanitize_html(html)
url = match.group(3).strip() if self.wrapper_class:
html = f'<figure class="{self.wrapper_class}">{html}</figure>'
# Stash raw HTML so it survives Markdown's escaping; place the
# placeholder inside an inline element that the tree-processor will
# later replace with the real HTML.
placeholder = self.md.htmlStash.store(html)
el = Element("span")
el.text = placeholder
return el, m.start(0), m.end(0)
def _get_oembed_html(self, url: str, alt: str = "") -> str | None:
"""Fetch oEmbed HTML for a URL, handling different response types."""
try: try:
response = self.consumer.embed(url) response = self.consumer.embed(url)
except oembed.OEmbedNoEndpoint: except oembed.OEmbedNoEndpoint:
LOG.error("No OEmbed Endpoint") LOG.warning("No oEmbed endpoint for URL: %s", url)
return None return None
except Exception as e: except Exception:
LOG.error(e) LOG.exception("Error fetching oEmbed for URL: %s", url)
return None return None
else:
return response['html'] # oEmbed 'video' and 'rich' types include an 'html' field
html = response.get("html")
if html:
return html
# oEmbed 'photo' type — construct an <img> tag
photo_url = response.get("url")
if photo_url:
width = response.get("width", "")
height = response.get("height", "")
escaped_alt = alt.replace('"', "&quot;")
return (
f'<img src="{photo_url}" alt="{escaped_alt}"'
f' width="{width}" height="{height}" />'
)
LOG.warning("oEmbed response for %s has no 'html' or 'url' field", url)
return None

View file

@ -0,0 +1 @@
__version__ = "0.3.0"

58
pyproject.toml Normal file
View file

@ -0,0 +1,58 @@
[project]
name = "python-markdown-oembed"
version = "0.3.0"
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"
authors = [
{ name = "Tanner Netterville", email = "tannern@gmail.com" },
]
keywords = ["markdown", "oembed"]
classifiers = [
"Development Status :: 4 - Beta",
"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",
]
dependencies = [
"python-oembed>=0.2.1",
"Markdown>=3.2",
"nh3>=0.2",
]
[project.urls]
Homepage = "https://github.com/rennat/python-markdown-oembed"
[project.entry-points."markdown.extensions"]
oembed = "mdx_oembed:makeExtension"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.sdist]
include = [
"mdx_oembed/",
"tests.py",
"README.md",
"LICENSE",
]
[tool.hatch.build.targets.wheel]
packages = ["mdx_oembed"]
[dependency-groups]
dev = [
"pytest>=7.0",
"pytest-mock>=3.0",
]
[tool.pytest.ini_options]
testpaths = ["."]
python_files = ["tests.py"]

View file

@ -1,3 +0,0 @@
[bdist_wheel]
universal = 1

View file

@ -1,51 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
try:
with open('README.markdown', 'r') as readme:
LONG_DESCRIPTION = readme.read()
except Exception:
LONG_DESCRIPTION = None
setup(
name='python-markdown-oembed',
version='0.2.1',
description="Markdown extension to allow media embedding using the oEmbed "
"standard.",
long_description=LONG_DESCRIPTION,
author='Tanner Netterville',
author_email='tannern@gmail.com',
url='https://github.com/rennat/python-markdown-oembed',
license='Public Domain',
classifiers=(
"Development Status :: 4 - Beta",
"License :: Public Domain",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3.2",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
),
keywords='markdown oembed',
packages=[
'mdx_oembed',
],
install_requires=[
"python-oembed >= 0.2.1",
"Markdown >= 2.6.1",
],
test_suite='nose.collector',
tests_require=[
'nose',
'mock'
]
)

392
tests.py
View file

@ -1,191 +1,279 @@
# -*- coding: utf-8 -*-
import re import re
import unittest import unittest
from unittest.mock import MagicMock, patch
import markdown import markdown
from mock import patch
from nose.plugins.skip import SkipTest
from mdx_oembed.extension import OEMBED_LINK_RE
from mdx_oembed import endpoints from mdx_oembed import endpoints
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, _is_image_url, _sanitize_html
class OEmbedPatternRegexTestCase(unittest.TestCase): # ---------------------------------------------------------------------------
# Regex tests
# ---------------------------------------------------------------------------
class TestOEmbedRegex(unittest.TestCase):
"""Tests for the raw OEMBED_LINK_RE pattern."""
def setUp(self): def setUp(self):
self.re = re.compile(OEMBED_LINK_RE) self.re = re.compile(OEMBED_LINK_RE)
# --- should NOT match (relative URLs) ---
def test_ignore_relative_image_link(self): def test_ignore_relative_image_link(self):
text = '![image](/image.png)' assert self.re.search("![image](/image.png)") is None
match = self.re.match(text)
self.assertIsNone(match)
def test_ignore_absolute_image_link(self): # --- should match (absolute URLs — image filtering is in Python now) ---
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)'
match = self.re.match(text)
self.assertIsNone(match)
def test_ignore_png_image_link(self): def test_match_absolute_url(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.png)' m = self.re.search("![img](http://example.com/photo.png)")
match = self.re.match(text) assert m is not None
self.assertIsNone(match)
def test_ignore_jpg_image_link(self): def test_match_youtube_link(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)' m = self.re.search("![video](http://www.youtube.com/watch?v=ABC)")
match = self.re.match(text) assert m is not None
self.assertIsNone(match) assert m.group(2) == "http://www.youtube.com/watch?v=ABC"
def test_ignore_gif_image_link(self): def test_match_youtube_short_link(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.gif)' m = self.re.search("![video](http://youtu.be/ABC)")
match = self.re.match(text) assert m is not None
self.assertIsNone(match)
def test_find_youtube_link(self): def test_match_https(self):
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)' m = self.re.search("![video](https://youtu.be/ABC)")
match = self.re.match(text) assert m is not None
self.assertIsNotNone(match)
def test_find_youtube_short_link(self): def test_match_protocol_relative(self):
text = '![video](http://youtu.be/7XzdZ4KcI8Y)' m = self.re.search("![video](//youtu.be/ABC)")
match = self.re.match(text) assert m is not None
self.assertIsNotNone(match)
def test_find_youtube_http(self): def test_alt_text_captured(self):
text = '![video](http://youtu.be/7XzdZ4KcI8Y)' m = self.re.search("![my alt text](https://example.com/embed)")
match = self.re.match(text) assert m is not None
self.assertIsNotNone(match) assert m.group(1) == "my alt text"
def test_find_youtube_https(self):
text = '![video](https://youtu.be/7XzdZ4KcI8Y)'
match = self.re.match(text)
self.assertIsNotNone(match)
def test_find_youtube_auto(self):
text = '![video](//youtu.be/7XzdZ4KcI8Y)'
match = self.re.match(text)
self.assertIsNotNone(match)
class OEmbedExtensionTestCase(unittest.TestCase): # ---------------------------------------------------------------------------
def setUp(self): # Image URL detection
self.markdown = markdown.Markdown(extensions=['oembed']) # ---------------------------------------------------------------------------
def assert_convert(self, text, expected): class TestIsImageUrl(unittest.TestCase):
with patch('oembed.OEmbedEndpoint') as MockOEmbedEndpoint:
MockOEmbedEndpoint.get.return_value = expected def test_common_extensions(self):
output = self.markdown.convert(text) for ext in ("png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "tiff", "ico"):
self.assertEqual(output, expected) assert _is_image_url(f"http://example.com/photo.{ext}") is True, ext
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_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
class IgnoredTestCase(OEmbedExtensionTestCase): # ---------------------------------------------------------------------------
""" # HTML sanitization
The OEmbedExtension should ignore these tags allowing markdown's image # ---------------------------------------------------------------------------
processor to find and handle them.
"""
def test_relative(self): class TestSanitizeHtml(unittest.TestCase):
text = '![alt](image.png)'
expected = '<p><img alt="alt" src="image.png" /></p>'
output = self.markdown.convert(text)
self.assertEqual(output, expected)
def test_slash_relative(self): def test_allows_iframe(self):
text = '![alt](/image.png)' html = '<iframe src="https://youtube.com/embed/x" width="560" height="315" allowfullscreen></iframe>'
expected = '<p><img alt="alt" src="/image.png" /></p>' result = _sanitize_html(html)
output = self.markdown.convert(text) assert "<iframe" in result
self.assertEqual(output, expected) assert 'src="https://youtube.com/embed/x"' in result
def test_absolute(self): def test_strips_script(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)' html = '<script>alert("xss")</script><iframe src="https://safe.com"></iframe>'
expected = '<p><img alt="Mumbo Jumbo" src="http://tannern.com/mumbo-jumbo.jpg" /></p>' result = _sanitize_html(html)
output = self.markdown.convert(text) assert "<script" not in result
self.assertEqual(output, expected) 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
class ProtocolVarietyTestCase(OEmbedExtensionTestCase): # ---------------------------------------------------------------------------
# Extension integration tests (mocked HTTP)
# ---------------------------------------------------------------------------
def test_http(self): def _make_mock_consumer(html_response="<iframe src='https://embed.example.com'></iframe>"):
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)' """Create a mock OEmbedConsumer that returns the given HTML."""
output = self.markdown.convert(text) consumer = MagicMock()
self.assertIn('<iframe', output) response = MagicMock()
response.get = lambda key, default=None: {"html": html_response, "type": "video"}.get(key, default)
def test_https(self): response.__getitem__ = lambda self_inner, key: {"html": html_response, "type": "video"}[key]
text = '![video](https://www.youtube.com/watch?v=7XzdZ4KcI8Y)' consumer.embed.return_value = response
output = self.markdown.convert(text) return consumer
self.assertIn('<iframe', output)
def test_auto(self):
raise SkipTest()
text = '![video](//www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
class YoutubeTestCase(OEmbedExtensionTestCase): def _make_photo_consumer(photo_url="https://example.com/photo.jpg", width=640, height=480):
""" consumer = MagicMock()
The OEmbedExtension should handle embedding for these cases. data = {"type": "photo", "url": photo_url, "width": width, "height": height}
""" response = MagicMock()
response.get = lambda key, default=None: data.get(key, default)
def test_youtube_link(self): response.__getitem__ = lambda self_inner, key: data[key]
""" consumer.embed.return_value = response
YouTube video link. return consumer
"""
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
def test_youtube_short_link(self):
"""
Short format YouTube video link.
"""
text = '![video](http://youtu.be/7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
class VimeoTestCase(OEmbedExtensionTestCase): def _make_failing_consumer(exc_class=Exception, msg="fail"):
consumer = MagicMock()
def test_vimeo_link(self): consumer.embed.side_effect = exc_class(msg)
""" return consumer
Vimeo video link.
"""
text = '![link](http://vimeo.com/52970271)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
class SlideshareTestCase(OEmbedExtensionTestCase):
def test_slideshare_link(self):
"""
Slideshare Presentation link.
"""
text = '![slides](http://www.slideshare.net/anantshri/career-in-information-security)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
class LimitedOEmbedExtensionTestCase(OEmbedExtensionTestCase): class TestOEmbedExtension(unittest.TestCase):
def setUp(self): """Integration tests with mocked oEmbed consumer."""
self.markdown = markdown.Markdown(
extensions=['oembed'],
extension_configs={
'oembed': {
'allowed_endpoints': [endpoints.YOUTUBE],
}
})
def test_youtube_link(self): def _convert(self, text, consumer=None, **ext_config):
""" """Helper: convert markdown with a mocked consumer."""
YouTube video link. if consumer is None:
""" consumer = _make_mock_consumer()
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
def test_vimeo_link(self): with patch("mdx_oembed.extension.oembed.OEmbedConsumer", return_value=consumer):
""" md = markdown.Markdown(
Vimeo video link. extensions=["oembed"],
""" extension_configs={"oembed": ext_config} if ext_config else {},
text = '![link](http://vimeo.com/52970271)' )
output = self.markdown.convert(text) return md.convert(text)
self.assertNotIn('<iframe', output)
# --- 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",
)
assert "embed-responsive" in output
def test_empty_wrapper_class(self):
output = self._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(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__": if __name__ == "__main__":

316
uv.lock Normal file
View file

@ -0,0 +1,316 @@
version = 1
revision = 3
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.10'",
"python_full_version < '3.10'",
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
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" },
]
[[package]]
name = "nh3"
version = "0.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/37/ab55eb2b05e334ff9a1ad52c556ace1f9c20a3f63613a165d384d5387657/nh3-0.3.3.tar.gz", hash = "sha256:185ed41b88c910b9ca8edc89ca3b4be688a12cb9de129d84befa2f74a0039fee", size = 18968, upload-time = "2026-02-14T09:35:15.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/a4/834f0ebd80844ce67e1bdb011d6f844f61cdb4c1d7cdc56a982bc054cc00/nh3-0.3.3-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:21b058cd20d9f0919421a820a2843fdb5e1749c0bf57a6247ab8f4ba6723c9fc", size = 1428680, upload-time = "2026-02-14T09:34:33.015Z" },
{ url = "https://files.pythonhosted.org/packages/7f/1a/a7d72e750f74c6b71befbeebc4489579fe783466889d41f32e34acde0b6b/nh3-0.3.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4400a73c2a62859e769f9d36d1b5a7a5c65c4179d1dddd2f6f3095b2db0cbfc", size = 799003, upload-time = "2026-02-14T09:34:35.108Z" },
{ url = "https://files.pythonhosted.org/packages/58/d5/089eb6d65da139dc2223b83b2627e00872eccb5e1afdf5b1d76eb6ad3fcc/nh3-0.3.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ef87f8e916321a88b45f2d597f29bd56e560ed4568a50f0f1305afab86b7189", size = 846818, upload-time = "2026-02-14T09:34:37Z" },
{ url = "https://files.pythonhosted.org/packages/9b/c6/44a0b65fc7b213a3a725f041ef986534b100e58cd1a2e00f0fd3c9603893/nh3-0.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a446eae598987f49ee97ac2f18eafcce4e62e7574bd1eb23782e4702e54e217d", size = 1012537, upload-time = "2026-02-14T09:34:38.515Z" },
{ url = "https://files.pythonhosted.org/packages/94/3a/91bcfcc0a61b286b8b25d39e288b9c0ba91c3290d402867d1cd705169844/nh3-0.3.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0d5eb734a78ac364af1797fef718340a373f626a9ff6b4fb0b4badf7927e7b81", size = 1095435, upload-time = "2026-02-14T09:34:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/fd/fd/4617a19d80cf9f958e65724ff5e97bc2f76f2f4c5194c740016606c87bd1/nh3-0.3.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:92a958e6f6d0100e025a5686aafd67e3c98eac67495728f8bb64fbeb3e474493", size = 1056344, upload-time = "2026-02-14T09:34:41.469Z" },
{ url = "https://files.pythonhosted.org/packages/bd/7d/5bcbbc56e71b7dda7ef1d6008098da9c5426d6334137ef32bb2b9c496984/nh3-0.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9ed40cf8449a59a03aa465114fedce1ff7ac52561688811d047917cc878b19ca", size = 1034533, upload-time = "2026-02-14T09:34:43.313Z" },
{ url = "https://files.pythonhosted.org/packages/3f/9c/054eff8a59a8b23b37f0f4ac84cdd688ee84cf5251664c0e14e5d30a8a67/nh3-0.3.3-cp314-cp314t-win32.whl", hash = "sha256:b50c3770299fb2a7c1113751501e8878d525d15160a4c05194d7fe62b758aad8", size = 608305, upload-time = "2026-02-14T09:34:44.622Z" },
{ url = "https://files.pythonhosted.org/packages/d7/b0/64667b8d522c7b859717a02b1a66ba03b529ca1df623964e598af8db1ed5/nh3-0.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:21a63ccb18ddad3f784bb775955839b8b80e347e597726f01e43ca1abcc5c808", size = 620633, upload-time = "2026-02-14T09:34:46.069Z" },
{ url = "https://files.pythonhosted.org/packages/91/b5/ae9909e4ddfd86ee076c4d6d62ba69e9b31061da9d2f722936c52df8d556/nh3-0.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f508ddd4e2433fdcb78c790fc2d24e3a349ba775e5fa904af89891321d4844a3", size = 607027, upload-time = "2026-02-14T09:34:47.91Z" },
{ url = "https://files.pythonhosted.org/packages/13/3e/aef8cf8e0419b530c95e96ae93a5078e9b36c1e6613eeb1df03a80d5194e/nh3-0.3.3-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e8ee96156f7dfc6e30ecda650e480c5ae0a7d38f0c6fafc3c1c655e2500421d9", size = 1448640, upload-time = "2026-02-14T09:34:49.316Z" },
{ url = "https://files.pythonhosted.org/packages/ca/43/d2011a4f6c0272cb122eeff40062ee06bb2b6e57eabc3a5e057df0d582df/nh3-0.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45fe0d6a607264910daec30360c8a3b5b1500fd832d21b2da608256287bcb92d", size = 839405, upload-time = "2026-02-14T09:34:50.779Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f3/965048510c1caf2a34ed04411a46a04a06eb05563cd06f1aa57b71eb2bc8/nh3-0.3.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bc1d4b30ba1ba896669d944b6003630592665974bd11a3dc2f661bde92798a7", size = 825849, upload-time = "2026-02-14T09:34:52.622Z" },
{ url = "https://files.pythonhosted.org/packages/78/99/b4bbc6ad16329d8db2c2c320423f00b549ca3b129c2b2f9136be2606dbb0/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f433a2dd66545aad4a720ad1b2150edcdca75bfff6f4e6f378ade1ec138d5e77", size = 1068303, upload-time = "2026-02-14T09:34:54.179Z" },
{ url = "https://files.pythonhosted.org/packages/3f/34/3420d97065aab1b35f3e93ce9c96c8ebd423ce86fe84dee3126790421a2a/nh3-0.3.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52e973cb742e95b9ae1b35822ce23992428750f4b46b619fe86eba4205255b30", size = 1029316, upload-time = "2026-02-14T09:34:56.186Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9a/99eda757b14e596fdb2ca5f599a849d9554181aa899274d0d183faef4493/nh3-0.3.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c730617bdc15d7092dcc0469dc2826b914c8f874996d105b4bc3842a41c1cd9", size = 919944, upload-time = "2026-02-14T09:34:57.886Z" },
{ url = "https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297", size = 811461, upload-time = "2026-02-14T09:34:59.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ec/b1bf57cab6230eec910e4863528dc51dcf21b57aaf7c88ee9190d62c9185/nh3-0.3.3-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3a62b8ae7c235481715055222e54c682422d0495a5c73326807d4e44c5d14691", size = 840360, upload-time = "2026-02-14T09:35:01.444Z" },
{ url = "https://files.pythonhosted.org/packages/37/5e/326ae34e904dde09af1de51219a611ae914111f0970f2f111f4f0188f57e/nh3-0.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc305a2264868ec8fa16548296f803d8fd9c1fa66cd28b88b605b1bd06667c0b", size = 859872, upload-time = "2026-02-14T09:35:03.348Z" },
{ url = "https://files.pythonhosted.org/packages/09/38/7eba529ce17ab4d3790205da37deabb4cb6edcba15f27b8562e467f2fc97/nh3-0.3.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:90126a834c18af03bfd6ff9a027bfa6bbf0e238527bc780a24de6bd7cc1041e2", size = 1023550, upload-time = "2026-02-14T09:35:04.829Z" },
{ url = "https://files.pythonhosted.org/packages/05/a2/556fdecd37c3681b1edee2cf795a6799c6ed0a5551b2822636960d7e7651/nh3-0.3.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:24769a428e9e971e4ccfb24628f83aaa7dc3c8b41b130c8ddc1835fa1c924489", size = 1105212, upload-time = "2026-02-14T09:35:06.821Z" },
{ url = "https://files.pythonhosted.org/packages/dd/e3/5db0b0ad663234967d83702277094687baf7c498831a2d3ad3451c11770f/nh3-0.3.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:b7a18ee057761e455d58b9d31445c3e4b2594cff4ddb84d2e331c011ef46f462", size = 1069970, upload-time = "2026-02-14T09:35:08.504Z" },
{ url = "https://files.pythonhosted.org/packages/79/b2/2ea21b79c6e869581ce5f51549b6e185c4762233591455bf2a326fb07f3b/nh3-0.3.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4b2c1f3e6f3cbe7048e17f4fefad3f8d3e14cc0fd08fb8599e0d5653f6b181", size = 1047588, upload-time = "2026-02-14T09:35:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/e2/92/2e434619e658c806d9c096eed2cdff9a883084299b7b19a3f0824eb8e63d/nh3-0.3.3-cp38-abi3-win32.whl", hash = "sha256:e974850b131fdffa75e7ad8e0d9c7a855b96227b093417fdf1bd61656e530f37", size = 616179, upload-time = "2026-02-14T09:35:11.366Z" },
{ url = "https://files.pythonhosted.org/packages/73/88/1ce287ef8649dc51365b5094bd3713b76454838140a32ab4f8349973883c/nh3-0.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:2efd17c0355d04d39e6d79122b42662277ac10a17ea48831d90b46e5ef7e4fc0", size = 631159, upload-time = "2026-02-14T09:35:12.77Z" },
{ 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 = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.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.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'" },
]
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" }
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" },
]
[[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.*'" },
]
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 = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
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'" },
]
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 = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "python-markdown-oembed"
version = "0.3.0"
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 = "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 = "pytest-mock" },
]
[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 = "pytest", specifier = ">=7.0" },
{ name = "pytest-mock", specifier = ">=3.0" },
]
[[package]]
name = "python-oembed"
version = "0.2.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" }
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" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
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" },
]