Compare commits

..

No commits in common. "master" and "v0.1.4" have entirely different histories.

15 changed files with 311 additions and 1186 deletions

8
.gitignore vendored
View file

@ -1,11 +1,5 @@
.DS_Store .DS_Store
.idea .idea
/dist dist
/.eggs
venv
*.pyc *.pyc
*.egg
*.egg-info *.egg-info
*.coverage
*.swp
*.swo

1
MANIFEST.in Normal file
View file

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

25
README.markdown Normal file
View file

@ -0,0 +1,25 @@
# 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.

112
README.md
View file

@ -1,112 +0,0 @@
# Python Markdown oEmbed
Markdown extension to allow media embedding using the oEmbed standard.
## Requirements
- Python >= 3.12
- Markdown >= 3.2
## Installation
pip install python-markdown-oembed-extension
Or with [uv](https://docs.astral.sh/uv/):
uv add python-markdown-oembed-extension
## 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.5.0
- **Breaking:** requires Python >= 3.12
- Replaced custom `html.escape()` for proper attribute escaping in photo `<img>` tags
- HTTP status validation: non-2xx oEmbed API responses now raise `OEmbedError`
- `addEndpoint()` is deprecated in favour of `add_endpoint()` (emits `DeprecationWarning`)
- Added `from __future__ import annotations` to all modules
- Added `py.typed` marker (PEP 561) for downstream type-checking support
- Version is now sourced dynamically from `mdx_oembed/version.py` via hatch
- Added ruff and pyright configuration in `pyproject.toml`
- Tests converted to pure pytest (dropped `unittest.TestCase`)
- New tests for HTTP status handling, deprecation warnings, and HTML escaping
- Removed legacy `src/` package tree
### 0.4.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,12 +1,11 @@
from __future__ import annotations # -*- coding: utf-8 -*-
from mdx_oembed.extension import OEmbedExtension from mdx_oembed.extension import OEmbedExtension
from mdx_oembed.version import __version__
VERSION = __version__
__all__ = ["OEmbedExtension", "VERSION", "__version__", "makeExtension"]
def makeExtension(**kwargs: object) -> OEmbedExtension: # noqa: N802 VERSION = '0.1.4'
return OEmbedExtension(**kwargs)
def makeExtension(configs=None):
if isinstance(configs, list):
configs = dict(configs)
return OEmbedExtension(configs=configs)

View file

@ -1,34 +1,16 @@
from __future__ import annotations # -*- coding: utf-8 -*-
import oembed
from mdx_oembed.oembed import OEmbedEndpoint
# URL patterns use shell-style globs with an "https?://" shorthand ENDPOINTS = {
# that matches both http and https schemes. 'youtube': oembed.OEmbedEndpoint('http://www.youtube.com/oembed', [
'https?://(*.)?youtube.com/*',
YOUTUBE = OEmbedEndpoint('https://www.youtube.com/oembed', [
'https?://*.youtube.com/*',
'https?://youtu.be/*', 'https?://youtu.be/*',
]) ]),
'flickr': oembed.OEmbedEndpoint('http://www.flickr.com/services/oembed/', [
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/*', 'https?://*.flickr.com/*',
]) ]),
'vimeo': oembed.OEmbedEndpoint('http://vimeo.com/api/oembed.json', [
VIMEO = OEmbedEndpoint('https://vimeo.com/api/oembed.json', [
'https?://vimeo.com/*', 'https?://vimeo.com/*',
]) ]),
}
DEFAULT_ENDPOINTS = [
YOUTUBE,
FLICKR,
VIMEO,
SLIDESHARE,
]

View file

@ -1,41 +1,34 @@
from __future__ import annotations # -*- coding: utf-8 -*-
from markdown import Extension from markdown import Extension
import oembed
from mdx_oembed.endpoints import ENDPOINTS
from mdx_oembed.inlinepatterns import OEmbedLinkPattern, OEMBED_LINK_RE
from mdx_oembed.endpoints import DEFAULT_ENDPOINTS
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, OEmbedLinkPattern AVAILABLE_ENDPOINTS = ENDPOINTS.keys()
from mdx_oembed.oembed import OEmbedConsumer
class OEmbedExtension(Extension): class OEmbedExtension(Extension):
def __init__(self, **kwargs): config = {
self.config = {
'allowed_endpoints': [ 'allowed_endpoints': [
DEFAULT_ENDPOINTS, AVAILABLE_ENDPOINTS,
"A list of oEmbed endpoints to allow. " "A list of oEmbed endpoints to allow. Possible values are "
"Defaults to endpoints.DEFAULT_ENDPOINTS", "{}.".format(', '.join(AVAILABLE_ENDPOINTS)),
],
'wrapper_class': [
'oembed',
"CSS class(es) for the <figure> wrapper element. "
"Set to empty string to disable wrapping.",
], ],
} }
super().__init__(**kwargs)
def extendMarkdown(self, md): # noqa: N802 def extendMarkdown(self, md, md_globals):
consumer = self._prepare_oembed_consumer() self.oembed_consumer = self.prepare_oembed_consumer()
wrapper_class = self.getConfig('wrapper_class', 'oembed') link_pattern = OEmbedLinkPattern(OEMBED_LINK_RE, md,
link_pattern = OEmbedLinkPattern( self.oembed_consumer)
OEMBED_LINK_RE, md, consumer, wrapper_class=wrapper_class, md.inlinePatterns.add('oembed_link', link_pattern, '<image_link')
)
# Priority 175 — run before the default image pattern (priority 150)
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',
consumer = OEmbedConsumer() AVAILABLE_ENDPOINTS)
for endpoint in (allowed_endpoints or []): consumer = oembed.OEmbedConsumer()
consumer.add_endpoint(endpoint) [consumer.addEndpoint(v)
for k,v in ENDPOINTS.items()
if k in allowed_endpoints]
return consumer return consumer

View file

@ -1,135 +1,34 @@
from __future__ import annotations # -*- coding: utf-8 -*-
import html as _html
import logging import logging
from os.path import splitext from markdown.inlinepatterns import Pattern
from urllib.parse import urlparse import oembed
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__) LOG = logging.getLogger(__name__)
# Image extensions to exclude from oEmbed processing
_IMAGE_EXTENSIONS = frozenset({
".png", ".jpg", ".jpeg", ".gif", ".avif", ".webp",
".svg", ".bmp", ".tiff", ".ico",
})
# Matches Markdown image syntax with an absolute URL: ![alt](https://...) OEMBED_LINK_RE = r'\!\[([^\]]*)\]\(((?:https?:)?//[^\)]*)' \
OEMBED_LINK_RE = r"!\[([^\]]*)\]\(((?:https?:)?//[^\)]+)\)" r'(?<!png)(?<!jpg)(?<!jpeg)(?<!gif)\)'
# 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"},
}
def _is_image_url(url: str) -> bool: class OEmbedLinkPattern(Pattern):
"""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, markdown_instance=None, oembed_consumer=None):
def _sanitize_html(html: str) -> str: Pattern.__init__(self, pattern, markdown_instance)
"""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: 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.consumer = oembed_consumer
self.wrapper_class = wrapper_class
def handleMatch(self, m, data): # noqa: N802 def handleMatch(self, match):
url = m.group(2).strip() html = self.get_oembed_html_for_match(match)
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, None, None
html = _sanitize_html(html)
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."""
if self.consumer is None:
LOG.warning("No oEmbed consumer configured")
return None return None
placeholder = self.markdown.htmlStash.store(html)
return placeholder
def get_oembed_html_for_match(self, match):
url = match.group(3).strip()
try: try:
response = self.consumer.embed(url) response = self.consumer.embed(url)
except OEmbedNoEndpoint: except oembed.OEmbedNoEndpoint:
LOG.warning("No oEmbed endpoint for URL: %s", url)
return None
except Exception:
LOG.exception("Error fetching oEmbed for URL: %s", url)
return None
# 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", "")
return (
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)
return None return None
else:
return response['html']

View file

@ -1,181 +0,0 @@
"""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

View file

View file

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

View file

@ -1,77 +0,0 @@
[project]
name = "python-markdown-oembed-extension"
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.12"
authors = [
{ name = "Benedikt Willi", email = "ben.willi@gmail.com" },
{ 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.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"Markdown>=3.2",
"nh3>=0.2",
]
[project.urls]
Homepage = "https://github.com/Hopiu/python-markdown-oembed"
[project.entry-points."markdown.extensions"]
oembed = "mdx_oembed:makeExtension"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "mdx_oembed/version.py"
[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",
"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"

44
setup.py Normal file
View file

@ -0,0 +1,44 @@
#!/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.1.4',
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",
),
keywords='markdown oembed',
packages=[
'mdx_oembed',
],
install_requires=[
"python-oembed >= 0.2.1",
"Markdown >= 2.2.0",
],
test_suite='nose.collector',
tests_require=['WebTest >= 1.2', 'BeautifulSoup', 'pytidylib', 'poster']
)

586
tests.py
View file

@ -1,415 +1,179 @@
"""Tests for python-markdown-oembed extension.""" # -*- coding: utf-8 -*-
from __future__ import annotations
import json
import re import re
import warnings import unittest
from unittest.mock import MagicMock, patch
import markdown import markdown
import pytest from mock import patch
from mdx_oembed.extension import OEMBED_LINK_RE
from mdx_oembed import endpoints
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, _is_image_url, _sanitize_html
from mdx_oembed.oembed import ( class OEmbedPatternRegexTestCase(unittest.TestCase):
OEmbedConsumer, def setUp(self):
OEmbedEndpoint, self.re = re.compile(OEMBED_LINK_RE)
OEmbedError,
OEmbedNoEndpoint, def test_ignore_relative_image_link(self):
) text = '![image](/image.png)'
match = self.re.match(text)
# --------------------------------------------------------------------------- self.assertIsNone(match)
# Regex tests
# --------------------------------------------------------------------------- def test_ignore_absolute_image_link(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)'
_OEMBED_RE = re.compile(OEMBED_LINK_RE) match = self.re.match(text)
self.assertIsNone(match)
def test_ignore_relative_image_link(): def test_ignore_png_image_link(self):
assert _OEMBED_RE.search("![image](/image.png)") is None text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.png)'
match = self.re.match(text)
self.assertIsNone(match)
def test_match_absolute_url():
m = _OEMBED_RE.search("![img](http://example.com/photo.png)") def test_ignore_jpg_image_link(self):
assert m is not None text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)'
match = self.re.match(text)
self.assertIsNone(match)
def test_match_youtube_link():
m = _OEMBED_RE.search("![video](http://www.youtube.com/watch?v=ABC)") def test_ignore_gif_image_link(self):
assert m is not None text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.gif)'
assert m.group(2) == "http://www.youtube.com/watch?v=ABC" match = self.re.match(text)
self.assertIsNone(match)
def test_match_youtube_short_link(): def test_find_youtube_link(self):
m = _OEMBED_RE.search("![video](http://youtu.be/ABC)") text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
assert m is not None match = self.re.match(text)
self.assertIsNotNone(match)
def test_match_https(): def test_find_youtube_short_link(self):
m = _OEMBED_RE.search("![video](https://youtu.be/ABC)") text = '![video](http://youtu.be/7XzdZ4KcI8Y)'
assert m is not None match = self.re.match(text)
self.assertIsNotNone(match)
def test_match_protocol_relative(): def test_find_youtube_http(self):
m = _OEMBED_RE.search("![video](//youtu.be/ABC)") text = '![video](http://youtu.be/7XzdZ4KcI8Y)'
assert m is not None match = self.re.match(text)
self.assertIsNotNone(match)
def test_alt_text_captured(): def test_find_youtube_https(self):
m = _OEMBED_RE.search("![my alt text](https://example.com/embed)") text = '![video](https://youtu.be/7XzdZ4KcI8Y)'
assert m is not None match = self.re.match(text)
assert m.group(1) == "my alt text" self.assertIsNotNone(match)
def test_find_youtube_auto(self):
# --------------------------------------------------------------------------- text = '![video](//youtu.be/7XzdZ4KcI8Y)'
# Image URL detection match = self.re.match(text)
# --------------------------------------------------------------------------- self.assertIsNotNone(match)
@pytest.mark.parametrize( class OEmbedExtensionTestCase(unittest.TestCase):
"ext", def setUp(self):
["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "tiff", "ico"], self.markdown = markdown.Markdown(extensions=['oembed'])
)
def test_common_image_extensions(ext: str): def assert_convert(self, text, expected):
assert _is_image_url(f"http://example.com/photo.{ext}") is True with patch('oembed.OEmbedEndpoint') as MockOEmbedEndpoint:
MockOEmbedEndpoint.get.return_value = expected
output = self.markdown.convert(text)
def test_image_url_case_insensitive(): self.assertEqual(output, expected)
assert _is_image_url("http://example.com/Photo.PNG") is True
assert _is_image_url("http://example.com/photo.JpEg") is True
class IgnoredTestCase(OEmbedExtensionTestCase):
"""
def test_image_url_query_string_ignored(): The OEmbedExtension should ignore these tags allowing markdown's image
assert _is_image_url("http://example.com/photo.jpg?size=large") is True processor to find and handle them.
"""
def test_non_image_url(): def test_relative(self):
assert _is_image_url("http://www.youtube.com/watch?v=ABC") is False text = '![alt](image.png)'
expected = '<p><img alt="alt" src="image.png" /></p>'
output = self.markdown.convert(text)
def test_no_extension_url(): self.assertEqual(output, expected)
assert _is_image_url("http://example.com/embed") is False
def test_slash_relative(self):
text = '![alt](/image.png)'
# --------------------------------------------------------------------------- expected = '<p><img alt="alt" src="/image.png" /></p>'
# HTML sanitization output = self.markdown.convert(text)
# --------------------------------------------------------------------------- self.assertEqual(output, expected)
def test_absolute(self):
def test_sanitize_allows_iframe(): text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)'
html = ( expected = '<p><img alt="Mumbo Jumbo" src="http://tannern.com/mumbo-jumbo.jpg" /></p>'
'<iframe src="https://youtube.com/embed/x"' output = self.markdown.convert(text)
' width="560" height="315" allowfullscreen></iframe>' self.assertEqual(output, expected)
)
result = _sanitize_html(html)
assert "<iframe" in result class ProtocolVarietyTestCase(OEmbedExtensionTestCase):
assert 'src="https://youtube.com/embed/x"' in result
def test_http(self):
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
def test_sanitize_strips_script(): output = self.markdown.convert(text)
html = '<script>alert("xss")</script><iframe src="https://safe.com"></iframe>' self.assertIn('<iframe', output)
result = _sanitize_html(html)
assert "<script" not in result def test_https(self):
assert "<iframe" in result text = '![video](https://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
def test_sanitize_strips_onerror():
html = '<img src="x" onerror="alert(1)" />' def test_auto(self):
result = _sanitize_html(html) text = '![video](//www.youtube.com/watch?v=7XzdZ4KcI8Y)'
assert "onerror" not in result output = self.markdown.convert(text)
self.assertIn('<iframe', output)
# ---------------------------------------------------------------------------
# OEmbedConsumer / OEmbedEndpoint unit tests class YoutubeTestCase(OEmbedExtensionTestCase):
# --------------------------------------------------------------------------- """
The OEmbedExtension should handle embedding for these cases.
"""
def test_endpoint_matches_http_and_https():
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"]) def test_youtube_link(self):
assert ep.matches("http://example.com/video/1") """
assert ep.matches("https://example.com/video/1") YouTube video link.
assert not ep.matches("http://other.com/video/1") """
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
def test_consumer_add_endpoint(): self.assertIn('<iframe', output)
consumer = OEmbedConsumer()
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"]) def test_youtube_short_link(self):
consumer.add_endpoint(ep) """
assert ep in consumer._endpoints # noqa: SLF001 Short format YouTube video link.
"""
text = '![video](http://youtu.be/7XzdZ4KcI8Y)'
def test_consumer_add_endpoint_deprecated_alias(): output = self.markdown.convert(text)
consumer = OEmbedConsumer() self.assertIn('<iframe', output)
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always") class VimeoTestCase(OEmbedExtensionTestCase):
consumer.addEndpoint(ep)
assert len(w) == 1 def test_vimeo_link(self):
assert issubclass(w[0].category, DeprecationWarning) """
assert "addEndpoint" in str(w[0].message) Vimeo video link.
assert ep in consumer._endpoints # noqa: SLF001 """
text = '![link](http://vimeo.com/52970271)'
output = self.markdown.convert(text)
def test_consumer_embed_no_endpoint(): self.assertIn('<iframe', output)
consumer = OEmbedConsumer()
with pytest.raises(OEmbedNoEndpoint):
consumer.embed("http://unknown.example.com/video") class LimitedOEmbedExtensionTestCase(OEmbedExtensionTestCase):
def setUp(self):
self.markdown = markdown.Markdown(
def test_consumer_http_status_error(): extensions=['oembed'],
"""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: 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: 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: 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()
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_failing_consumer(
exc_class: type[Exception] = Exception, msg: str = "fail"
) -> MagicMock:
consumer = MagicMock()
consumer.embed.side_effect = exc_class(msg)
return 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()
with patch("mdx_oembed.extension.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():
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={ extension_configs={
"oembed": {"allowed_endpoints": [endpoints.YOUTUBE]}, 'oembed': {
}, 'allowed_endpoints': ['youtube',],
) }
yt_output = md.convert("![v](http://www.youtube.com/watch?v=A)") })
assert "<iframe" in yt_output
md.reset() def test_youtube_link(self):
vim_output = md.convert("![v](http://vimeo.com/12345)") """
assert "<iframe" not in vim_output YouTube video link.
"""
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
def test_vimeo_link(self):
"""
Vimeo video link.
"""
text = '![link](http://vimeo.com/52970271)'
output = self.markdown.convert(text)
self.assertNotIn('<iframe', output)
if __name__ == "__main__":
unittest.main()

205
uv.lock
View file

@ -1,205 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[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 = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
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.10.2"
source = { registry = "https://pypi.org/simple" }
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 = "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"
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 = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
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/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" }
dependencies = [
{ 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 = [
{ 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" },
]
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-extension"
source = { editable = "." }
dependencies = [
{ name = "markdown" },
{ name = "nh3" },
]
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-mock" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "markdown", specifier = ">=3.2" },
{ name = "nh3", specifier = ">=0.2" },
]
[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 = "ruff"
version = "0.15.4"
source = { registry = "https://pypi.org/simple" }
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/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]]
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" },
]