mirror of
https://github.com/Hopiu/python-markdown-oembed.git
synced 2026-03-17 14:30:25 +00:00
Compare commits
32 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdcafbf4c6 | ||
|
|
9e48edcfdf | ||
|
|
6864af69fb | ||
|
|
428883fd99 | ||
|
|
7e41514005 | ||
|
|
dd567e7013 | ||
|
|
20f2880e07 | ||
|
|
f3963e7ad5 | ||
|
|
22e7f76d56 | ||
|
|
6df30f7cf3 | ||
|
|
bb5eb5445e | ||
|
|
79c7c2f060 | ||
|
|
745056d5e6 | ||
|
|
0e3bf49c86 | ||
|
|
477d04541c | ||
|
|
d216b6eb68 | ||
|
|
e5d64d6853 | ||
|
|
cb46ae8f8a | ||
|
|
a665517a5d | ||
|
|
ffbac50ce0 | ||
|
|
cdc3a41260 | ||
|
|
7fb4cb2aa1 | ||
|
|
5d6b341f8a | ||
|
|
d510e7f081 | ||
|
|
c1088492e0 | ||
|
|
8768a5a546 | ||
|
|
81a8378564 | ||
|
|
8d8dd3d21b | ||
|
|
78645522ee | ||
|
|
d2d5632f4f | ||
|
|
9e11630327 | ||
|
|
029e1cd4f2 |
16 changed files with 1149 additions and 309 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -2,7 +2,10 @@
|
|||
.idea
|
||||
/dist
|
||||
/.eggs
|
||||
/venv
|
||||
venv
|
||||
*.pyc
|
||||
*.egg
|
||||
*.egg-info
|
||||
*.coverage
|
||||
*.swp
|
||||
*.swo
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.2"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
install: "pip install . nose mock"
|
||||
script: nosetests
|
||||
|
|
@ -1 +0,0 @@
|
|||
include README.markdown LICENSE
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
# Python Markdown oEmbed
|
||||
|
||||
[](https://travis-ci.org/rennat/python-markdown-oembed)
|
||||
|
||||
Markdown extension to allow media embedding using the oEmbed standard.
|
||||
|
||||
## Installation
|
||||
|
||||
pip install python-markdown-oembed
|
||||
|
||||
## Usage
|
||||
|
||||
>>> import markdown
|
||||
>>> md = markdown.Markdown(extensions=['oembed'])
|
||||
>>> md.convert('')
|
||||
u'<iframe width="459" height="344" src="http://www.youtube.com/embed/zqnh_YJBvOI?fs=1&feature=oembed" frameborder="0" allowfullscreen></iframe>'
|
||||
|
||||
## Links
|
||||
|
||||
- [python-markdown-oembed](https://github.com/rennat/python-markdown-oembed)
|
||||
- [Markdown](http://daringfireball.net/projects/markdown/)
|
||||
- [oEmbed](http://www.oembed.com/)
|
||||
- [python-oembed](https://github.com/abarmat/python-oembed)
|
||||
|
||||
## License
|
||||
|
||||
A Public Domain work. Do as you wish.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 0.2.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
|
||||
112
README.md
Normal file
112
README.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# 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('')
|
||||
```
|
||||
|
||||
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
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from mdx_oembed.extension import OEmbedExtension
|
||||
from mdx_oembed.version import __version__
|
||||
|
||||
VERSION = __version__
|
||||
|
||||
__all__ = ["OEmbedExtension", "VERSION", "__version__", "makeExtension"]
|
||||
|
||||
|
||||
VERSION = '0.2.0'
|
||||
|
||||
|
||||
def makeExtension(**kwargs):
|
||||
def makeExtension(**kwargs: object) -> OEmbedExtension: # noqa: N802
|
||||
return OEmbedExtension(**kwargs)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import oembed
|
||||
from __future__ import annotations
|
||||
|
||||
YOUTUBE = oembed.OEmbedEndpoint('http://www.youtube.com/oembed', [
|
||||
'https?://(*.)?youtube.com/*',
|
||||
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/*',
|
||||
])
|
||||
|
||||
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/*',
|
||||
])
|
||||
|
||||
VIMEO = oembed.OEmbedEndpoint('http://vimeo.com/api/oembed.json', [
|
||||
VIMEO = OEmbedEndpoint('https://vimeo.com/api/oembed.json', [
|
||||
'https?://vimeo.com/*',
|
||||
])
|
||||
|
||||
DEFAULT_ENDPOINTS = [
|
||||
YOUTUBE,
|
||||
FLICKR,
|
||||
VIMEO
|
||||
VIMEO,
|
||||
SLIDESHARE,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from markdown import Extension
|
||||
import oembed
|
||||
|
||||
from mdx_oembed.endpoints import DEFAULT_ENDPOINTS
|
||||
from mdx_oembed.inlinepatterns import OEmbedLinkPattern, OEMBED_LINK_RE
|
||||
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, OEmbedLinkPattern
|
||||
from mdx_oembed.oembed import OEmbedConsumer
|
||||
|
||||
|
||||
class OEmbedExtension(Extension):
|
||||
|
|
@ -11,24 +13,29 @@ class OEmbedExtension(Extension):
|
|||
self.config = {
|
||||
'allowed_endpoints': [
|
||||
DEFAULT_ENDPOINTS,
|
||||
"A list of oEmbed endpoints to allow. Defaults to "
|
||||
"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(OEmbedExtension, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
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 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 prepare_oembed_consumer(self):
|
||||
def _prepare_oembed_consumer(self):
|
||||
allowed_endpoints = self.getConfig('allowed_endpoints', DEFAULT_ENDPOINTS)
|
||||
consumer = oembed.OEmbedConsumer()
|
||||
|
||||
if allowed_endpoints:
|
||||
for endpoint in allowed_endpoints:
|
||||
consumer.addEndpoint(endpoint)
|
||||
|
||||
consumer = OEmbedConsumer()
|
||||
for endpoint in (allowed_endpoints or []):
|
||||
consumer.add_endpoint(endpoint)
|
||||
return consumer
|
||||
|
|
|
|||
|
|
@ -1,36 +1,135 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from markdown.inlinepatterns import Pattern
|
||||
import oembed
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import logging
|
||||
from os.path import splitext
|
||||
from urllib.parse import urlparse
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import markdown
|
||||
import nh3
|
||||
from markdown.inlinepatterns import InlineProcessor
|
||||
|
||||
from mdx_oembed.oembed import OEmbedConsumer, OEmbedNoEndpoint
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# Image extensions to exclude from oEmbed processing
|
||||
_IMAGE_EXTENSIONS = frozenset({
|
||||
".png", ".jpg", ".jpeg", ".gif", ".avif", ".webp",
|
||||
".svg", ".bmp", ".tiff", ".ico",
|
||||
})
|
||||
|
||||
OEMBED_LINK_RE = r'\!\[([^\]]*)\]\(((?:https?:)?//[^\)]*)' \
|
||||
r'(?<!png)(?<!jpg)(?<!jpeg)(?<!gif)\)'
|
||||
# Matches Markdown image syntax with an absolute URL: 
|
||||
OEMBED_LINK_RE = r"!\[([^\]]*)\]\(((?:https?:)?//[^\)]+)\)"
|
||||
|
||||
# Allowed HTML tags and attributes for sanitizing oEmbed responses
|
||||
_SANITIZE_TAGS = {
|
||||
"iframe", "video", "audio", "source", "img",
|
||||
"blockquote", "div", "p", "a", "span", "figure",
|
||||
}
|
||||
_SANITIZE_ATTRS = {
|
||||
"*": {"class", "style", "title"},
|
||||
"iframe": {
|
||||
"src", "width", "height", "frameborder",
|
||||
"allowfullscreen", "allow", "referrerpolicy", "sandbox",
|
||||
},
|
||||
"video": {
|
||||
"src", "width", "height", "controls",
|
||||
"autoplay", "loop", "muted", "poster", "preload",
|
||||
},
|
||||
"audio": {
|
||||
"src", "controls", "autoplay", "loop", "muted", "preload",
|
||||
},
|
||||
"source": {"src", "type"},
|
||||
"img": {"src", "alt", "width", "height", "loading"},
|
||||
"a": {"href", "target"},
|
||||
}
|
||||
|
||||
|
||||
class OEmbedLinkPattern(Pattern):
|
||||
def _is_image_url(url: str) -> bool:
|
||||
"""Check if a URL points to an image based on its path extension."""
|
||||
try:
|
||||
path = urlparse(url).path
|
||||
_, ext = splitext(path)
|
||||
return ext.lower() in _IMAGE_EXTENSIONS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def __init__(self, pattern, markdown_instance=None, oembed_consumer=None):
|
||||
Pattern.__init__(self, pattern, markdown_instance)
|
||||
|
||||
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)
|
||||
self.consumer = oembed_consumer
|
||||
self.wrapper_class = wrapper_class
|
||||
|
||||
def handleMatch(self, match):
|
||||
html = self.get_oembed_html_for_match(match)
|
||||
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)
|
||||
if html is None:
|
||||
return None
|
||||
else:
|
||||
html = "<figure class=\"oembed\">%s</figure>" % html
|
||||
placeholder = self.markdown.htmlStash.store(html, True)
|
||||
return placeholder
|
||||
return None, None, None
|
||||
|
||||
def get_oembed_html_for_match(self, match):
|
||||
url = match.group(3).strip()
|
||||
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
|
||||
try:
|
||||
response = self.consumer.embed(url)
|
||||
except oembed.OEmbedNoEndpoint:
|
||||
except OEmbedNoEndpoint:
|
||||
LOG.warning("No oEmbed endpoint for URL: %s", url)
|
||||
return None
|
||||
else:
|
||||
return response['html']
|
||||
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
|
||||
|
|
|
|||
181
mdx_oembed/oembed.py
Normal file
181
mdx_oembed/oembed.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Minimal oEmbed consumer — replaces the python-oembed dependency.
|
||||
|
||||
Implements just the subset used by this extension:
|
||||
- OEmbedEndpoint: pairs an API URL with URL-glob patterns
|
||||
- OEmbedConsumer: resolves a URL against registered endpoints and
|
||||
fetches the oEmbed JSON response
|
||||
- OEmbedError / OEmbedNoEndpoint: exception hierarchy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import warnings
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from mdx_oembed.version import __version__
|
||||
|
||||
__all__ = [
|
||||
"OEmbedEndpoint",
|
||||
"OEmbedConsumer",
|
||||
"OEmbedError",
|
||||
"OEmbedNoEndpoint",
|
||||
"REQUEST_TIMEOUT",
|
||||
]
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# Default timeout (seconds) for outbound oEmbed HTTP requests.
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
_USER_AGENT = f"python-markdown-oembed/{__version__}"
|
||||
|
||||
# Pre-compiled regex for the ``https?://`` scheme shorthand used in oEmbed
|
||||
# URL patterns. Kept at module level to avoid re-creation on every call.
|
||||
_SCHEME_RE = re.compile(r"https\?://")
|
||||
_SCHEME_PLACEHOLDER = "__SCHEME__"
|
||||
|
||||
|
||||
# -- Exceptions -------------------------------------------------------------
|
||||
|
||||
class OEmbedError(Exception):
|
||||
"""Base exception for oEmbed errors."""
|
||||
|
||||
|
||||
class OEmbedNoEndpoint(OEmbedError): # noqa: N818
|
||||
"""Raised when no registered endpoint matches the requested URL."""
|
||||
|
||||
|
||||
# -- Endpoint ---------------------------------------------------------------
|
||||
|
||||
class OEmbedEndpoint:
|
||||
"""An oEmbed provider endpoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_url:
|
||||
The provider's oEmbed API URL (e.g. ``https://www.youtube.com/oembed``).
|
||||
url_patterns:
|
||||
Shell-style glob patterns (with ``https?://`` shorthand) that describe
|
||||
which content URLs this endpoint handles. The ``?`` in ``https?``
|
||||
is treated specially: it makes the preceding ``s`` optional so a single
|
||||
pattern can match both ``http`` and ``https``.
|
||||
"""
|
||||
|
||||
def __init__(self, api_url: str, url_patterns: list[str]) -> None:
|
||||
self.api_url = api_url
|
||||
self.url_patterns = url_patterns
|
||||
self._regexes: list[re.Pattern[str]] = [
|
||||
self._compile(p) for p in url_patterns
|
||||
]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"OEmbedEndpoint({self.api_url!r}, {self.url_patterns!r})"
|
||||
|
||||
# -- internal helpers ----------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _compile(pattern: str) -> re.Pattern[str]:
|
||||
"""Convert a URL-glob pattern to a compiled regex.
|
||||
|
||||
Handles the ``https?://`` convention used by oEmbed providers:
|
||||
the ``s`` before ``?`` is made optional *before* the rest of the
|
||||
pattern is translated via `fnmatch`.
|
||||
"""
|
||||
converted = _SCHEME_RE.sub(_SCHEME_PLACEHOLDER, pattern)
|
||||
# fnmatch.translate anchors with \\A … \\Z and handles */?/[] globs.
|
||||
regex = fnmatch.translate(converted)
|
||||
# Put the scheme alternation back.
|
||||
regex = regex.replace(_SCHEME_PLACEHOLDER, r"https?://")
|
||||
return re.compile(regex, re.IGNORECASE)
|
||||
|
||||
def matches(self, url: str) -> bool:
|
||||
"""Return True if *url* matches any of this endpoint's patterns."""
|
||||
return any(r.match(url) for r in self._regexes)
|
||||
|
||||
|
||||
# -- Consumer ---------------------------------------------------------------
|
||||
|
||||
class OEmbedConsumer:
|
||||
"""Registry of `OEmbedEndpoint` objects that can resolve arbitrary URLs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
timeout:
|
||||
HTTP request timeout in seconds. Defaults to :data:`REQUEST_TIMEOUT`.
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: int = REQUEST_TIMEOUT) -> None:
|
||||
self._endpoints: list[OEmbedEndpoint] = []
|
||||
self.timeout = timeout
|
||||
|
||||
def __repr__(self) -> str:
|
||||
names = [ep.api_url for ep in self._endpoints]
|
||||
return f"OEmbedConsumer(endpoints={names!r})"
|
||||
|
||||
def add_endpoint(self, endpoint: OEmbedEndpoint) -> None:
|
||||
"""Register an oEmbed endpoint."""
|
||||
self._endpoints.append(endpoint)
|
||||
|
||||
def addEndpoint(self, endpoint: OEmbedEndpoint) -> None: # noqa: N802
|
||||
"""Deprecated alias for :meth:`add_endpoint`."""
|
||||
warnings.warn(
|
||||
"addEndpoint() is deprecated, use add_endpoint() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self.add_endpoint(endpoint)
|
||||
|
||||
def embed(self, url: str) -> dict[str, Any]:
|
||||
"""Fetch the oEmbed response for *url*.
|
||||
|
||||
Returns the parsed JSON as a ``dict``.
|
||||
|
||||
Raises
|
||||
------
|
||||
OEmbedNoEndpoint
|
||||
If none of the registered endpoints match *url*.
|
||||
OEmbedError
|
||||
On HTTP or JSON-parsing failures.
|
||||
"""
|
||||
endpoint = self._find_endpoint(url)
|
||||
if endpoint is None:
|
||||
raise OEmbedNoEndpoint(f"No oEmbed endpoint registered for {url}")
|
||||
return self._fetch(endpoint, url)
|
||||
|
||||
# -- internal helpers ----------------------------------------------------
|
||||
|
||||
def _find_endpoint(self, url: str) -> OEmbedEndpoint | None:
|
||||
for ep in self._endpoints:
|
||||
if ep.matches(url):
|
||||
return ep
|
||||
return None
|
||||
|
||||
def _fetch(self, endpoint: OEmbedEndpoint, content_url: str) -> dict[str, Any]:
|
||||
params = urlencode({"url": content_url, "format": "json"})
|
||||
api_url = f"{endpoint.api_url}?{params}"
|
||||
request = Request(api_url, headers={ # noqa: S310
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
})
|
||||
LOG.debug("Fetching oEmbed: %s", api_url)
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as resp: # noqa: S310
|
||||
if resp.status is not None and not (200 <= resp.status < 300):
|
||||
raise OEmbedError(
|
||||
f"oEmbed request for {content_url} returned HTTP {resp.status}"
|
||||
)
|
||||
charset = resp.headers.get_content_charset() or "utf-8"
|
||||
data: dict[str, Any] = json.loads(resp.read().decode(charset))
|
||||
except OEmbedError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise OEmbedError(
|
||||
f"Failed to fetch oEmbed for {content_url}: {exc}"
|
||||
) from exc
|
||||
return data
|
||||
0
mdx_oembed/py.typed
Normal file
0
mdx_oembed/py.typed
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = "0.5.0"
|
||||
77
pyproject.toml
Normal file
77
pyproject.toml
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
[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"
|
||||
47
setup.py
47
setup.py
|
|
@ -1,47 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
try:
|
||||
from setuptools import setup
|
||||
except ImportError:
|
||||
from distutils.core import setup
|
||||
|
||||
|
||||
try:
|
||||
with open('README.markdown', 'r') as readme:
|
||||
LONG_DESCRIPTION = readme.read()
|
||||
except Exception:
|
||||
LONG_DESCRIPTION = None
|
||||
|
||||
|
||||
setup(
|
||||
name='python-markdown-oembed',
|
||||
version='0.2.0',
|
||||
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.6.1",
|
||||
],
|
||||
|
||||
test_suite='nose.collector',
|
||||
tests_require=[
|
||||
'nose',
|
||||
'mock'
|
||||
]
|
||||
)
|
||||
559
tests.py
559
tests.py
|
|
@ -1,182 +1,415 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Tests for python-markdown-oembed extension."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
import warnings
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import markdown
|
||||
from mock import patch
|
||||
from nose.plugins.skip import SkipTest
|
||||
from mdx_oembed.extension import OEMBED_LINK_RE
|
||||
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)
|
||||
|
||||
|
||||
class OEmbedPatternRegexTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.re = re.compile(OEMBED_LINK_RE)
|
||||
|
||||
def test_ignore_relative_image_link(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNone(match)
|
||||
|
||||
def test_ignore_absolute_image_link(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNone(match)
|
||||
|
||||
def test_ignore_png_image_link(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNone(match)
|
||||
|
||||
def test_ignore_jpg_image_link(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNone(match)
|
||||
|
||||
def test_ignore_gif_image_link(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNone(match)
|
||||
|
||||
def test_find_youtube_link(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNotNone(match)
|
||||
|
||||
def test_find_youtube_short_link(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNotNone(match)
|
||||
|
||||
def test_find_youtube_http(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNotNone(match)
|
||||
|
||||
def test_find_youtube_https(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNotNone(match)
|
||||
|
||||
def test_find_youtube_auto(self):
|
||||
text = ''
|
||||
match = self.re.match(text)
|
||||
self.assertIsNotNone(match)
|
||||
def test_ignore_relative_image_link():
|
||||
assert _OEMBED_RE.search("") is None
|
||||
|
||||
|
||||
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)
|
||||
def test_match_absolute_url():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
|
||||
class IgnoredTestCase(OEmbedExtensionTestCase):
|
||||
"""
|
||||
The OEmbedExtension should ignore these tags allowing markdown's image
|
||||
processor to find and handle them.
|
||||
"""
|
||||
|
||||
def test_relative(self):
|
||||
text = ''
|
||||
expected = '<p><img alt="alt" src="image.png" /></p>'
|
||||
output = self.markdown.convert(text)
|
||||
self.assertEqual(output, expected)
|
||||
|
||||
def test_slash_relative(self):
|
||||
text = ''
|
||||
expected = '<p><img alt="alt" src="/image.png" /></p>'
|
||||
output = self.markdown.convert(text)
|
||||
self.assertEqual(output, expected)
|
||||
|
||||
def test_absolute(self):
|
||||
text = ''
|
||||
expected = '<p><img alt="Mumbo Jumbo" src="http://tannern.com/mumbo-jumbo.jpg" /></p>'
|
||||
output = self.markdown.convert(text)
|
||||
self.assertEqual(output, expected)
|
||||
def test_match_youtube_link():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
assert m.group(2) == "http://www.youtube.com/watch?v=ABC"
|
||||
|
||||
|
||||
class ProtocolVarietyTestCase(OEmbedExtensionTestCase):
|
||||
|
||||
def test_http(self):
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertIn('<iframe', output)
|
||||
|
||||
def test_https(self):
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertIn('<iframe', output)
|
||||
|
||||
def test_auto(self):
|
||||
raise SkipTest()
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertIn('<iframe', output)
|
||||
def test_match_youtube_short_link():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
|
||||
class YoutubeTestCase(OEmbedExtensionTestCase):
|
||||
"""
|
||||
The OEmbedExtension should handle embedding for these cases.
|
||||
"""
|
||||
|
||||
def test_youtube_link(self):
|
||||
"""
|
||||
YouTube video link.
|
||||
"""
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertIn('<iframe', output)
|
||||
|
||||
def test_youtube_short_link(self):
|
||||
"""
|
||||
Short format YouTube video link.
|
||||
"""
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertIn('<iframe', output)
|
||||
def test_match_https():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
|
||||
class VimeoTestCase(OEmbedExtensionTestCase):
|
||||
|
||||
def test_vimeo_link(self):
|
||||
"""
|
||||
Vimeo video link.
|
||||
"""
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertIn('<iframe', output)
|
||||
def test_match_protocol_relative():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
|
||||
class LimitedOEmbedExtensionTestCase(OEmbedExtensionTestCase):
|
||||
def setUp(self):
|
||||
self.markdown = markdown.Markdown(
|
||||
extensions=['oembed'],
|
||||
def test_alt_text_captured():
|
||||
m = _OEMBED_RE.search("")
|
||||
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("")
|
||||
assert "<iframe" in output
|
||||
assert "oembed" in output # wrapper class
|
||||
|
||||
|
||||
def test_vimeo_embed():
|
||||
output = _convert("")
|
||||
assert "<iframe" in output
|
||||
|
||||
|
||||
# --- images pass through ---
|
||||
|
||||
|
||||
def test_image_png_passthrough():
|
||||
output = _convert("")
|
||||
assert "<img" in output
|
||||
|
||||
|
||||
def test_image_jpg_passthrough():
|
||||
output = _convert("")
|
||||
assert "<img" in output
|
||||
|
||||
|
||||
def test_image_with_query_passthrough():
|
||||
output = _convert("")
|
||||
assert "<img" in output
|
||||
|
||||
|
||||
def test_image_uppercase_passthrough():
|
||||
output = _convert("")
|
||||
assert "<img" in output
|
||||
|
||||
|
||||
# --- relative images are unaffected ---
|
||||
|
||||
|
||||
def test_relative_image():
|
||||
output = _convert("")
|
||||
assert '<img alt="alt" src="image.png"' in output
|
||||
|
||||
|
||||
def test_slash_relative_image():
|
||||
output = _convert("")
|
||||
assert '<img alt="alt" src="/image.png"' in output
|
||||
|
||||
|
||||
# --- photo type response ---
|
||||
|
||||
|
||||
def test_photo_type_response():
|
||||
consumer = _make_photo_consumer()
|
||||
output = _convert("", 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(
|
||||
"", consumer
|
||||
)
|
||||
# The & in the photo URL must be escaped as & in the src attribute
|
||||
assert "&" in output
|
||||
# The " in the photo URL must be escaped (nh3 may use " or ")
|
||||
assert 'b=2"' not in output
|
||||
|
||||
|
||||
# --- error handling ---
|
||||
|
||||
|
||||
def test_no_endpoint_falls_through():
|
||||
consumer = _make_failing_consumer(OEmbedNoEndpoint)
|
||||
output = _convert("", consumer)
|
||||
assert "<iframe" not in output
|
||||
|
||||
|
||||
def test_network_error_falls_through():
|
||||
consumer = _make_failing_consumer(Exception, "timeout")
|
||||
output = _convert("", consumer)
|
||||
assert "<iframe" not in output
|
||||
|
||||
|
||||
# --- configuration ---
|
||||
|
||||
|
||||
def test_custom_wrapper_class():
|
||||
output = _convert(
|
||||
"",
|
||||
wrapper_class="embed-responsive",
|
||||
)
|
||||
assert "embed-responsive" in output
|
||||
|
||||
|
||||
def test_empty_wrapper_class():
|
||||
output = _convert(
|
||||
"",
|
||||
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("", evil_consumer)
|
||||
assert "<script" not in output
|
||||
assert "<iframe" in output
|
||||
|
||||
|
||||
# --- multiple links ---
|
||||
|
||||
|
||||
def test_multiple_embeds():
|
||||
text = (
|
||||
"\n\n"
|
||||
""
|
||||
)
|
||||
output = _convert(text)
|
||||
assert output.count("<iframe") == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Limited endpoints configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_youtube_only_endpoint():
|
||||
def side_effect(url: str) -> MagicMock:
|
||||
if "youtube" in url:
|
||||
resp = MagicMock()
|
||||
data = {"html": "<iframe src='yt'></iframe>", "type": "video"}
|
||||
resp.get = lambda key, default=None: data.get(key, default)
|
||||
resp.__getitem__ = lambda self_inner, key: data[key]
|
||||
return resp
|
||||
raise OEmbedNoEndpoint("nope")
|
||||
|
||||
consumer = MagicMock()
|
||||
consumer.embed.side_effect = side_effect
|
||||
|
||||
with patch("mdx_oembed.extension.OEmbedConsumer", return_value=consumer):
|
||||
md = markdown.Markdown(
|
||||
extensions=["oembed"],
|
||||
extension_configs={
|
||||
'oembed': {
|
||||
'allowed_endpoints': [endpoints.YOUTUBE],
|
||||
}
|
||||
})
|
||||
"oembed": {"allowed_endpoints": [endpoints.YOUTUBE]},
|
||||
},
|
||||
)
|
||||
yt_output = md.convert("")
|
||||
assert "<iframe" in yt_output
|
||||
|
||||
def test_youtube_link(self):
|
||||
"""
|
||||
YouTube video link.
|
||||
"""
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertIn('<iframe', output)
|
||||
|
||||
def test_vimeo_link(self):
|
||||
"""
|
||||
Vimeo video link.
|
||||
"""
|
||||
text = ''
|
||||
output = self.markdown.convert(text)
|
||||
self.assertNotIn('<iframe', output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
md.reset()
|
||||
vim_output = md.convert("")
|
||||
assert "<iframe" not in vim_output
|
||||
|
|
|
|||
205
uv.lock
Normal file
205
uv.lock
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
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" },
|
||||
]
|
||||
Loading…
Reference in a new issue