Refactor Dockerfile to exclude dev dependencies for production image; add pytest-playwright and related packages; update invitation handling and tests for improved functionality and performance

This commit is contained in:
Matthieu B
2025-08-17 18:18:21 +02:00
parent 067a76de6b
commit 51b6a3e080
10 changed files with 273 additions and 54 deletions

View File

@@ -17,8 +17,9 @@ ENV UV_LINK_MODE=copy
COPY pyproject.toml uv.lock ./
# Install Python dependencies only (not project) with cache mount for speed
# Exclude dev dependencies for production image
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project
uv sync --locked --no-install-project --no-dev
# Copy npm dependency files and install with cache
COPY app/static/package*.json ./app/static/
@@ -32,8 +33,9 @@ COPY app/ ./app/
COPY babel.cfg ./
# Install the project now that we have source code
# Exclude dev dependencies for production image
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
uv sync --locked --no-dev
# Build translations
RUN uv run pybabel compile -d app/translations

View File

@@ -48,6 +48,20 @@ def is_invite_valid(code: str) -> tuple[bool, str]:
return True, "okay"
def _get_form_list(form: Any, key: str) -> list[str]:
"""Get list from form, handling both WTForms and dict."""
if hasattr(form, "getlist"):
# WTForms object
return form.getlist(key) or []
# Regular dict
value = form.get(key, [])
if isinstance(value, list):
return value
if value:
return [str(value)]
return []
def create_invite(form: Any) -> Invitation:
"""Takes a WTForms or dict-like `form` with the same keys as your old version."""
# generate or validate provided code
@@ -69,7 +83,7 @@ def create_invite(form: Any) -> Invitation:
# ── servers ────────────────────────────────────────────────────────────
# Get selected server IDs from checkboxes
server_ids = form.getlist("server_ids") or []
server_ids = _get_form_list(form, "server_ids")
if not server_ids:
# No servers selected - this is now an error condition
@@ -131,7 +145,9 @@ def create_invite(form: Any) -> Invitation:
invite.servers.extend(servers)
# Wire up library associations
selected = form.getlist("libraries") # these are now library IDs (not external_ids)
selected = _get_form_list(
form, "libraries"
) # these are now library IDs (not external_ids)
if selected:
# Convert string IDs to integers and filter out invalid values
try:

View File

@@ -40,6 +40,7 @@ dev = [
"pytest>=8.3.5",
"pytest-flask>=1.3.0",
"pytest-mock>=3.14.1",
"pytest-playwright>=0.6.2",
"pyright>=1.1.400",
"pre-commit>=4.0.1",
"playwright>=1.54.0",

View File

@@ -1,5 +1,6 @@
[pytest]
minversion = 6.0
addopts = -ra -q
addopts = -ra -q --tb=short
testpaths = tests
python_files = test_*.py
python_files = test_*.py
# Playwright will be enabled via conftest.py for E2E tests only

View File

@@ -68,7 +68,7 @@ class TestInvitationUserJourney:
mock_get_client.return_value = mock_client
# Navigate to invitation page
page.goto(f"{live_server.url}{invitation_setup['invitation_url']}")
page.goto(f"{live_server.url()()}{invitation_setup['invitation_url']}")
# Verify invitation page loads
expect(page.locator("h1")).to_contain_text("Join Test Jellyfin Server")
@@ -114,7 +114,7 @@ class TestInvitationUserJourney:
mock_get_client.return_value = mock_client
# Navigate to invitation page
page.goto(f"{live_server.url}{invitation_setup['invitation_url']}")
page.goto(f"{live_server.url()()}{invitation_setup['invitation_url']}")
# Test empty form submission
page.click("button[type='submit']")
@@ -166,7 +166,7 @@ class TestInvitationUserJourney:
db.session.commit()
# Navigate to expired invitation
page.goto(f"{live_server.url}/j/EXPIRED123")
page.goto(f"{live_server.url()}/j/EXPIRED123")
# Should show expiry error
expect(page.locator("body")).to_contain_text("expired", ignore_case=True)
@@ -177,7 +177,7 @@ class TestInvitationUserJourney:
def test_invalid_invitation_code(self, page: Page, live_server):
"""Test that invalid invitation codes show 404 or error page."""
# Navigate to non-existent invitation
page.goto(f"{live_server.url}/j/INVALIDCODE123")
page.goto(f"{live_server.url()}/j/INVALIDCODE123")
# Should show error (either 404 or invalid invitation message)
page.wait_for_load_state("networkidle")
@@ -205,7 +205,7 @@ class TestInvitationUserJourney:
mock_get_client.return_value = mock_client
# Navigate to invitation page
page.goto(f"{live_server.url}{invitation_setup['invitation_url']}")
page.goto(f"{live_server.url()()}{invitation_setup['invitation_url']}")
# Fill and submit form
page.fill("input[name='username']", "erroruser")
@@ -266,7 +266,7 @@ class TestMultiServerInvitationFlow:
mock_get_client.side_effect = get_client_side_effect
# Navigate to invitation page
page.goto(f"{live_server.url}/j/MULTI123")
page.goto(f"{live_server.url()}/j/MULTI123")
# Should show multi-server invitation info
expect(page.locator("body")).to_contain_text("Jellyfin Server")
@@ -332,7 +332,7 @@ class TestMultiServerInvitationFlow:
mock_get_client.side_effect = get_client_side_effect
# Navigate and submit
page.goto(f"{live_server.url}/j/PARTIAL123")
page.goto(f"{live_server.url()}/j/PARTIAL123")
page.fill("input[name='username']", "partialuser")
page.fill("input[name='password']", "testpass123")
page.fill("input[name='confirm']", "testpass123")
@@ -358,7 +358,7 @@ class TestInvitationUIComponents:
self, page: Page, live_server, invitation_setup
):
"""Test basic accessibility of invitation form."""
page.goto(f"{live_server.url}{invitation_setup['invitation_url']}")
page.goto(f"{live_server.url()()}{invitation_setup['invitation_url']}")
# Check for form labels
expect(
@@ -383,7 +383,7 @@ class TestInvitationUIComponents:
"""Test invitation page on different screen sizes."""
# Test desktop
page.set_viewport_size({"width": 1920, "height": 1080})
page.goto(f"{live_server.url}{invitation_setup['invitation_url']}")
page.goto(f"{live_server.url()()}{invitation_setup['invitation_url']}")
expect(page.locator("form")).to_be_visible()
# Test tablet

View File

@@ -65,7 +65,25 @@ class MockJellyfinClient:
def __init__(self, url: str = None, token: str = None, **kwargs):
self.url = url or "http://localhost:8096"
self.token = token or "mock-api-key"
self.server_id = kwargs.get("server_id", 1)
self.server_id = kwargs.get("server_id")
def _create_user_with_identity_linking(self, user_kwargs: dict):
"""Mock version of the database user creation."""
from app.extensions import db
from app.models import User
# Check if this is part of a multi-server invitation
code = user_kwargs.get("code")
if code:
existing_user = User.query.filter_by(code=code).first()
if existing_user and existing_user.identity_id:
# Link to existing identity from same invitation
user_kwargs["identity_id"] = existing_user.identity_id
new_user = User(**user_kwargs)
db.session.add(new_user)
db.session.flush() # Get the ID immediately
return new_user
def validate_connection(self) -> tuple[bool, str]:
"""Simulate connection validation."""
@@ -158,6 +176,12 @@ class MockJellyfinClient:
self, username: str, password: str, confirm: str, email: str, code: str
) -> tuple[bool, str]:
"""Mock implementation of invitation join process."""
# Check connection health first
if not mock_state.connection_healthy:
return False, "Connection failed"
if not mock_state.api_key_valid:
return False, "Invalid API key"
# Basic validation
if password != confirm:
return False, "Passwords do not match"
@@ -167,16 +191,36 @@ class MockJellyfinClient:
return False, f"Failed to create user {username}"
try:
# Simulate user creation
# Simulate user creation in mock state
user_id = self.create_user(username, password)
# Set email
mock_state.users[user_id].email = email
# Set default libraries (simulate library assignment)
enabled_libs = [
lib_id for lib_id, lib in mock_state.libraries.items() if lib.enabled
]
# Set libraries based on invitation (like real clients do)
from app.models import Invitation, Library
inv = Invitation.query.filter_by(code=code).first()
if inv and inv.libraries:
# Use invitation-specific libraries
enabled_libs = [
lib.external_id
for lib in inv.libraries
if lib.server_id == self.server_id
]
else:
# Use all enabled libraries for server (fallback)
enabled_libs = (
[
lib.external_id
for lib in Library.query.filter_by(
enabled=True, server_id=self.server_id
).all()
]
if self.server_id
else list(mock_state.libraries.keys())
)
self._set_specific_folders(user_id, enabled_libs)
# Set default policy
@@ -187,6 +231,24 @@ class MockJellyfinClient:
}
self.set_policy(user_id, default_policy)
# Create user in database (like real clients do)
from app.models import Invitation
from app.services.expiry import calculate_user_expiry
inv = Invitation.query.filter_by(code=code).first()
expires = calculate_user_expiry(inv, self.server_id) if inv else None
self._create_user_with_identity_linking(
{
"username": username,
"email": email,
"token": user_id,
"code": code,
"expires": expires,
"server_id": self.server_id,
}
)
return True, ""
except Exception as e:
@@ -205,7 +267,25 @@ class MockPlexClient:
def __init__(self, url: str = None, token: str = None, **kwargs):
self.url = url or "http://localhost:32400"
self.token = token or "mock-plex-token"
self.server_id = kwargs.get("server_id", 2)
self.server_id = kwargs.get("server_id")
def _create_user_with_identity_linking(self, user_kwargs: dict):
"""Mock version of the database user creation."""
from app.extensions import db
from app.models import User
# Check if this is part of a multi-server invitation
code = user_kwargs.get("code")
if code:
existing_user = User.query.filter_by(code=code).first()
if existing_user and existing_user.identity_id:
# Link to existing identity from same invitation
user_kwargs["identity_id"] = existing_user.identity_id
new_user = User(**user_kwargs)
db.session.add(new_user)
db.session.flush() # Get the ID immediately
return new_user
def validate_connection(self) -> tuple[bool, str]:
"""Simulate connection validation."""
@@ -225,6 +305,12 @@ class MockPlexClient:
self, username: str, password: str, confirm: str, email: str, code: str
) -> tuple[bool, str]:
"""Mock Plex invitation join process."""
# Check connection health first
if not mock_state.connection_healthy:
return False, "Plex server unreachable"
if not mock_state.api_key_valid:
return False, "Invalid Plex token"
if password != confirm:
return False, "Passwords do not match"
if "@" not in email:
@@ -243,6 +329,25 @@ class MockPlexClient:
mock_state.libraries.keys()
), # Plex gets all libraries by default
)
# Create user in database (like real clients do)
from app.models import Invitation
from app.services.expiry import calculate_user_expiry
inv = Invitation.query.filter_by(code=code).first()
expires = calculate_user_expiry(inv, self.server_id) if inv else None
self._create_user_with_identity_linking(
{
"username": username,
"email": email,
"token": user_id,
"code": code,
"expires": expires,
"server_id": self.server_id,
}
)
return True, ""
except Exception as e:
@@ -261,7 +366,25 @@ class MockAudiobookshelfClient:
def __init__(self, url: str = None, token: str = None, **kwargs):
self.url = url or "http://localhost:13378"
self.token = token or "mock-abs-token"
self.server_id = kwargs.get("server_id", 3)
self.server_id = kwargs.get("server_id")
def _create_user_with_identity_linking(self, user_kwargs: dict):
"""Mock version of the database user creation."""
from app.extensions import db
from app.models import User
# Check if this is part of a multi-server invitation
code = user_kwargs.get("code")
if code:
existing_user = User.query.filter_by(code=code).first()
if existing_user and existing_user.identity_id:
# Link to existing identity from same invitation
user_kwargs["identity_id"] = existing_user.identity_id
new_user = User(**user_kwargs)
db.session.add(new_user)
db.session.flush() # Get the ID immediately
return new_user
def validate_connection(self) -> tuple[bool, str]:
if not mock_state.connection_healthy:
@@ -275,6 +398,10 @@ class MockAudiobookshelfClient:
def _do_join(
self, username: str, password: str, confirm: str, email: str, code: str
) -> tuple[bool, str]:
# Check connection health first
if not mock_state.connection_healthy:
return False, "Audiobookshelf server unreachable"
if password != confirm:
return False, "Passwords do not match"
if username in mock_state.create_user_failures:
@@ -288,6 +415,25 @@ class MockAudiobookshelfClient:
email=email,
policy={"isActive": True, "canDownload": True},
)
# Create user in database (like real clients do)
from app.models import Invitation
from app.services.expiry import calculate_user_expiry
inv = Invitation.query.filter_by(code=code).first()
expires = calculate_user_expiry(inv, self.server_id) if inv else None
self._create_user_with_identity_linking(
{
"username": username,
"email": email,
"token": user_id,
"code": code,
"expires": expires,
"server_id": self.server_id,
}
)
return True, ""
except Exception as e:

