From 9e48edcfdfb3085689ad9cf99c0d6a4501979632 Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Tue, 3 Mar 2026 14:26:52 +0100 Subject: [PATCH] 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. --- .travis.yml | 9 - flake.lock | 27 - flake.nix | 29 - mdx_oembed/__init__.py | 6 +- mdx_oembed/endpoints.py | 34 ++ .../extension.py | 16 +- .../inlinepatterns.py | 58 +- mdx_oembed/oembed.py | 181 ++++++ mdx_oembed/py.typed | 0 mdx_oembed/version.py | 2 +- pyproject.toml | 30 +- .../__init__.py | 9 - .../endpoints.py | 31 - .../tests/test_expectedHtml.html | 9 - .../tests/test_markdown.md | 12 - .../tests/test_markdown.py | 21 - .../tests/vimeoMock.yaml | 30 - tests.py | 547 +++++++++++------- uv.lock | 209 ++----- 19 files changed, 687 insertions(+), 573 deletions(-) delete mode 100644 .travis.yml delete mode 100644 flake.lock delete mode 100644 flake.nix create mode 100644 mdx_oembed/endpoints.py rename src/python_markdown_oembed_extension/oembedextension.py => mdx_oembed/extension.py (77%) rename {src/python_markdown_oembed_extension => mdx_oembed}/inlinepatterns.py (70%) create mode 100644 mdx_oembed/oembed.py create mode 100644 mdx_oembed/py.typed delete mode 100644 src/python_markdown_oembed_extension/__init__.py delete mode 100644 src/python_markdown_oembed_extension/endpoints.py delete mode 100644 src/python_markdown_oembed_extension/tests/test_expectedHtml.html delete mode 100644 src/python_markdown_oembed_extension/tests/test_markdown.md delete mode 100644 src/python_markdown_oembed_extension/tests/test_markdown.py delete mode 100644 src/python_markdown_oembed_extension/tests/vimeoMock.yaml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 89de62b..0000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: python -python: - - "2.7" - - "3.2" - - "3.3" - - "3.4" - - "3.5" -install: "pip install . nose mock" -script: nosetests diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 9b0eab5..0000000 --- a/flake.lock +++ /dev/null @@ -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 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index ffb8b7d..0000000 --- a/flake.nix +++ /dev/null @@ -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 - ''; - }; - }; -} diff --git a/mdx_oembed/__init__.py b/mdx_oembed/__init__.py index 4ed9c02..0a88f9d 100644 --- a/mdx_oembed/__init__.py +++ b/mdx_oembed/__init__.py @@ -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) diff --git a/mdx_oembed/endpoints.py b/mdx_oembed/endpoints.py new file mode 100644 index 0000000..08a175a --- /dev/null +++ b/mdx_oembed/endpoints.py @@ -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, +] diff --git a/src/python_markdown_oembed_extension/oembedextension.py b/mdx_oembed/extension.py similarity index 77% rename from src/python_markdown_oembed_extension/oembedextension.py rename to mdx_oembed/extension.py index bfe3cc7..e236525 100644 --- a/src/python_markdown_oembed_extension/oembedextension.py +++ b/mdx_oembed/extension.py @@ -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 - diff --git a/src/python_markdown_oembed_extension/inlinepatterns.py b/mdx_oembed/inlinepatterns.py similarity index 70% rename from src/python_markdown_oembed_extension/inlinepatterns.py rename to mdx_oembed/inlinepatterns.py index 257f29b..0899223 100644 --- a/src/python_markdown_oembed_extension/inlinepatterns.py +++ b/mdx_oembed/inlinepatterns.py @@ -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'{escaped_alt}' + f'{_html.escape(alt, quote=True)}' ) LOG.warning("oEmbed response for %s has no 'html' or 'url' field", url) diff --git a/mdx_oembed/oembed.py b/mdx_oembed/oembed.py new file mode 100644 index 0000000..75823eb --- /dev/null +++ b/mdx_oembed/oembed.py @@ -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 diff --git a/mdx_oembed/py.typed b/mdx_oembed/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/mdx_oembed/version.py b/mdx_oembed/version.py index 6a9beea..3d18726 100644 --- a/mdx_oembed/version.py +++ b/mdx_oembed/version.py @@ -1 +1 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/pyproject.toml b/pyproject.toml index ff29e6b..cc595ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/python_markdown_oembed_extension/__init__.py b/src/python_markdown_oembed_extension/__init__.py deleted file mode 100644 index b733a04..0000000 --- a/src/python_markdown_oembed_extension/__init__.py +++ /dev/null @@ -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) diff --git a/src/python_markdown_oembed_extension/endpoints.py b/src/python_markdown_oembed_extension/endpoints.py deleted file mode 100644 index 1f12c32..0000000 --- a/src/python_markdown_oembed_extension/endpoints.py +++ /dev/null @@ -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, -] diff --git a/src/python_markdown_oembed_extension/tests/test_expectedHtml.html b/src/python_markdown_oembed_extension/tests/test_expectedHtml.html deleted file mode 100644 index 0740525..0000000 --- a/src/python_markdown_oembed_extension/tests/test_expectedHtml.html +++ /dev/null @@ -1,9 +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?

