Compare commits

...

48 commits

Author SHA1 Message Date
Benedikt Willi
bdcafbf4c6 Update README for version 0.5.0: require Python >= 3.12, enhance installation instructions, and add changelog details 2026-03-03 15:37:31 +01:00
Benedikt Willi
9e48edcfdf Refactor and Update python-markdown-oembed-extension
- **Removed legacy build files**:
  - Deleted obsolete `.travis.yml`, `flake.lock`, and `flake.nix` files that were no longer needed for the current build and dependency management setup.

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

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

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

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

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

These changes focus on modernizing the codebase, improving adherence to current Python standards, and enhancing overall functionality and maintainability.
2026-03-03 14:26:52 +01:00
Benedikt Willi
6864af69fb Reorder authors in pyproject.toml to place Benedikt Willi first 2026-03-03 10:46:03 +01:00
Benedikt Willi
428883fd99 Rename project to "python-markdown-oembed-extension" and update homepage URL 2026-03-03 09:25:20 +01:00
Benedikt Willi
7e41514005 Bump version to 0.4.0 and update changelog; clean up tests 2026-03-02 17:44:08 +01:00
Benedikt Willi
dd567e7013 Merge branch 'master' of https://github.com/Hopiu/python-markdown-oembed 2026-03-02 17:35:20 +01:00
Benedikt Willi
20f2880e07 Refactor and modernize codebase. 2026-03-02 17:25:43 +01:00
Benedikt Willi
f3963e7ad5
Update __init__.py 2023-11-07 16:40:00 +01:00
Benedikt Willi
22e7f76d56
Update __init__.py 2023-11-07 16:33:46 +01:00
Benedikt Willi
6df30f7cf3
Merge pull request #1 from durgaBahadur/master
use flit packaging tool, write simple test
2023-11-07 11:19:20 +01:00
cristoffel
bb5eb5445e use flit packaging tool, write simple test 2023-11-06 18:58:32 +01:00
Benedikt Willi
79c7c2f060 Use f-strings, add missing classes. 2023-08-08 15:58:56 +02:00
Benedikt Willi
745056d5e6
Update extension.py 2022-08-16 08:19:59 +02:00
Benedikt Willi
0e3bf49c86
Update inlinepatterns.py 2022-08-16 08:18:53 +02:00
Benedikt Willi
477d04541c
Update inlinepatterns.py 2022-08-15 17:18:39 +02:00
Benedikt Willi
d216b6eb68
Update inlinepatterns.py 2022-08-15 16:41:35 +02:00
Benedikt Willi
e5d64d6853
Update inlinepatterns.py 2022-08-15 16:21:34 +02:00
W.A.Flozart
cb46ae8f8a
Merge pull request #3 from Hopiu/patch-3
Update extension.py
2022-08-08 13:19:31 +02:00
Benedikt Willi
a665517a5d
Update extension.py 2022-08-08 13:18:50 +02:00
W.A.Flozart
ffbac50ce0
Merge pull request #2 from Hopiu/patch-2
Update extension.py
2022-08-08 11:37:11 +02:00
Benedikt Willi
cdc3a41260
Update extension.py 2022-08-08 11:33:25 +02:00
W.A.Flozart
7fb4cb2aa1
Update endpoints.py 2022-08-02 14:13:40 +02:00
W.A.Flozart
5d6b341f8a
Update endpoints.py 2021-12-08 15:07:57 +01:00
W.A.Flozart
d510e7f081
Merge pull request #1 from Hopiu/master
Thanks for the fix, Hopi!  
Merging fists into inlinepatterns.py
2020-04-07 14:43:20 +02:00
Benedikt Willi
c1088492e0
Update inlinepatterns.py 2020-04-07 14:37:38 +02:00
Floroni
8768a5a546 removed True argument from store() as it raised a 3 arguments given error 2019-03-06 12:22:20 +01:00
Tanner Netterville
81a8378564 version bump for 0.2.1 and wheel setup 2016-04-22 00:17:51 -05:00
Tanner Netterville
8d8dd3d21b Merge branch 'anantshri-patch-1' 2016-04-22 00:15:56 -05:00
Anant Shrivastava
78645522ee Slideshare test case 2016-04-14 14:35:22 +05:30
Anant Shrivastava
d2d5632f4f Update endpoints.py 2016-04-14 08:53:55 +05:30
Anant Shrivastava
9e11630327 Adding slideshare as a oembed provider
Adding slideshare as a oembed provider
2016-04-14 08:46:26 +05:30
Tanner Netterville
029e1cd4f2 Merge pull request #5 from rennat/0.2.0
0.2.0
2016-02-16 05:08:49 -06:00
Tanner Netterville
87bbf8fc90 Release 0.2.0
- Merged improvements from Wenzil's fork. Thanks Wenzil!
- Update for new version of Markdown breaks backwards compatability.
- Dropped test support for Python 2.6
- Fixed test suite (including Travis CI config)
2016-02-16 05:04:24 -06:00
Tanner Netterville
7d5a0ef503 Merge remote-tracking branch 'wenzil/master' into 0.2.0 2016-02-16 05:02:02 -06:00
Tanner Netterville
784a7a0189 update setup.py test requirements 2015-04-21 13:03:05 -05:00
Tanner Netterville
12515f4907 Add Travis CI config 2015-04-21 12:32:52 -05:00
Sami Turcotte
95d2797cfa Fix syntax error 2015-04-21 05:23:39 -04:00
Sami Turcotte
d8b37231ac Version bump 2015-04-21 05:19:19 -04:00
Sami Turcotte
e2e29489d7 Allow arbitrary oEmbed endpoints
Before this change, only three hardcoded oEmbed endpoints were available
for users of the extension; youtube, flickr and vimeo. In the
configuration, it was only possible to pass a subset of those 3 oEmbed
endpoints. It was done by passing the names of the allowed endpoints.

