diff --git a/Makefile b/Makefile index e1bef2a..23d4023 100644 --- a/Makefile +++ b/Makefile @@ -14,3 +14,6 @@ test: migrate: # apply the bundled initial migration uv run alembic upgrade head + +fmt: + uv run black . diff --git a/alembic/env.py b/alembic/env.py index 8cb4373..bebd445 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -3,6 +3,7 @@ The DB URL and target metadata come from the application so migrations always match the models and the active configuration. """ + import asyncio from logging.config import fileConfig diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py index d02411d..bb098ff 100644 --- a/alembic/versions/0001_initial.py +++ b/alembic/versions/0001_initial.py @@ -4,6 +4,7 @@ Revision ID: 0001_initial Revises: Create Date: 2026-01-01 00:00:00.000000 """ + from collections.abc import Sequence import sqlalchemy as sa @@ -24,7 +25,9 @@ def upgrade() -> None: sa.Column("hashed_password", sa.String(length=255), nullable=False), sa.Column("full_name", sa.String(length=255), nullable=True), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column( + "is_superuser", sa.Boolean(), nullable=False, server_default=sa.false() + ), sa.Column( "created_at", sa.DateTime(timezone=True), @@ -64,7 +67,9 @@ def upgrade() -> None: sa.Column("role_id", sa.Integer(), nullable=False), sa.Column("permission_id", sa.Integer(), nullable=False), sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["permission_id"], ["permissions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["permission_id"], ["permissions.id"], ondelete="CASCADE" + ), sa.PrimaryKeyConstraint("role_id", "permission_id"), ) diff --git a/app/api/roles.py b/app/api/roles.py index 454806e..1410b74 100644 --- a/app/api/roles.py +++ b/app/api/roles.py @@ -52,7 +52,9 @@ async def get_role( ): role = await role_crud.get(db, role_id) if role is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) return role @@ -65,7 +67,9 @@ async def update_role( ): role = await role_crud.get(db, role_id) if role is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) return await role_crud.update(db, role, payload.model_dump(exclude_unset=True)) @@ -77,5 +81,7 @@ async def delete_role( ): role = await role_crud.get(db, role_id) if role is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" + ) await role_crud.delete(db, role) diff --git a/app/api/users.py b/app/api/users.py index 5956b25..57b0500 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -53,7 +53,9 @@ async def get_user( ): user = await user_crud.get(db, user_id) if user is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) return user @@ -66,7 +68,9 @@ async def update_user( ): user = await user_crud.get(db, user_id) if user is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) return await user_crud.update(db, user, payload.model_dump(exclude_unset=True)) @@ -78,5 +82,7 @@ async def delete_user( ): user = await user_crud.get(db, user_id) if user is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) await user_crud.delete(db, user) diff --git a/app/core/config.py b/app/core/config.py index 4264385..edb9c95 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -3,6 +3,7 @@ Settings are read from environment variables first, then from a `.env` file. Environment variables always take precedence over the file. """ + from functools import lru_cache from pydantic_settings import BaseSettings, SettingsConfigDict diff --git a/app/core/database.py b/app/core/database.py index 6d55599..8ef0b6a 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -2,6 +2,7 @@ Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL. """ + from collections.abc import AsyncGenerator from sqlalchemy.ext.asyncio import ( diff --git a/app/core/security.py b/app/core/security.py index 68c906e..d4fb4e7 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,4 +1,5 @@ """Password hashing and JWT token helpers.""" + import hashlib import uuid from datetime import UTC, datetime, timedelta @@ -14,6 +15,7 @@ REFRESH_TOKEN_TYPE = "refresh" # --- Passwords --------------------------------------------------------------- + def hash_password(password: str) -> str: return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() @@ -27,6 +29,7 @@ def verify_password(password: str, hashed: str) -> bool: # --- JWT --------------------------------------------------------------------- + def _create_token( subject: str | int, expires_delta: timedelta, diff --git a/app/crud/role.py b/app/crud/role.py index 57a7c08..0def107 100644 --- a/app/crud/role.py +++ b/app/crud/role.py @@ -66,6 +66,7 @@ async def delete(db: AsyncSession, role: Role) -> None: # --- Permissions ------------------------------------------------------------- + async def list_permissions(db: AsyncSession) -> list[Permission]: result = await db.execute(select(Permission).order_by(Permission.name)) return list(result.scalars().all()) diff --git a/app/deps.py b/app/deps.py index 40b53f1..b8eb288 100644 --- a/app/deps.py +++ b/app/deps.py @@ -1,4 +1,5 @@ """Shared FastAPI dependencies: DB session, current user, RBAC enforcement.""" + import jwt from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer @@ -44,6 +45,7 @@ async def _user_from_token(token: str | None, db: AsyncSession) -> User | None: # --- JSON API auth ----------------------------------------------------------- + async def get_current_user( request: Request, bearer: str | None = Depends(oauth2_scheme), @@ -86,6 +88,7 @@ def require_superuser(user: User = Depends(get_current_user)) -> User: # --- Web (HTMX) auth --------------------------------------------------------- + async def get_current_web_user( request: Request, db: AsyncSession = Depends(get_db), diff --git a/app/models/__init__.py b/app/models/__init__.py index 0447029..035eee7 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ """Import all models so they register with ``Base.metadata``.""" + from app.models.associations import role_permissions, user_roles from app.models.permission import Permission from app.models.refresh_token import RefreshToken diff --git a/app/models/associations.py b/app/models/associations.py index f4b9e5f..c47eb68 100644 --- a/app/models/associations.py +++ b/app/models/associations.py @@ -1,4 +1,5 @@ """Many-to-many association tables for RBAC.""" + from sqlalchemy import Column, ForeignKey, Table from app.core.database import Base @@ -14,5 +15,9 @@ role_permissions = Table( "role_permissions", Base.metadata, Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True), - Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True), + Column( + "permission_id", + ForeignKey("permissions.id", ondelete="CASCADE"), + primary_key=True, + ), ) diff --git a/app/services/auth.py b/app/services/auth.py index 1d3d114..cb73f7e 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,4 +1,5 @@ """Token issuance / rotation shared by the JSON API and the HTMX UI.""" + import jwt from sqlalchemy.ext.asyncio import AsyncSession @@ -30,7 +31,9 @@ class RefreshError(Exception): pass -async def rotate_refresh_token(db: AsyncSession, raw_refresh: str) -> tuple[str, str, User]: +async def rotate_refresh_token( + db: AsyncSession, raw_refresh: str +) -> tuple[str, str, User]: """Validate a refresh token, revoke it, and issue a fresh pair. Raises ``RefreshError`` if the token is invalid, expired, or already revoked. diff --git a/app/web/admin.py b/app/web/admin.py index c472f3e..55572c3 100644 --- a/app/web/admin.py +++ b/app/web/admin.py @@ -1,4 +1,5 @@ """HTMX-driven admin dashboard for users and roles.""" + from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse, Response from sqlalchemy.ext.asyncio import AsyncSession @@ -34,6 +35,7 @@ async def admin_home(): # --- Users ------------------------------------------------------------------- + @router.get("/users", response_class=HTMLResponse) async def users_page( request: Request, @@ -74,7 +76,10 @@ async def create_user( return templates.TemplateResponse( request, "admin/_users_table.html", - {"users": await user_crud.list_users(db), "roles": await role_crud.list_roles(db)}, + { + "users": await user_crud.list_users(db), + "roles": await role_crud.list_roles(db), + }, ) @@ -152,6 +157,7 @@ async def delete_user( # --- Roles ------------------------------------------------------------------- + @router.get("/roles", response_class=HTMLResponse) async def roles_page( request: Request, diff --git a/app/web/pages.py b/app/web/pages.py index 4f9f948..f9fe769 100644 --- a/app/web/pages.py +++ b/app/web/pages.py @@ -30,9 +30,7 @@ async def dashboard( request: Request, user: User = Depends(get_current_web_user), ): - return templates.TemplateResponse( - request, "dashboard.html", {"user": user} - ) + return templates.TemplateResponse(request, "dashboard.html", {"user": user}) @router.get("/login", response_class=HTMLResponse) diff --git a/pyproject.toml b/pyproject.toml index c5cf258..8c00649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dev = [ "pytest-asyncio>=0.23", "httpx>=0.27", "ruff>=0.5", + "black>=24", ] [tool.uv] diff --git a/scripts/seed.py b/scripts/seed.py index f3d96e0..9bb4aa6 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -6,6 +6,7 @@ settings (env vars or .env). Usage: uv run python -m scripts.seed """ + import asyncio from app.core.config import settings @@ -38,7 +39,9 @@ async def seed() -> None: for name, description in PERMISSIONS: perm = await role_crud.get_permission_by_name(db, name) if perm is None: - perm = await role_crud.create_permission(db, name=name, description=description) + perm = await role_crud.create_permission( + db, name=name, description=description + ) perms[name] = perm # Roles diff --git a/tests/conftest.py b/tests/conftest.py index fcd0b45..abcffab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,7 +32,9 @@ async def seeded(db_sessionmaker): async with db_sessionmaker() as db: perms = {} for name, desc in PERMISSIONS: - perms[name] = await role_crud.create_permission(db, name=name, description=desc) + perms[name] = await role_crud.create_permission( + db, name=name, description=desc + ) roles = {} for role_name, perm_names in ROLES.items(): roles[role_name] = await role_crud.create( diff --git a/tests/test_auth.py b/tests/test_auth.py index 194b779..be6f8c0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2,7 +2,11 @@ async def test_register_login_me_refresh_logout(client, seeded): # Register resp = await client.post( "/api/auth/register", - json={"email": "alice@example.com", "password": "secret123", "full_name": "Alice"}, + json={ + "email": "alice@example.com", + "password": "secret123", + "full_name": "Alice", + }, ) assert resp.status_code == 201, resp.text assert resp.json()["email"] == "alice@example.com" @@ -17,7 +21,9 @@ async def test_register_login_me_refresh_logout(client, seeded): access, refresh = tokens["access_token"], tokens["refresh_token"] # /me with bearer token - resp = await client.get("/api/users/me", headers={"Authorization": f"Bearer {access}"}) + resp = await client.get( + "/api/users/me", headers={"Authorization": f"Bearer {access}"} + ) assert resp.status_code == 200 assert resp.json()["email"] == "alice@example.com" diff --git a/uv.lock b/uv.lock index 90d326c..78996e0 100644 --- a/uv.lock +++ b/uv.lock @@ -174,6 +174,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, ] +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -263,6 +300,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "black" }, { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -275,6 +313,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.13" }, { name = "asyncpg", specifier = ">=0.29" }, { name = "bcrypt", specifier = ">=4.1" }, + { name = "black", marker = "extra == 'dev'", specifier = ">=24" }, { name = "fastapi", specifier = ">=0.111" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "jinja2", specifier = ">=3.1" }, @@ -563,6 +602,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -572,6 +620,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -782,6 +848,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"