diff --git a/src/python_markdown_oembed_extension/tests/test_markdown.md b/src/python_markdown_oembed_extension/tests/test_markdown.md deleted file mode 100644 index 107b350..0000000 --- a/src/python_markdown_oembed_extension/tests/test_markdown.md +++ /dev/null @@ -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} - -![embed](https://vimeo.com/734276368/f29c542352) - -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?** diff --git a/src/python_markdown_oembed_extension/tests/test_markdown.py b/src/python_markdown_oembed_extension/tests/test_markdown.py deleted file mode 100644 index 4391235..0000000 --- a/src/python_markdown_oembed_extension/tests/test_markdown.py +++ /dev/null @@ -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() - diff --git a/src/python_markdown_oembed_extension/tests/vimeoMock.yaml b/src/python_markdown_oembed_extension/tests/vimeoMock.yaml deleted file mode 100644 index 4559214..0000000 --- a/src/python_markdown_oembed_extension/tests/vimeoMock.yaml +++ /dev/null @@ -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: >- - - 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 diff --git a/tests.py b/tests.py index 33f5f15..bded3c9 100644 --- a/tests.py +++ b/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("![image](/image.png)") is None - def test_ignore_relative_image_link(self): - assert self.re.search("![image](/image.png)") is None - # --- should match (absolute URLs — image filtering is in Python now) --- +def test_match_absolute_url(): + m = _OEMBED_RE.search("![img](http://example.com/photo.png)") + assert m is not None - def test_match_absolute_url(self): - m = self.re.search("![img](http://example.com/photo.png)") - assert m is not None - def test_match_youtube_link(self): - m = self.re.search("![video](http://www.youtube.com/watch?v=ABC)") - assert m is not None - assert m.group(2) == "http://www.youtube.com/watch?v=ABC" +def test_match_youtube_link(): + m = _OEMBED_RE.search("![video](http://www.youtube.com/watch?v=ABC)") + assert m is not None + assert m.group(2) == "http://www.youtube.com/watch?v=ABC" - def test_match_youtube_short_link(self): - m = self.re.search("![video](http://youtu.be/ABC)") - assert m is not None - def test_match_https(self): - m = self.re.search("![video](https://youtu.be/ABC)") - assert m is not None +def test_match_youtube_short_link(): + m = _OEMBED_RE.search("![video](http://youtu.be/ABC)") + assert m is not None - def test_match_protocol_relative(self): - m = self.re.search("![video](//youtu.be/ABC)") - assert m is not None - def test_alt_text_captured(self): - m = self.re.search("![my alt text](https://example.com/embed)") - assert m is not None - assert m.group(1) == "my alt text" +def test_match_https(): + m = _OEMBED_RE.search("![video](https://youtu.be/ABC)") + assert m is not None + + +def test_match_protocol_relative(): + m = _OEMBED_RE.search("![video](//youtu.be/ABC)") + assert m is not None + + +def test_alt_text_captured(): + m = _OEMBED_RE.search("![my alt text](https://example.com/embed)") + assert m is not None + assert m.group(1) == "my alt text" # --------------------------------------------------------------------------- # Image URL detection # --------------------------------------------------------------------------- -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 = '' - result = _sanitize_html(html) - assert "' + ) + result = _sanitize_html(html) + assert "", "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"] == "" # --------------------------------------------------------------------------- # Extension integration tests (mocked HTTP) # --------------------------------------------------------------------------- -def _make_mock_consumer(html_response=""): + +def _make_mock_consumer( + html_response: str = "", +) -> 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("![video](http://www.youtube.com/watch?v=ABC)") - assert "alert("xss")' + ) + output = _convert("![v](http://www.youtube.com/watch?v=ABC)", evil_consumer) + assert " MagicMock: + if "youtube" in url: + resp = MagicMock() + data = {"html": "", "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 "alert("xss")' - ) - output = self._convert("![v](http://www.youtube.com/watch?v=ABC)", evil_consumer) - assert "", "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("![v](http://www.youtube.com/watch?v=A)") - assert "= '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" }, -]