test: add tests

This commit is contained in:
Vlad Stan
2026-07-07 18:32:54 +03:00
parent 15d26093a5
commit 4f072a6247
12 changed files with 543 additions and 0 deletions
+6
View File
@@ -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 \
+1
View File
@@ -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]
+173
View File
@@ -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."
}
]
}
@@ -0,0 +1 @@
window.__lnbitsWasmTestExtensionLoaded = true
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WASM Test Extension Admin</title>
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
</head>
<body>
<main id="wasm-test-admin">WASM Test Admin</main>
</body>
</html>
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>WASM Test Extension Public</title>
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
</head>
<body>
<main id="wasm-test-public">WASM Test Public</main>
</body>
</html>
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
+149
View File
@@ -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
]
+14
View File
@@ -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
+94
View File
@@ -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}}$")
Generated
+82
View File
@@ -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"