Compare commits

..

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

15 changed files with 310 additions and 1186 deletions

8
.gitignore vendored
View file

@ -1,11 +1,5 @@
.DS_Store
.idea
/dist
/.eggs
venv
dist
*.pyc
*.egg
*.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.version import __version__
VERSION = __version__
__all__ = ["OEmbedExtension", "VERSION", "__version__", "makeExtension"]
def makeExtension(**kwargs: object) -> OEmbedExtension: # noqa: N802
return OEmbedExtension(**kwargs)
VERSION = '0.1.3'
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
# that matches both http and https schemes.
YOUTUBE = OEmbedEndpoint('https://www.youtube.com/oembed', [
'https?://*.youtube.com/*',
'https?://youtu.be/*',
])
SLIDESHARE = OEmbedEndpoint('https://www.slideshare.net/api/oembed/2', [
'https?://www.slideshare.net/*/*',
'https?://fr.slideshare.net/*/*',
'https?://de.slideshare.net/*/*',
'https?://es.slideshare.net/*/*',
'https?://pt.slideshare.net/*/*',
])
FLICKR = OEmbedEndpoint('https://www.flickr.com/services/oembed/', [
'https?://*.flickr.com/*',
])
VIMEO = OEmbedEndpoint('https://vimeo.com/api/oembed.json', [
'https?://vimeo.com/*',
])
DEFAULT_ENDPOINTS = [
YOUTUBE,
FLICKR,
VIMEO,
SLIDESHARE,
]
ENDPOINTS = {
'youtube': oembed.OEmbedEndpoint('http://www.youtube.com/oembed', [
'https?://(*.)?youtube.com/*',
'https?://youtu.be/*',
]),
'flickr': oembed.OEmbedEndpoint('http://www.flickr.com/services/oembed/', [
'https?://*.flickr.com/*',
]),
'vimeo': oembed.OEmbedEndpoint('http://vimeo.com/api/oembed.json', [
'https?://vimeo.com/*',
]),
}

View file

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

View file

@ -1,135 +1,34 @@
from __future__ import annotations
import html as _html
# -*- coding: utf-8 -*-
import logging
from os.path import splitext
from urllib.parse import urlparse
from xml.etree.ElementTree import Element
from markdown.inlinepatterns import Pattern
import oembed
import markdown
import nh3
from markdown.inlinepatterns import InlineProcessor
from mdx_oembed.oembed import OEmbedConsumer, OEmbedNoEndpoint
LOG = logging.getLogger(__name__)
# Image extensions to exclude from oEmbed processing
_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?:)?//[^\)]+)\)"
# 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"},
}
OEMBED_LINK_RE = r'\!\[([^\]]*)\]\(((?:https?:)?//[^\)]*)' \
r'(?<!png)(?<!jpg)(?<!jpeg)(?<!gif)\)'
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
class OEmbedLinkPattern(Pattern):
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: str,
md: markdown.Markdown | None = None,
oembed_consumer: OEmbedConsumer | None = None,
wrapper_class: str = "oembed",
) -> None:
super().__init__(pattern, md)
def __init__(self, pattern, markdown_instance=None, oembed_consumer=None):
Pattern.__init__(self, pattern, markdown_instance)
self.consumer = oembed_consumer
self.wrapper_class = wrapper_class
def handleMatch(self, m, data): # noqa: N802
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)
def handleMatch(self, match):
html = self.get_oembed_html_for_match(match)
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
placeholder = self.markdown.htmlStash.store(html)
return placeholder
def get_oembed_html_for_match(self, match):
url = match.group(3).strip()
try:
response = self.consumer.embed(url)
except OEmbedNoEndpoint:
LOG.warning("No oEmbed endpoint for URL: %s", url)
except oembed.OEmbedNoEndpoint:
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
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"