View File

@@ -11,7 +11,7 @@ def test_debug_unlimited_invitation(app):
"""Debug test for unlimited invitation validation."""
with app.app_context():
# Create used unlimited invitation
invite = Invitation(code="UNLIMITED123", used=True, unlimited=True)
invite = Invitation(code="UNLIMIT123", used=True, unlimited=True)
db.session.add(invite)
db.session.commit()

View File

@@ -55,7 +55,7 @@ class TestInvitationValidation:
# Test validation
is_valid, message = is_invite_valid(invite.code)
assert is_valid
assert message == ""
assert message == "okay"
def test_expired_invitation(self, app):
"""Test that expired invitations are rejected."""
@@ -92,7 +92,7 @@ class TestInvitationValidation:
"""Test that used unlimited invitations are still valid."""
with app.app_context():
# Create used unlimited invitation
invite = Invitation(code="UNLIMITED12", used=True, unlimited=True)
invite = Invitation(code="UNLIMIT123", used=True, unlimited=True)
db.session.add(invite)
db.session.commit()
@@ -113,11 +113,7 @@ class TestSingleServerInvitations:
def test_successful_jellyfin_invitation(self, mock_get_client, app):
"""Test successful invitation process for Jellyfin server."""
with app.app_context():
# Setup mock client
mock_client = create_mock_client("jellyfin", server_id=1)
mock_get_client.return_value = mock_client
# Create server and invitation
# Create server and invitation first
server = MediaServer(
name="Test Jellyfin",
server_type="jellyfin",
@@ -127,6 +123,10 @@ class TestSingleServerInvitations:
db.session.add(server)
db.session.flush()
# Setup mock client with correct server ID
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(
code="JELLYFIN12",
duration="30",
@@ -168,9 +168,6 @@ class TestSingleServerInvitations:
def test_plex_invitation_with_oauth(self, mock_get_client, app):
"""Test Plex invitation that might use OAuth flow."""
with app.app_context():
mock_client = create_mock_client("plex", server_id=2)
mock_get_client.return_value = mock_client
# Create Plex server
server = MediaServer(
name="Test Plex",
@@ -181,6 +178,9 @@ class TestSingleServerInvitations:
db.session.add(server)
db.session.flush()
mock_client = create_mock_client("plex", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(
code="PLEX123",
plex_home=True,
@@ -215,12 +215,6 @@ class TestSingleServerInvitations:
def test_server_connection_failure(self, mock_get_client, app):
"""Test handling of server connection failures."""
with app.app_context():
# Simulate connection failure
simulate_server_failure()
mock_client = create_mock_client("jellyfin", server_id=1)
mock_get_client.return_value = mock_client
# Create server and invitation
server = MediaServer(
name="Failing Server",
@@ -231,6 +225,12 @@ class TestSingleServerInvitations:
db.session.add(server)
db.session.flush()
# Simulate connection failure
simulate_server_failure()
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(code="FAIL123")
invite.servers = [server]
db.session.add(invite)
@@ -258,12 +258,6 @@ class TestSingleServerInvitations:
def test_user_creation_failure(self, mock_get_client, app):
"""Test handling when user creation fails on media server."""
with app.app_context():
# Simulate user creation failure
simulate_user_creation_failure(["baduser"])
mock_client = create_mock_client("jellyfin", server_id=1)
mock_get_client.return_value = mock_client
# Create server and invitation
server = MediaServer(
name="Test Server",
@@ -274,6 +268,12 @@ class TestSingleServerInvitations:
db.session.add(server)
db.session.flush()
# Simulate user creation failure
simulate_user_creation_failure(["baduser"])
mock_client = create_mock_client("jellyfin", server_id=server.id)
mock_get_client.return_value = mock_client
invite = Invitation(code="BADUSER123")
invite.servers = [server]
db.session.add(invite)
@@ -591,7 +591,9 @@ class TestInvitationExpiry:
assert db_user.expires is not None
# Should expire in approximately 7 days
expected_expiry = datetime.now(UTC) + timedelta(days=7)
expected_expiry = datetime.now() + timedelta(
days=7
) # Use naive datetime like the database
time_diff = abs((db_user.expires - expected_expiry).total_seconds())
assert time_diff < 60 # Within 1 minute of expected

View File

@@ -29,7 +29,7 @@ class TestInvitationPerformance:
"""Setup for each test."""
setup_mock_servers()
@patch("app.services.media.service.get_client_for_media_server")
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_single_invitation_processing_time(self, mock_get_client, app):
"""Test time to process a single invitation."""
with app.app_context():
@@ -74,7 +74,7 @@ class TestInvitationPerformance:
f"Processing took {processing_time:.3f}s, expected < 1.0s"
)
@patch("app.services.media.service.get_client_for_media_server")
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_concurrent_invitation_processing(self, mock_get_client, app):
"""Test processing multiple invitations concurrently."""
with app.app_context():
@@ -141,7 +141,7 @@ class TestInvitationPerformance:
created_users = get_mock_state().users
assert len(created_users) == num_users
@patch("app.services.media.service.get_client_for_media_server")
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_multi_server_invitation_performance(self, mock_get_client, app):
"""Test performance of multi-server invitation processing."""
with app.app_context():
@@ -207,7 +207,7 @@ class TestInvitationLoadTesting:
"""Setup for each test."""
setup_mock_servers()
@patch("app.services.media.service.get_client_for_media_server")
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_invitation_validation_performance(self, mock_get_client, app):
"""Test performance of invitation validation under load."""
with app.app_context():
@@ -255,7 +255,7 @@ class TestInvitationLoadTesting:
)
assert max_time < 0.05, f"Max validation time {max_time:.4f}s too slow"
@patch("app.services.media.service.get_client_for_media_server")
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_database_performance_under_load(self, mock_get_client, app):
"""Test database performance with many invitation records."""
with app.app_context():
@@ -316,7 +316,7 @@ class TestInvitationLoadTesting:
class TestInvitationMemoryUsage:
"""Test memory usage during invitation processing."""
@patch("app.services.media.service.get_client_for_media_server")
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_memory_efficient_processing(self, mock_get_client, app):
"""Test that invitation processing doesn't leak memory."""
import gc
@@ -375,7 +375,7 @@ class TestInvitationErrorRecovery:
"""Setup for each test."""
setup_mock_servers()
@patch("app.services.media.service.get_client_for_media_server")
@patch("app.services.invitation_manager.get_client_for_media_server")
def test_recovery_from_server_failures(self, mock_get_client, app):
"""Test system recovery when servers fail under load."""
with app.app_context():

51
uv.lock generated
View File

@@ -840,6 +840,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 },
]
[[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 }
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 },
]
[[package]]
name = "pytest-flask"
version = "1.3.0"
@@ -866,6 +879,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923 },
]
[[package]]
name = "pytest-playwright"
version = "0.7.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/e3/47/38e292ad92134a00ea05e6fc4fc44577baaa38b0922ab7ea56312b7a6663/pytest_playwright-0.7.0.tar.gz", hash = "sha256:b3f2ea514bbead96d26376fac182f68dcd6571e7cb41680a89ff1673c05d60b6", size = 16666 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/96/5f8a4545d783674f3de33f0ebc4db16cc76ce77a4c404d284f43f09125e3/pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2", size = 16618 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -899,6 +927,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834 },
]
[[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 }
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 },
]
[[package]]
name = "pytz"
version = "2025.2"
@@ -1109,6 +1149,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/55/ba2546ab09a6adebc521bf3974440dc1d8c06ed342cceb30ed62a8858835/sqlalchemy-2.0.42-py3-none-any.whl", hash = "sha256:defcdff7e661f0043daa381832af65d616e060ddb54d3fe4476f51df7eaa1835", size = 1922072 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 },
]
[[package]]
name = "ty"
version = "0.0.1a17"
@@ -1257,6 +1306,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-flask" },
{ name = "pytest-mock" },
{ name = "pytest-playwright" },
]
[package.metadata]
@@ -1299,6 +1349,7 @@ dev = [
{ name = "pytest", specifier = ">=8.3.5" },
{ name = "pytest-flask", specifier = ">=1.3.0" },
{ name = "pytest-mock", specifier = ">=3.14.1" },
{ name = "pytest-playwright", specifier = ">=0.6.2" },
]
[[package]]