With this change, the API has changed. The allowed_endpoints kwarg now
expects to be passed a list of OEmbedEndpoint objects (as opposed to
names). This means the responsibility of creating OEmbedEndpoint objects
has been shifted to the user of the extension. If the allowed_endpoints
kwarg is omitted, the default oEmbed endpoints used will be the same as
before; youtube, flickr and vimeo.

The motivation is to allow arbitrary oEmbed endpoints, without
necessitating anyone to maintain the list of all possible oEmbed
endpoints out there.
2015-04-21 05:18:42 -04:00
Sami Turcotte
350d90b6b9 Version bump 2015-04-20 00:44:21 -04:00
Sami Turcotte
639d4a6dec Change div wrapper to a figure wrapper
The reasoning is that the translation of Markdown into HTML should be as
concise as possible. The semantics of the figure element seem to fit the
embedded nature of OEmbed content moreso than the div element which is
quite generic.
2015-04-20 00:42:50 -04:00
Sami Turcotte
0e3aaac58c version bump 2015-04-04 03:34:33 -04:00
Sami Turcotte
f3f09aae98 Wrap oEmbed html output in a div
To keep a constant aspect ratio for the resulting iframe of an oEmbed
request in a responsive website, the iframe needs a div wrapper. With
this change, html results are wrapped in a "oembed" class div.
2015-04-04 03:33:33 -04:00
Sami Turcotte
7f12354970 Version bump 2015-04-03 01:51:34 -04:00
Sami Turcotte
3adab335ec Add support for python-markdown safe mode
Mark the resulting OEmbed html tags as safe so they don't get stripped
away in safe mode.
2015-04-03 01:22:11 -04:00
Sami Turcotte
4a7f2a4d46 Version bump 2015-04-02 03:48:18 -04:00
Sami Turcotte
c3a4466eca Reconcile with Markdown 2.6
Updated the initialization of OEmbedExtension to be compatible with
Markdown 2.6. See https://pythonhosted.org/Markdown/release-2.6.html
2015-04-02 03:47:33 -04:00
J. Tanner Netterville
469e696a95 remove silly import from setup.py 2014-07-23 16:02:57 -05:00
15 changed files with 1173 additions and 297 deletions

8
.gitignore vendored
View file

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

View file

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

View file

@ -1,25 +0,0 @@
# 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 Normal file
View 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('![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,11 +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.1.3'
def makeExtension(configs=None):
if isinstance(configs, list):
configs = dict(configs)
return OEmbedExtension(configs=configs)
def makeExtension(**kwargs: object) -> OEmbedExtension: # noqa: N802
return OEmbedExtension(**kwargs)

View file

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

View file

@ -1,34 +1,41 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from markdown import Extension
import oembed
from mdx_oembed.endpoints import ENDPOINTS
from mdx_oembed.inlinepatterns import OEmbedLinkPattern, OEMBED_LINK_RE
AVAILABLE_ENDPOINTS = ENDPOINTS.keys()
from mdx_oembed.endpoints import DEFAULT_ENDPOINTS
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, OEmbedLinkPattern
from mdx_oembed.oembed import OEmbedConsumer
class OEmbedExtension(Extension):
config = {
'allowed_endpoints': [
AVAILABLE_ENDPOINTS,
"A list of oEmbed endpoints to allow. Possible values are "
"{}.".format(', '.join(AVAILABLE_ENDPOINTS)),
],
}
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)
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):
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]
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)
return consumer

View file

@ -1,34 +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: ![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"},
}
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
placeholder = self.markdown.htmlStash.store(html)
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
View file

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

0
mdx_oembed/py.typed Normal file
View file

View file

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

77
pyproject.toml Normal file
View 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"

View file

@ -1,43 +0,0 @@
#!/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']
)

558
tests.py
View file

@ -1,179 +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 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 = '![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)
def test_ignore_relative_image_link():
assert _OEMBED_RE.search("![image](/image.png)") 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("![img](http://example.com/photo.png)")
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 = '![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)
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"
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)
def test_match_youtube_short_link():
m = _OEMBED_RE.search("![video](http://youtu.be/ABC)")
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 = '![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)
def test_match_https():
m = _OEMBED_RE.search("![video](https://youtu.be/ABC)")
assert m is not None
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)
def test_match_protocol_relative():
m = _OEMBED_RE.search("![video](//youtu.be/ABC)")
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("![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"],
extension_configs={
'oembed': {
'allowed_endpoints': ['youtube',],
}
})
"oembed": {"allowed_endpoints": [endpoints.YOUTUBE]},
},
)
yt_output = md.convert("![v](http://www.youtube.com/watch?v=A)")
assert "<iframe" in yt_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()
md.reset()
vim_output = md.convert("![v](http://vimeo.com/12345)")
assert "<iframe" not in vim_output

205
uv.lock Normal file
View 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" },
]