43
setup.py Normal file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
import mdx_oembed
with open('README.markdown', 'r') as readme:
LONG_DESCRIPTION = readme.read()
VERSION = mdx_oembed.VERSION
setup(
name='python-markdown-oembed',
version=VERSION,
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."""
from __future__ import annotations
import json
# -*- coding: utf-8 -*-
import re
import warnings
from unittest.mock import MagicMock, patch
import unittest
import markdown
import pytest
from mdx_oembed import endpoints
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, _is_image_url, _sanitize_html
from mdx_oembed.oembed import (
OEmbedConsumer,
OEmbedEndpoint,
OEmbedError,
OEmbedNoEndpoint,
)
# ---------------------------------------------------------------------------
# Regex tests
# ---------------------------------------------------------------------------
_OEMBED_RE = re.compile(OEMBED_LINK_RE)
def test_ignore_relative_image_link():
assert _OEMBED_RE.search("![image](/image.png)") is None
def test_match_absolute_url():
m = _OEMBED_RE.search("![img](http://example.com/photo.png)")
assert m is not None
def test_match_youtube_link():
m = _OEMBED_RE.search("![video](http://www.youtube.com/watch?v=ABC)")
assert m is not None
assert m.group(2) == "http://www.youtube.com/watch?v=ABC"
def test_match_youtube_short_link():
m = _OEMBED_RE.search("![video](http://youtu.be/ABC)")
assert m is not None
def test_match_https():
m = _OEMBED_RE.search("![video](https://youtu.be/ABC)")
assert m is not None
def test_match_protocol_relative():
m = _OEMBED_RE.search("![video](//youtu.be/ABC)")
assert m is not None
def test_alt_text_captured():
m = _OEMBED_RE.search("![my alt text](https://example.com/embed)")
assert m is not None
assert m.group(1) == "my alt text"
# ---------------------------------------------------------------------------
# Image URL detection
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"ext",
["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "tiff", "ico"],
)
def test_common_image_extensions(ext: str):
assert _is_image_url(f"http://example.com/photo.{ext}") is True
def test_image_url_case_insensitive():
assert _is_image_url("http://example.com/Photo.PNG") is True
assert _is_image_url("http://example.com/photo.JpEg") is True
def test_image_url_query_string_ignored():
assert _is_image_url("http://example.com/photo.jpg?size=large") is True
def test_non_image_url():
assert _is_image_url("http://www.youtube.com/watch?v=ABC") is False
def test_no_extension_url():
assert _is_image_url("http://example.com/embed") is False
# ---------------------------------------------------------------------------
# HTML sanitization
# ---------------------------------------------------------------------------
def test_sanitize_allows_iframe():
html = (
'<iframe src="https://youtube.com/embed/x"'
' width="560" height="315" allowfullscreen></iframe>'
)
result = _sanitize_html(html)
assert "<iframe" in result
assert 'src="https://youtube.com/embed/x"' in result
def test_sanitize_strips_script():
html = '<script>alert("xss")</script><iframe src="https://safe.com"></iframe>'
result = _sanitize_html(html)
assert "<script" not in result
assert "<iframe" in result
def test_sanitize_strips_onerror():
html = '<img src="x" onerror="alert(1)" />'
result = _sanitize_html(html)
assert "onerror" not in result
# ---------------------------------------------------------------------------
# OEmbedConsumer / OEmbedEndpoint unit tests
# ---------------------------------------------------------------------------
def test_endpoint_matches_http_and_https():
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
assert ep.matches("http://example.com/video/1")
assert ep.matches("https://example.com/video/1")
assert not ep.matches("http://other.com/video/1")
def test_consumer_add_endpoint():
consumer = OEmbedConsumer()
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
consumer.add_endpoint(ep)
assert ep in consumer._endpoints # noqa: SLF001
def test_consumer_add_endpoint_deprecated_alias():
consumer = OEmbedConsumer()
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
consumer.addEndpoint(ep)
assert len(w) == 1
assert issubclass(w[0].category, DeprecationWarning)
assert "addEndpoint" in str(w[0].message)
assert ep in consumer._endpoints # noqa: SLF001
def test_consumer_embed_no_endpoint():
consumer = OEmbedConsumer()
with pytest.raises(OEmbedNoEndpoint):
consumer.embed("http://unknown.example.com/video")
def test_consumer_http_status_error():
"""Non-2xx HTTP responses should raise OEmbedError."""
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
consumer = OEmbedConsumer()
consumer.add_endpoint(ep)
mock_resp = MagicMock()
mock_resp.status = 404
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("mdx_oembed.oembed.urlopen", return_value=mock_resp):
with pytest.raises(OEmbedError, match="HTTP 404"):
consumer.embed("http://example.com/video/1")
def test_consumer_successful_fetch():
"""Successful 200 response should return parsed JSON."""
ep = OEmbedEndpoint("https://example.com/oembed", ["https?://example.com/*"])
consumer = OEmbedConsumer()
consumer.add_endpoint(ep)
body = json.dumps({"html": "<iframe></iframe>", "type": "video"}).encode()
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.read.return_value = body
mock_resp.headers.get_content_charset.return_value = "utf-8"
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("mdx_oembed.oembed.urlopen", return_value=mock_resp):
data = consumer.embed("http://example.com/video/1")
assert data["html"] == "<iframe></iframe>"
# ---------------------------------------------------------------------------
# Extension integration tests (mocked HTTP)
# ---------------------------------------------------------------------------
def _make_mock_consumer(
html_response: 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"],
from mock import patch
from mdx_oembed.extension import OEMBED_LINK_RE
class OEmbedPatternRegexTestCase(unittest.TestCase):
def setUp(self):
self.re = re.compile(OEMBED_LINK_RE)
def test_ignore_relative_image_link(self):
text = '![image](/image.png)'
match = self.re.match(text)
self.assertIsNone(match)
def test_ignore_absolute_image_link(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)'
match = self.re.match(text)
self.assertIsNone(match)
def test_ignore_png_image_link(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.png)'
match = self.re.match(text)
self.assertIsNone(match)
def test_ignore_jpg_image_link(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)'
match = self.re.match(text)
self.assertIsNone(match)
def test_ignore_gif_image_link(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.gif)'
match = self.re.match(text)
self.assertIsNone(match)
def test_find_youtube_link(self):
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
match = self.re.match(text)
self.assertIsNotNone(match)
def test_find_youtube_short_link(self):
text = '![video](http://youtu.be/7XzdZ4KcI8Y)'
match = self.re.match(text)
self.assertIsNotNone(match)
def test_find_youtube_http(self):
text = '![video](http://youtu.be/7XzdZ4KcI8Y)'
match = self.re.match(text)
self.assertIsNotNone(match)
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):
self.markdown = markdown.Markdown(extensions=['oembed'])
def assert_convert(self, text, expected):
with patch('oembed.OEmbedEndpoint') as MockOEmbedEndpoint:
MockOEmbedEndpoint.get.return_value = expected
output = self.markdown.convert(text)
self.assertEqual(output, expected)
class IgnoredTestCase(OEmbedExtensionTestCase):
"""
The OEmbedExtension should ignore these tags allowing markdown's image
processor to find and handle them.
"""
def test_relative(self):
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):
text = '![alt](/image.png)'
expected = '<p><img alt="alt" src="/image.png" /></p>'
output = self.markdown.convert(text)
self.assertEqual(output, expected)
def test_absolute(self):
text = '![Mumbo Jumbo](http://tannern.com/mumbo-jumbo.jpg)'
expected = '<p><img alt="Mumbo Jumbo" src="http://tannern.com/mumbo-jumbo.jpg" /></p>'
output = self.markdown.convert(text)
self.assertEqual(output, expected)
class ProtocolVarietyTestCase(OEmbedExtensionTestCase):
def test_http(self):
text = '![video](http://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
def test_https(self):
text = '![video](https://www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
def test_auto(self):
text = '![video](//www.youtube.com/watch?v=7XzdZ4KcI8Y)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
class YoutubeTestCase(OEmbedExtensionTestCase):
"""
The OEmbedExtension should handle embedding for these cases.
"""
def test_youtube_link(self):
"""
YouTube video link.
"""
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 test_vimeo_link(self):
"""
Vimeo video link.
"""
text = '![link](http://vimeo.com/52970271)'
output = self.markdown.convert(text)
self.assertIn('<iframe', output)
class LimitedOEmbedExtensionTestCase(OEmbedExtensionTestCase):
def setUp(self):
self.markdown = 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
'oembed': {
'allowed_endpoints': ['youtube',],
}
})
md.reset()
vim_output = md.convert("![v](http://vimeo.com/12345)")
assert "<iframe" not in vim_output
def test_youtube_link(self):
"""
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" },
]