mirror of
https://github.com/Hopiu/python-markdown-oembed.git
synced 2026-03-16 22:10:24 +00:00
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.
This commit is contained in:
parent
6864af69fb
commit
9e48edcfdf
19 changed files with 687 additions and 573 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
|
||||
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,10 +1,10 @@
|
|||
[project]
|
||||
name = "python-markdown-oembed-extension"
|
||||
version = "0.4.0"
|
||||
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 = "Benedikt Willi", email = "ben.willi@gmail.com" },
|
||||
{ name = "Tanner Netterville", email = "tannern@gmail.com" },
|
||||
|
|
@ -15,14 +15,11 @@ 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",
|
||||
]
|
||||
|
|
@ -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