From 51b6a3e08063452ff2d9d58e50120fab60ce2893 Mon Sep 17 00:00:00 2001 From: Matthieu B Date: Sun, 17 Aug 2025 18:18:21 +0200 Subject: [PATCH] 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 --- Dockerfile | 6 +- app/services/invites.py | 20 ++- pyproject.toml | 1 + pytest.ini | 5 +- tests/e2e/test_invitation_e2e.py | 18 +-- tests/mocks/media_server_mocks.py | 162 +++++++++++++++++++++++-- tests/test_debug_invitation.py | 2 +- tests/test_invitation_comprehensive.py | 48 ++++---- tests/test_invitation_performance.py | 14 +-- uv.lock | 51 ++++++++ 10 files changed, 273 insertions(+), 54 deletions(-) diff --git a/Dockerfile b/Dockerfile index c45d132d..2c835f6f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/services/invites.py b/app/services/invites.py index 3319221b..e0a4fedb 100644 --- a/app/services/invites.py +++ b/app/services/invites.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index a5f226bb..74c1d9fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/pytest.ini b/pytest.ini index baa20592..ef14bef7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,6 @@ [pytest] minversion = 6.0 -addopts = -ra -q +addopts = -ra -q --tb=short testpaths = tests -python_files = test_*.py \ No newline at end of file +python_files = test_*.py +# Playwright will be enabled via conftest.py for E2E tests only \ No newline at end of file diff --git a/tests/e2e/test_invitation_e2e.py b/tests/e2e/test_invitation_e2e.py index 365103ae..7ea08ef0 100644 --- a/tests/e2e/test_invitation_e2e.py +++ b/tests/e2e/test_invitation_e2e.py @@ -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 diff --git a/tests/mocks/media_server_mocks.py b/tests/mocks/media_server_mocks.py index e8c34af6..8a742964 100644 --- a/tests/mocks/media_server_mocks.py +++ b/tests/mocks/media_server_mocks.py @@ -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: diff --git a/tests/test_debug_invitation.py b/tests/test_debug_invitation.py index 5b7badb5..302d6337 100644 --- a/tests/test_debug_invitation.py +++ b/tests/test_debug_invitation.py @@ -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() diff --git a/tests/test_invitation_comprehensive.py b/tests/test_invitation_comprehensive.py index 1cd72eb4..274f429d 100644 --- a/tests/test_invitation_comprehensive.py +++ b/tests/test_invitation_comprehensive.py @@ -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 diff --git a/tests/test_invitation_performance.py b/tests/test_invitation_performance.py index 200f05a7..237f0a4a 100644 --- a/tests/test_invitation_performance.py +++ b/tests/test_invitation_performance.py @@ -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(): diff --git a/uv.lock b/uv.lock index 6845e596..b28b0154 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]