diff --git a/Makefile b/Makefile index 283caf9db..04c8089ae 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,12 @@ test-api: DEBUG=true \ uv run pytest tests/api +test-wasm-e2e: + LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \ + PYTHONUNBUFFERED=1 \ + DEBUG=true \ + uv run pytest tests/wasm_ext --browser chromium + test-regtest: LNBITS_DATA_FOLDER="./tests/data" \ PYTHONUNBUFFERED=1 \ diff --git a/pyproject.toml b/pyproject.toml index f10a95929..0d0d0c941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ dev = [ "types-mock~=5.2.0.20250924", "mock~=5.2.0", "grpcio-tools~=1.76.0", + "pytest-playwright>=0.8.0", ] [tool.uv] diff --git a/tests/fixtures/lnbits-wasm-test-extension/config.json b/tests/fixtures/lnbits-wasm-test-extension/config.json new file mode 100644 index 000000000..6385c73dd --- /dev/null +++ b/tests/fixtures/lnbits-wasm-test-extension/config.json @@ -0,0 +1,173 @@ +{ + "id": "lnbits-wasm-test-extension", + "name": "WASM Test Extension", + "short_description": "Minimal WASM extension used by LNbits e2e tests.", + "version": "0.0.1", + "min_lnbits_version": "1.5.5", + "extension_type": "wasm", + "wasm": { + "module": "wasm/module.wasm", + "exports": [ + { + "name": "list-wallets", + "visibility": "authenticated" + }, + { + "name": "invoice-details", + "visibility": "authenticated" + }, + { + "name": "get-selection", + "visibility": "authenticated" + }, + { + "name": "save-selection", + "visibility": "authenticated" + }, + { + "name": "pay-large-invoice", + "visibility": "authenticated" + }, + { + "name": "create-tip-jar", + "visibility": "authenticated" + }, + { + "name": "list-tip-jars", + "visibility": "authenticated" + }, + { + "name": "get-public-tip-jar", + "visibility": "public" + }, + { + "name": "create-tip-invoice", + "visibility": "public" + }, + { + "name": "record-payment", + "visibility": "event" + } + ] + }, + "events": { + "onInvoicePaid": "record-payment" + }, + "ui_routes": [ + { + "path": "/lnbits-wasm-test-extension", + "entrypoint": "ui/admin.html", + "auth": "user" + }, + { + "path": "/lnbits-wasm-test-extension/public/{item_id}", + "entrypoint": "ui/public.html", + "auth": "public", + "path_params": { + "item_id": "itemId" + } + } + ], + "api_routes": [ + { + "method": "GET", + "path": "/wallets", + "export": "list-wallets", + "auth": "user" + }, + { + "method": "POST", + "path": "/payments", + "export": "pay-large-invoice", + "auth": "user" + }, + { + "method": "GET", + "path": "/jars/{jar_id}", + "export": "get-public-tip-jar", + "auth": "public", + "path_params": { + "jar_id": "jarId" + } + }, + { + "method": "POST", + "path": "/invoice", + "export": "create-tip-invoice", + "auth": "public" + } + ], + "permissions": [ + { + "id": "wallet.pay_invoice", + "description": "Pay Lightning invoices from selected wallets." + }, + { + "id": "wallet.list", + "description": "List wallets available to the installing user." + }, + { + "id": "wallet.balance.read", + "description": "Read wallet balances for payment selection." + }, + { + "id": "extension.api.request", + "description": "Call APIs exposed by another installed extension.", + "policies": [ + { + "id": "watchonly", + "access": [ + "read", + "write" + ] + } + ] + }, + { + "id": "ui.camera.scan_qr", + "description": "Use the LNbits scanner to read QR codes when requested." + }, + { + "id": "ext.storage.read", + "description": "Read extension storage rows." + }, + { + "id": "ext.storage.write", + "description": "Write extension storage rows." + }, + { + "id": "ext.storage.read_public", + "description": "Read public extension storage fields.", + "policies": [ + { + "table_name": "tip_jars", + "public_fields": [ + "id", + "title", + "description", + "currency", + "suggested_amounts" + ] + } + ] + }, + { + "id": "wallet.create_invoice", + "description": "Create incoming Lightning invoices from authenticated pages." + }, + { + "id": "wallet.create_invoice_public", + "description": "Create incoming Lightning invoices from public pages.", + "policies": [ + { + "table": "tip_jars", + "wallet_field": "wallet_id" + } + ] + }, + { + "id": "utils.basic", + "description": "Use LNbits utility functions." + } + ] +} diff --git a/tests/fixtures/lnbits-wasm-test-extension/static/app.js b/tests/fixtures/lnbits-wasm-test-extension/static/app.js new file mode 100644 index 000000000..f83e5ae0d --- /dev/null +++ b/tests/fixtures/lnbits-wasm-test-extension/static/app.js @@ -0,0 +1 @@ +window.__lnbitsWasmTestExtensionLoaded = true diff --git a/tests/fixtures/lnbits-wasm-test-extension/ui/admin.html b/tests/fixtures/lnbits-wasm-test-extension/ui/admin.html new file mode 100644 index 000000000..85732ea6c --- /dev/null +++ b/tests/fixtures/lnbits-wasm-test-extension/ui/admin.html @@ -0,0 +1,11 @@ + + + + + WASM Test Extension Admin + + + +
WASM Test Admin
+ + diff --git a/tests/fixtures/lnbits-wasm-test-extension/ui/public.html b/tests/fixtures/lnbits-wasm-test-extension/ui/public.html new file mode 100644 index 000000000..a165feed6 --- /dev/null +++ b/tests/fixtures/lnbits-wasm-test-extension/ui/public.html @@ -0,0 +1,11 @@ + + + + + WASM Test Extension Public + + + +
WASM Test Public
+ + diff --git a/tests/fixtures/lnbits-wasm-test-extension/wasm/module.wasm b/tests/fixtures/lnbits-wasm-test-extension/wasm/module.wasm new file mode 100644 index 000000000..fd9aba096 Binary files /dev/null and b/tests/fixtures/lnbits-wasm-test-extension/wasm/module.wasm differ diff --git a/tests/wasm_ext/__init__.py b/tests/wasm_ext/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/wasm_ext/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/wasm_ext/conftest.py b/tests/wasm_ext/conftest.py new file mode 100644 index 000000000..86979b173 --- /dev/null +++ b/tests/wasm_ext/conftest.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from tests.wasm_ext.helpers import EXTENSION_ID, SERVER_HOST, LiveLNbitsServer + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="session") +def browser_name() -> str: + return "chromium" + + +@pytest.fixture(scope="session") +def lnbits_server( + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[LiveLNbitsServer]: + run_root = tmp_path_factory.mktemp("wasm_ext_e2e") + data_dir = run_root / "data" + extensions_root = run_root / "extensions-root" + extension_target = extensions_root / "extensions" / EXTENSION_ID + fixture_extension = REPO_ROOT / "tests" / "fixtures" / EXTENSION_ID + + shutil.copytree(fixture_extension, extension_target) + + port = _free_tcp_port() + base_url = f"http://{SERVER_HOST}:{port}" + env = { + **os.environ, + "AUTH_HTTPS_ONLY": "false", + "DEBUG": "true", + "HOST": SERVER_HOST, + "LNBITS_ADMIN_UI": "true", + "LNBITS_BACKEND_WALLET_CLASS": "FakeWallet", + "LNBITS_DATA_FOLDER": str(data_dir), + "LNBITS_EXTENSIONS_DEACTIVATE_ALL": "false", + "LNBITS_EXTENSIONS_PATH": str(extensions_root), + "LNBITS_PATH": str(REPO_ROOT), + "PORT": str(port), + "PYTHONUNBUFFERED": "1", + } + + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "uvicorn", + "lnbits.__main__:app", + "--host", + SERVER_HOST, + "--port", + str(port), + "--loop", + "asyncio", + "--log-level", + "warning", + ], + cwd=REPO_ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + try: + _wait_for_first_install_page(process, base_url) + auth_cookies = _complete_first_install(base_url) + yield LiveLNbitsServer(base_url=base_url, auth_cookies=auth_cookies) + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) + + +@pytest.fixture +def authenticated_page(page: Any, lnbits_server: LiveLNbitsServer) -> Any: + page.context.add_cookies(lnbits_server.auth_cookies) + return page + + +def _free_tcp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((SERVER_HOST, 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_first_install_page( + process: subprocess.Popen[str], + base_url: str, +) -> None: + deadline = time.monotonic() + 60 + last_error: Exception | None = None + with httpx.Client(follow_redirects=False, timeout=2) as client: + while time.monotonic() < deadline: + if process.poll() is not None: + output = process.stdout.read() if process.stdout else "" + raise RuntimeError(f"LNbits exited before startup:\n{output}") + try: + response = client.get(f"{base_url}/first_install") + if response.status_code == 200: + return + except httpx.HTTPError as exc: + last_error = exc + time.sleep(0.25) + + output = process.stdout.read() if process.stdout else "" + raise TimeoutError(f"LNbits did not start: {last_error}\n{output}") + + +def _complete_first_install(base_url: str) -> list[dict[str, Any]]: + with httpx.Client(base_url=base_url, follow_redirects=False, timeout=10) as client: + response = client.put( + "/api/v1/auth/first_install", + json={ + "username": "wasmtest-admin", + "password": "secret1234", + "password_repeat": "secret1234", + }, + ) + response.raise_for_status() + + enable_response = client.put(f"/api/v1/extension/{EXTENSION_ID}/enable") + enable_response.raise_for_status() + + return [ + { + "name": cookie.name, + "value": cookie.value, + "url": base_url, + "secure": cookie.secure, + "sameSite": "Lax", + } + for cookie in client.cookies.jar + ] diff --git a/tests/wasm_ext/helpers.py b/tests/wasm_ext/helpers.py new file mode 100644 index 000000000..30d6425c6 --- /dev/null +++ b/tests/wasm_ext/helpers.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +EXTENSION_ID = "lnbits-wasm-test-extension" +SERVER_HOST = "127.0.0.1" + + +@dataclass(frozen=True) +class LiveLNbitsServer: + base_url: str + auth_cookies: list[dict[str, Any]] + extension_id: str = EXTENSION_ID diff --git a/tests/wasm_ext/test_wasm_extension_e2e.py b/tests/wasm_ext/test_wasm_extension_e2e.py new file mode 100644 index 000000000..fe8bd35c1 --- /dev/null +++ b/tests/wasm_ext/test_wasm_extension_e2e.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import re +from re import Pattern + +from playwright.sync_api import Page, expect + +from tests.wasm_ext.helpers import LiveLNbitsServer + + +def test_public_wasm_page_loads_sandboxed_frame( + page: Page, + lnbits_server: LiveLNbitsServer, +) -> None: + public_url = ( + f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}/public/item-123" + "?source=test" + ) + frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1" + + with page.expect_response(lambda response: frame_url_part in response.url) as info: + page.goto(public_url) + + frame_response = info.value + assert frame_response.status == 200 + assert "sandbox allow-scripts" in frame_response.headers["content-security-policy"] + assert frame_response.headers["cache-control"] == "no-store" + assert frame_response.headers["x-content-type-options"] == "nosniff" + + frame = page.locator("iframe.wasm-extension-frame") + expect(frame).to_have_attribute("sandbox", "allow-scripts") + expect(frame).to_have_attribute("allow", "clipboard-write") + expect(frame).to_have_attribute("referrerpolicy", "no-referrer") + expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part)) + expect( + page.frame_locator("iframe.wasm-extension-frame").locator("body") + ).to_contain_text("WASM Test Public") + + +def test_frame_token_is_route_bound_required_and_single_use( + page: Page, + lnbits_server: LiveLNbitsServer, +) -> None: + frame_config = page.request.post( + f"{lnbits_server.base_url}/api/v1/ext/" + f"{lnbits_server.extension_id}/_ui/frame", + data={ + "path": f"/ext/{lnbits_server.extension_id}/public/item-123", + "query": {"source": "test"}, + }, + ) + assert frame_config.status == 200 + frame_url = frame_config.json()["frameUrl"] + + missing_token = page.request.get( + f"{lnbits_server.base_url}/ext-frame/{lnbits_server.extension_id}/1" + ) + assert missing_token.status == 404 + + wrong_route = page.request.get( + f"{lnbits_server.base_url}{frame_url.replace('/1?', '/0?')}" + ) + assert wrong_route.status == 404 + + first_use = page.request.get(f"{lnbits_server.base_url}{frame_url}") + assert first_use.status == 200 + assert "form-action 'none'" in first_use.headers["content-security-policy"] + + second_use = page.request.get(f"{lnbits_server.base_url}{frame_url}") + assert second_use.status == 404 + + +def test_private_wasm_page_uses_lnbits_shell_and_private_frame( + authenticated_page: Page, + lnbits_server: LiveLNbitsServer, +) -> None: + page = authenticated_page + frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/0" + + with page.expect_response(lambda response: frame_url_part in response.url) as info: + page.goto(f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}") + + assert info.value.status == 200 + expect(page.locator("body")).to_contain_text("LNbits") + frame = page.locator("iframe.wasm-extension-frame") + expect(frame).to_have_attribute("sandbox", "allow-scripts") + expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part)) + expect( + page.frame_locator("iframe.wasm-extension-frame").locator("body") + ).to_contain_text("WASM Test Admin") + + +def _frame_src_pattern(frame_url_part: str) -> Pattern[str]: + return re.compile(f"^{re.escape(frame_url_part)}\\?frame_token=[a-f0-9]{{32}}$") diff --git a/uv.lock b/uv.lock index d50cc32e9..a21d418b1 100644 --- a/uv.lock +++ b/uv.lock @@ -1368,6 +1368,7 @@ dev = [ { name = "pytest-httpserver" }, { name = "pytest-md" }, { name = "pytest-mock" }, + { name = "pytest-playwright" }, { name = "ruff" }, { name = "types-mock" }, { name = "types-passlib" }, @@ -1446,6 +1447,7 @@ dev = [ { name = "pytest-httpserver", specifier = "~=1.1.3" }, { name = "pytest-md", specifier = "~=0.2.0" }, { name = "pytest-mock", specifier = "~=3.15.1" }, + { name = "pytest-playwright", specifier = ">=0.8.0" }, { name = "ruff", specifier = "~=0.14.10" }, { name = "types-mock", specifier = "~=5.2.0.20250924" }, { name = "types-passlib", specifier = "~=1.7.7.20250602" }, @@ -1832,6 +1834,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] +[[package]] +name = "playwright" +version = "1.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ee/31e4e0db36588b817a10b299a0285082545fde7d36543c2abe498bb3d61a/playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0", size = 43421877, upload-time = "2026-06-29T10:32:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/42/35/71395dd3ecc798965be4a3ef8c443217d4abca168e7cb34536304f9489e6/playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a", size = 42205016, upload-time = "2026-06-29T10:32:52.104Z" }, + { url = "https://files.pythonhosted.org/packages/f4/44/323164cf5cd1647bdefce76ffce27651aadb959d089b48f53ea40918276e/playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af", size = 43421884, upload-time = "2026-06-29T10:32:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/a35bf179e4ba2522c1893635094a64e407572547bd61528820fc0abc87fe/playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e", size = 47421381, upload-time = "2026-06-29T10:32:59.903Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/e3f922348ec17c315f98c463f72faa1181a1c3de0bfe31a8d2edf6561723/playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c", size = 47120545, upload-time = "2026-06-29T10:33:03.574Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a6/5be4e52b40a9c0c8a073e7c5b0785c05cf5a9ea8f8a7b5b260e32d970342/playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b", size = 37844841, upload-time = "2026-06-29T10:33:07.361Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/2b78036e5fbe9d5f5645bbe08a1eac7160c51243c0093963edbcf67c35d9/playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597", size = 37844846, upload-time = "2026-06-29T10:33:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/1b0f3c4ee4eb0514bc805b5c2f9a223e5b6de4f11a926f5235d51d0fc81b/playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51", size = 33955127, upload-time = "2026-06-29T10:33:14.008Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2059,6 +2080,18 @@ email = [ { name = "email-validator" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2201,6 +2234,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-base-url" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -2251,6 +2297,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "pytest-playwright" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/ef/172eb8e23c80491fc72f1401c72f9305663873649351306a38b18406b0c9/pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981", size = 17132, upload-time = "2026-05-18T10:16:15.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/71/1c545fac6a9054b52b3771238fb2dc6e8f1d0ccec116e1c7786ec191887c/pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933", size = 17143, upload-time = "2026-05-18T10:16:18.226Z" }, +] + [[package]] name = "python-crontab" version = "3.3.0" @@ -2291,6 +2352,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -2630,6 +2703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, ] +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "tibs" version = "0.5.7"