mirror of
https://github.com/Hopiu/python-markdown-oembed.git
synced 2026-05-04 05:14:43 +00:00
Compare commits
4 commits
7e41514005
...
bdcafbf4c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdcafbf4c6 | ||
|
|
9e48edcfdf | ||
|
|
6864af69fb | ||
|
|
428883fd99 |
20 changed files with 707 additions and 579 deletions
|
|
@ -1,9 +0,0 @@
|
|||
language: python
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.2"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
install: "pip install . nose mock"
|
||||
script: nosetests
|
||||
20
README.md
20
README.md
|
|
@ -4,16 +4,16 @@ Markdown extension to allow media embedding using the oEmbed standard.
|
|||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.9
|
||||
- Python >= 3.12
|
||||
- Markdown >= 3.2
|
||||
|
||||
## Installation
|
||||
|
||||
pip install python-markdown-oembed
|
||||
pip install python-markdown-oembed-extension
|
||||
|
||||
Or with [uv](https://docs.astral.sh/uv/):
|
||||
|
||||
uv add python-markdown-oembed
|
||||
uv add python-markdown-oembed-extension
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -71,6 +71,20 @@ 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
|
||||
|
|
|
|||
27
flake.lock
27
flake.lock
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1698611440,
|
||||
"narHash": "sha256-jPjHjrerhYDy3q9+s5EAsuhyhuknNfowY6yt6pjn9pc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0cbe9f69c234a7700596e943bfae7ef27a31b735",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
29
flake.nix
29
flake.nix
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
description = "Oembed plugin flake";
|
||||
inputs = {
|
||||
nixpkgs.url = github:NixOS/nixpkgs/nixos-unstable;
|
||||
};
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [];
|
||||
};
|
||||
pythonPackages = pkgs.python3Packages;
|
||||
system = "x86_64-linux";
|
||||
in rec {
|
||||
devShell.x86_64-linux = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.python3
|
||||
pkgs.python3Packages.pip
|
||||
];
|
||||
shellHook = ''
|
||||
export PS1='\u@md-oembed \$ '
|
||||
export PIP_PREFIX=$(pwd)/venv/pip_packages
|
||||
export PYTHONPATH="$PIP_PREFIX/${pkgs.python3.sitePackages}:$PYTHONPATH"
|
||||
export PATH="$PIP_PREFIX/bin:$PATH"
|
||||
unset SOURCE_DATE_EPOCH
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from mdx_oembed.extension import OEmbedExtension
|
||||
from mdx_oembed.version import __version__
|
||||
|
||||
VERSION = __version__
|
||||
|
||||
__all__ = ["OEmbedExtension", "VERSION", "__version__", "makeExtension"]
|
||||
|
||||
def makeExtension(**kwargs):
|
||||
|
||||
def makeExtension(**kwargs: object) -> OEmbedExtension: # noqa: N802
|
||||
return OEmbedExtension(**kwargs)
|
||||
|
|
|
|||
34
mdx_oembed/endpoints.py
Normal file
34
mdx_oembed/endpoints.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from mdx_oembed.oembed import OEmbedEndpoint
|
||||
|
||||
# URL patterns use shell-style globs with an "https?://" shorthand
|
||||
# that matches both http and https schemes.
|
||||
|
||||
YOUTUBE = OEmbedEndpoint('https://www.youtube.com/oembed', [
|
||||
'https?://*.youtube.com/*',
|
||||
'https?://youtu.be/*',
|
||||
])
|
||||
|
||||
SLIDESHARE = OEmbedEndpoint('https://www.slideshare.net/api/oembed/2', [
|
||||
'https?://www.slideshare.net/*/*',
|
||||
'https?://fr.slideshare.net/*/*',
|
||||
'https?://de.slideshare.net/*/*',
|
||||
'https?://es.slideshare.net/*/*',
|
||||
'https?://pt.slideshare.net/*/*',
|
||||
])
|
||||
|
||||
FLICKR = OEmbedEndpoint('https://www.flickr.com/services/oembed/', [
|
||||
'https?://*.flickr.com/*',
|
||||
])
|
||||
|
||||
VIMEO = OEmbedEndpoint('https://vimeo.com/api/oembed.json', [
|
||||
'https?://vimeo.com/*',
|
||||
])
|
||||
|
||||
DEFAULT_ENDPOINTS = [
|
||||
YOUTUBE,
|
||||
FLICKR,
|
||||
VIMEO,
|
||||
SLIDESHARE,
|
||||
]
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from markdown import Extension
|
||||
import oembed
|
||||
from python_markdown_oembed_extension.endpoints import DEFAULT_ENDPOINTS
|
||||
from python_markdown_oembed_extension.inlinepatterns import OEmbedLinkPattern, OEMBED_LINK_RE
|
||||
|
||||
from mdx_oembed.endpoints import DEFAULT_ENDPOINTS
|
||||
from mdx_oembed.inlinepatterns import OEMBED_LINK_RE, OEmbedLinkPattern
|
||||
from mdx_oembed.oembed import OEmbedConsumer
|
||||
|
||||
|
||||
class OEmbedExtension(Extension):
|
||||
|
|
@ -21,7 +24,7 @@ class OEmbedExtension(Extension):
|
|||
}
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def extendMarkdown(self, md):
|
||||
def extendMarkdown(self, md): # noqa: N802
|
||||
consumer = self._prepare_oembed_consumer()
|
||||
wrapper_class = self.getConfig('wrapper_class', 'oembed')
|
||||
link_pattern = OEmbedLinkPattern(
|
||||
|
|
@ -32,8 +35,7 @@ class OEmbedExtension(Extension):
|
|||
|
||||
def _prepare_oembed_consumer(self):
|
||||
allowed_endpoints = self.getConfig('allowed_endpoints', DEFAULT_ENDPOINTS)
|
||||
consumer = oembed.OEmbedConsumer()
|
||||
consumer = OEmbedConsumer()
|
||||
for endpoint in (allowed_endpoints or []):
|
||||
consumer.addEndpoint(endpoint)
|
||||
consumer.add_endpoint(endpoint)
|
||||
return consumer
|
||||
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
import logging
|
||||
from posixpath import splitext
|
||||
from urllib.parse import urlparse
|
||||
from __future__ import annotations
|
||||
|
||||
import nh3
|
||||
import oembed
|
||||
from markdown.inlinepatterns import InlineProcessor
|
||||
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
|
||||
|
|
@ -19,12 +24,23 @@ _IMAGE_EXTENSIONS = frozenset({
|
|||
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_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"},
|
||||
"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"},
|
||||
|
|
@ -49,12 +65,18 @@ def _sanitize_html(html: str) -> str:
|
|||
class OEmbedLinkPattern(InlineProcessor):
|
||||
"""Inline processor that replaces Markdown image links with oEmbed content."""
|
||||
|
||||
def __init__(self, pattern, md=None, oembed_consumer=None, wrapper_class="oembed"):
|
||||
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, m, data):
|
||||
def handleMatch(self, m, data): # noqa: N802
|
||||
url = m.group(2).strip()
|
||||
alt = m.group(1)
|
||||
|
||||
|
|
@ -80,9 +102,12 @@ class OEmbedLinkPattern(InlineProcessor):
|
|||
|
||||
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
|
||||
except Exception:
|
||||
|
|
@ -99,10 +124,11 @@ class OEmbedLinkPattern(InlineProcessor):
|
|||
if photo_url:
|
||||
width = response.get("width", "")
|
||||
height = response.get("height", "")
|
||||
escaped_alt = alt.replace('"', """)
|
||||
return (
|
||||
f'<img src="{photo_url}" alt="{escaped_alt}"'
|
||||
f' width="{width}" height="{height}" />'
|
||||
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)
|
||||
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
|
|
@ -1 +1 @@
|
|||
__version__ = "0.4.0"
|
||||
__version__ = "0.5.0"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
[project]
|
||||
name = "python-markdown-oembed"
|
||||
version = "0.4.0"
|
||||
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.9"
|
||||
requires-python = ">=3.12"
|
||||
authors = [
|
||||
{ name = "Tanner Netterville", email = "tannern@gmail.com" },
|
||||
{ name = "Benedikt Willi", email = "ben.willi@gmail.com" },
|
||||
{ name = "Tanner Netterville", email = "tannern@gmail.com" },
|
||||
]
|
||||
keywords = ["markdown", "oembed"]
|
||||
classifiers = [
|
||||
|
|
@ -15,20 +15,17 @@ classifiers = [
|
|||
"License :: Public Domain",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"python-oembed>=0.2.1",
|
||||
"Markdown>=3.2",
|
||||
"nh3>=0.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/rennat/python-markdown-oembed"
|
||||
Homepage = "https://github.com/Hopiu/python-markdown-oembed"
|
||||
|
||||
[project.entry-points."markdown.extensions"]
|
||||
oembed = "mdx_oembed:makeExtension"
|
||||
|
|
@ -37,6 +34,9 @@ oembed = "mdx_oembed:makeExtension"
|
|||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "mdx_oembed/version.py"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"mdx_oembed/",
|
||||
|
|
@ -52,8 +52,26 @@ packages = ["mdx_oembed"]
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from python_markdown_oembed_extension.oembedextension import OEmbedExtension
|
||||
|
||||
|
||||
VERSION = '0.2.2'
|
||||
|
||||
|
||||
def makeExtension(**kwargs):
|
||||
return OEmbedExtension(**kwargs)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import oembed
|
||||
|
||||
# URL patterns use python-oembed's glob-like syntax, not standard regex.
|
||||
|
||||
YOUTUBE = oembed.OEmbedEndpoint('https://www.youtube.com/oembed', [
|
||||
'https?://(*.)?youtube.com/*',
|
||||
'https?://youtu.be/*',
|
||||
])
|
||||
|
||||
SLIDESHARE = oembed.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 = oembed.OEmbedEndpoint('https://www.flickr.com/services/oembed/', [
|
||||
'https?://*.flickr.com/*',
|
||||
])
|
||||
|
||||
VIMEO = oembed.OEmbedEndpoint('https://vimeo.com/api/oembed.json', [
|
||||
'https?://vimeo.com/*',
|
||||
])
|
||||
|
||||
DEFAULT_ENDPOINTS = [
|
||||
YOUTUBE,
|
||||
FLICKR,
|
||||
VIMEO,
|
||||
SLIDESHARE,
|
||||
]
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<p>In this video Jakob Zinsstag introduces the topic of the course. You will
|
||||
discover that the relationship between humans and animals is manifold.
|
||||
{.lead}</p>
|
||||
<p><figure class="oembed ratio ratio-16x9"><iframe src="https://player.vimeo.com/video/734276368?h=f29c542352&app_id=122963" width="426" height="240" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" title="One-Health_Tales_EN_1-02"></iframe></figure> </p>
|
||||
<p>Have a look at the farm of Jakob Zinsstag’s cousin in the Canton of Jura,
|
||||
Switzerland. Different animals create different feelings: there are those we
|
||||
love, some provoke fears and others will be eaten. Jakob Zinsstag shares the
|
||||
personal experiences he has had with animals. </p>
|
||||
<p><strong>How do you categorise your own experience with animals?</strong></p>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
In this video Jakob Zinsstag introduces the topic of the course. You will
|
||||
discover that the relationship between humans and animals is manifold.
|
||||
{.lead}
|
||||
|
||||

|
||||
|
||||
Have a look at the farm of Jakob Zinsstag’s cousin in the Canton of Jura,
|
||||
Switzerland. Different animals create different feelings: there are those we
|
||||
love, some provoke fears and others will be eaten. Jakob Zinsstag shares the
|
||||
personal experiences he has had with animals.
|
||||
|
||||
**How do you categorise your own experience with animals?**
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import markdown, yaml, requests_mock
|
||||
from python_markdown_oembed_extension.oembedextension import OEmbedExtension
|
||||
from python_markdown_oembed_extension.endpoints import VIMEO
|
||||
|
||||
|
||||
def test_full():
|
||||
with ( requests_mock.Mocker() as m
|
||||
, open('./src/python_markdown_oembed_extension/tests/vimeoMock.yaml', 'r') as vm
|
||||
, open('./src/python_markdown_oembed_extension/tests/test_markdown.md', 'r') as md
|
||||
, open('./src/python_markdown_oembed_extension/tests/test_expectedHtml.html', 'r') as expectedHtml
|
||||
):
|
||||
|
||||
yml = yaml.safe_load(vm)
|
||||
m.get(yml['request'], json=yml['response'])
|
||||
|
||||
mdString = md.read()
|
||||
htmlString = markdown.markdown(mdString, extensions=[OEmbedExtension()])
|
||||
print(htmlString)
|
||||
|
||||
assert htmlString == expectedHtml.read().rstrip()
|
||||
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
request: 'https://vimeo.com/734276368/f29c542352'
|
||||
response:
|
||||
account_type: 'live_premium'
|
||||
author_name: 'NMC Universität Basel'
|
||||
author_url: 'https://vimeo.com/newmediacenterunibasel'
|
||||
description: ''
|
||||
duration: 282
|
||||
height: 240
|
||||
html: >-
|
||||
<iframe
|
||||
src="https://player.vimeo.com/video/734276368?h=f29c542352&app_id=122963"
|
||||
width="426" height="240" frameborder="0" allow="autoplay; fullscreen; picture-in-picture"
|
||||
title="One-Health_Tales_EN_1-02"
|
||||
>
|
||||
</iframe>
|
||||
is_plus: '0'
|
||||
provider_name: 'Vimeo'
|
||||
provider_url: 'https://vimeo.com/'
|
||||
thumbnail_height: 166
|
||||
thumbnail_url: 'https://i.vimeocdn.com/video/1480489232-5ca2d723cadc09ae077c8b437581e84bd0485049780c60e218986fda60881110-d_295x166'
|
||||
thumbnail_url_with_play_button: 'https://i.vimeocdn.com/filter/overlay?src0=https%3A%2F%2Fi.vimeocdn.com%2Fvideo%2F1480489232-5ca2d723cadc09ae077c8b437581e84bd0485049780c60e218986fda60881110-d_295x166&src1=http%3A%2F%2Ff.vimeocdn.com%2Fp%2Fimages%2Fcrawler_play.png'
|
||||
thumbnail_width: 295
|
||||
title: 'One-Health_Tales_EN_1-02'
|
||||
type: 'video'
|
||||
upload_date: '2022-07-28 04:16:03'
|
||||
uri: '/videos/734276368:f29c542352'
|
||||
version: '1.0'
|
||||
video_id: 734276368
|
||||
width: 426
|
||||
547
tests.py
547
tests.py
|
|
@ -1,118 +1,220 @@
|
|||
"""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
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOEmbedRegex(unittest.TestCase):
|
||||
"""Tests for the raw OEMBED_LINK_RE pattern."""
|
||||
_OEMBED_RE = re.compile(OEMBED_LINK_RE)
|
||||
|
||||
def setUp(self):
|
||||
self.re = re.compile(OEMBED_LINK_RE)
|
||||
|
||||
# --- should NOT match (relative URLs) ---
|
||||
def test_ignore_relative_image_link():
|
||||
assert _OEMBED_RE.search("") is None
|
||||
|
||||
def test_ignore_relative_image_link(self):
|
||||
assert self.re.search("") is None
|
||||
|
||||
# --- should match (absolute URLs — image filtering is in Python now) ---
|
||||
def test_match_absolute_url():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
def test_match_absolute_url(self):
|
||||
m = self.re.search("")
|
||||
assert m is not None
|
||||
|
||||
def test_match_youtube_link(self):
|
||||
m = self.re.search("")
|
||||
assert m is not None
|
||||
assert m.group(2) == "http://www.youtube.com/watch?v=ABC"
|
||||
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"
|
||||
|
||||
def test_match_youtube_short_link(self):
|
||||
m = self.re.search("")
|
||||
assert m is not None
|
||||
|
||||
def test_match_https(self):
|
||||
m = self.re.search("")
|
||||
assert m is not None
|
||||
def test_match_youtube_short_link():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
def test_match_protocol_relative(self):
|
||||
m = self.re.search("")
|
||||
assert m is not None
|
||||
|
||||
def test_alt_text_captured(self):
|
||||
m = self.re.search("")
|
||||
assert m is not None
|
||||
assert m.group(1) == "my alt text"
|
||||
def test_match_https():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
|
||||
def test_match_protocol_relative():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
|
||||
|
||||
def test_alt_text_captured():
|
||||
m = _OEMBED_RE.search("")
|
||||
assert m is not None
|
||||
assert m.group(1) == "my alt text"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image URL detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsImageUrl(unittest.TestCase):
|
||||
|
||||
def test_common_extensions(self):
|
||||
for ext in ("png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "tiff", "ico"):
|
||||
assert _is_image_url(f"http://example.com/photo.{ext}") is True, ext
|
||||
@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_case_insensitive(self):
|
||||
assert _is_image_url("http://example.com/Photo.PNG") is True
|
||||
assert _is_image_url("http://example.com/photo.JpEg") is True
|
||||
|
||||
def test_query_string_ignored(self):
|
||||
assert _is_image_url("http://example.com/photo.jpg?size=large") is True
|
||||
def test_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_non_image(self):
|
||||
assert _is_image_url("http://www.youtube.com/watch?v=ABC") is False
|
||||
|
||||
def test_no_extension(self):
|
||||
assert _is_image_url("http://example.com/embed") is False
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSanitizeHtml(unittest.TestCase):
|
||||
|
||||
def test_allows_iframe(self):
|
||||
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_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_strips_script(self):
|
||||
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_strips_onerror(self):
|
||||
html = '<img src="x" onerror="alert(1)" />'
|
||||
result = _sanitize_html(html)
|
||||
assert "onerror" not 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="<iframe src='https://embed.example.com'></iframe>"):
|
||||
|
||||
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: {"html": html_response, "type": "video"}.get(key, default)
|
||||
response.__getitem__ = lambda self_inner, key: {"html": html_response, "type": "video"}[key]
|
||||
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="https://example.com/photo.jpg", width=640, height=480):
|
||||
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()
|
||||
|
|
@ -122,157 +224,192 @@ def _make_photo_consumer(photo_url="https://example.com/photo.jpg", width=640, h
|
|||
return consumer
|
||||
|
||||
|
||||
def _make_failing_consumer(exc_class=Exception, msg="fail"):
|
||||
def _make_failing_consumer(
|
||||
exc_class: type[Exception] = Exception, msg: str = "fail"
|
||||
) -> MagicMock:
|
||||
consumer = MagicMock()
|
||||
consumer.embed.side_effect = exc_class(msg)
|
||||
return consumer
|
||||
|
||||
|
||||
class TestOEmbedExtension(unittest.TestCase):
|
||||
"""Integration tests with mocked oEmbed 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()
|
||||
|
||||
def _convert(self, text, consumer=None, **ext_config):
|
||||
"""Helper: convert markdown with a mocked consumer."""
|
||||
if consumer is None:
|
||||
consumer = _make_mock_consumer()
|
||||
|
||||
with patch("mdx_oembed.extension.oembed.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(self):
|
||||
output = self._convert("")
|
||||
assert "<iframe" in output
|
||||
assert "oembed" in output # wrapper class
|
||||
|
||||
def test_vimeo_embed(self):
|
||||
output = self._convert("")
|
||||
assert "<iframe" in output
|
||||
|
||||
# --- images pass through ---
|
||||
|
||||
def test_image_png_passthrough(self):
|
||||
output = self._convert("")
|
||||
assert "<img" in output
|
||||
|
||||
def test_image_jpg_passthrough(self):
|
||||
output = self._convert("")
|
||||
assert "<img" in output
|
||||
|
||||
def test_image_with_query_passthrough(self):
|
||||
output = self._convert("")
|
||||
assert "<img" in output
|
||||
|
||||
def test_image_uppercase_passthrough(self):
|
||||
output = self._convert("")
|
||||
assert "<img" in output
|
||||
|
||||
# --- relative images are unaffected ---
|
||||
|
||||
def test_relative_image(self):
|
||||
output = self._convert("")
|
||||
assert '<img alt="alt" src="image.png"' in output
|
||||
|
||||
def test_slash_relative_image(self):
|
||||
output = self._convert("")
|
||||
assert '<img alt="alt" src="/image.png"' in output
|
||||
|
||||
# --- photo type response ---
|
||||
|
||||
def test_photo_type_response(self):
|
||||
consumer = _make_photo_consumer()
|
||||
output = self._convert("", consumer)
|
||||
assert "<img" in output
|
||||
assert "https://example.com/photo.jpg" in output
|
||||
|
||||
# --- error handling ---
|
||||
|
||||
def test_no_endpoint_falls_through(self):
|
||||
import oembed as _oembed
|
||||
consumer = _make_failing_consumer(_oembed.OEmbedNoEndpoint)
|
||||
output = self._convert("", consumer)
|
||||
assert "<iframe" not in output
|
||||
|
||||
def test_network_error_falls_through(self):
|
||||
consumer = _make_failing_consumer(Exception, "timeout")
|
||||
output = self._convert("", consumer)
|
||||
assert "<iframe" not in output
|
||||
|
||||
# --- configuration ---
|
||||
|
||||
def test_custom_wrapper_class(self):
|
||||
output = self._convert(
|
||||
"",
|
||||
wrapper_class="embed-responsive",
|
||||
with patch("mdx_oembed.extension.OEmbedConsumer", return_value=consumer):
|
||||
md = markdown.Markdown(
|
||||
extensions=["oembed"],
|
||||
extension_configs={"oembed": ext_config} if ext_config else {},
|
||||
)
|
||||
assert "embed-responsive" in output
|
||||
return md.convert(text)
|
||||
|
||||
def test_empty_wrapper_class(self):
|
||||
output = self._convert(
|
||||
"",
|
||||
wrapper_class="",
|
||||
|
||||
# --- 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]},
|
||||
},
|
||||
)
|
||||
assert "<figure" not in output
|
||||
assert "<iframe" in output
|
||||
yt_output = md.convert("")
|
||||
assert "<iframe" in yt_output
|
||||
|
||||
# --- XSS protection ---
|
||||
|
||||
def test_script_stripped_from_response(self):
|
||||
evil_consumer = _make_mock_consumer(
|
||||
'<script>alert("xss")</script><iframe src="https://ok.com"></iframe>'
|
||||
)
|
||||
output = self._convert("", evil_consumer)
|
||||
assert "<script" not in output
|
||||
assert "<iframe" in output
|
||||
|
||||
# --- multiple links ---
|
||||
|
||||
def test_multiple_embeds(self):
|
||||
text = (
|
||||
"\n\n"
|
||||
""
|
||||
)
|
||||
output = self._convert(text)
|
||||
assert output.count("<iframe") == 2
|
||||
|
||||
|
||||
class TestLimitedEndpoints(unittest.TestCase):
|
||||
"""Test allowed_endpoints configuration."""
|
||||
|
||||
def test_youtube_only(self):
|
||||
import oembed as _oembed
|
||||
|
||||
def side_effect(url):
|
||||
if "youtube" in url:
|
||||
resp = MagicMock()
|
||||
data = {"html": "<iframe src='yt'></iframe>", "type": "video"}
|
||||
resp.get = lambda key, default=None: data.get(key, default)
|
||||
resp.__getitem__ = lambda self_inner, key: data[key]
|
||||
return resp
|
||||
raise _oembed.OEmbedNoEndpoint("nope")
|
||||
|
||||
consumer = MagicMock()
|
||||
consumer.embed.side_effect = side_effect
|
||||
|
||||
with patch("mdx_oembed.extension.oembed.OEmbedConsumer", return_value=consumer):
|
||||
md = markdown.Markdown(
|
||||
extensions=["oembed"],
|
||||
extension_configs={
|
||||
"oembed": {"allowed_endpoints": [endpoints.YOUTUBE]},
|
||||
},
|
||||
)
|
||||
yt_output = md.convert("")
|
||||
assert "<iframe" in yt_output
|
||||
|
||||
md.reset()
|
||||
vim_output = md.convert("")
|
||||
assert "<iframe" not in vim_output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
md.reset()
|
||||
vim_output = md.convert("")
|
||||
assert "<iframe" not in vim_output
|
||||
|
|
|
|||
209
uv.lock
209
uv.lock
|
|
@ -1,10 +1,6 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.9"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
|
|
@ -15,76 +11,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
|
||||
|
|
@ -124,6 +63,15 @@ wheels = [
|
|||
{ 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"
|
||||
|
|
@ -152,41 +100,28 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
name = "pyright"
|
||||
version = "1.1.408"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
|
||||
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version < '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.10'" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
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/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||
{ 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" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
|
||||
{ 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 = [
|
||||
|
|
@ -198,8 +133,7 @@ name = "pytest-mock"
|
|||
version = "3.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ 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 = [
|
||||
|
|
@ -207,94 +141,58 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-markdown-oembed"
|
||||
version = "0.3.0"
|
||||
name = "python-markdown-oembed-extension"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "markdown" },
|
||||
{ name = "nh3" },
|
||||
{ name = "python-oembed" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "markdown", specifier = ">=3.2" },
|
||||
{ name = "nh3", specifier = ">=0.2" },
|
||||
{ name = "python-oembed", specifier = ">=0.2.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pyright", specifier = ">=1.1" },
|
||||
{ name = "pytest", specifier = ">=7.0" },
|
||||
{ name = "pytest-mock", specifier = ">=3.0" },
|
||||
{ name = "ruff", specifier = ">=0.4" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-oembed"
|
||||
version = "0.2.4"
|
||||
name = "ruff"
|
||||
version = "0.15.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/e2/8effcd118f659af206ff74b64b4ab2145b3d399969199254aeacdf2c424c/python-oembed-0.2.4.tar.gz", hash = "sha256:e0804ea3bd7ab8ec1460d139b7a92b6a9e4e3cddd83012dfe30cc67e314716ea", size = 7981, upload-time = "2016-01-02T00:38:26.229Z" }
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
|
||||
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/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
|
||||
{ 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]]
|
||||
|
|
@ -305,12 +203,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
|
|||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue