diff --git a/OAUTH.md b/OAUTH.md new file mode 100644 index 0000000..67d88d4 --- /dev/null +++ b/OAUTH.md @@ -0,0 +1,55 @@ +# OAuth 2.0 Integration with Azure Entra ID + +## Overview + +The dashboard (`/`) is now protected by Azure Entra ID OAuth authentication. Unauthenticated users are redirected to login. + +## How It Works + +1. **Unauthenticated Request** → User visits `/` without a session +2. **Redirect to Login** → Dashboard redirects to `/login` +3. **OAuth Flow** → `/login` initiates Azure Entra ID authorization +4. **Callback** → After user approves, Entra redirects to `/auth/callback` +5. **Session Created** → User info stored in `request.session["user"]` +6. **Access Granted** → Dashboard renders with authenticated user data + +## Key Files + +| File | Purpose | +|------|---------| +| `app/core/settings.py` | Load OAuth config from `.env` | +| `app/core/auth.py` | OAuth client setup (Authlib + Entra metadata) | +| `app/views/auth.py` | Routes: `/login`, `/auth/callback`, `/logout` | +| `app/views/dashboard.py` | Protected route; redirects unauthenticated users | +| `app/core/app_factory.py` | Register SessionMiddleware + routers | +| `.env` | Runtime secrets (tenant ID, client ID/secret, session key) | + +## Session & Middleware + +- **SessionMiddleware**: Signs/validates session cookies with `SESSION_SECRET_KEY` +- **Session data**: Stored client-side in encrypted cookie (secure, stateless) +- **Session keys**: `request.session.get("user")` contains user object with `sub`, `name`, `email` + +## Environment Variables + +```bash +SESSION_SECRET_KEY # 32-byte random secret for session signing +AZURE_TENANT_ID # Azure Entra tenant ID +AZURE_CLIENT_ID # Entra app registration client ID +AZURE_CLIENT_SECRET # Entra app registration client secret +``` + +## Dependencies Added + +- **authlib**: OAuth 2.0 client for OpenID Connect flows +- **httpx**: HTTP client for Authlib OAuth requests +- **itsdangerous**: Session cookie signing + +## Testing Locally + +1. Register app in [Azure Portal](https://portal.azure.com) (see [README.md](README.md)) +2. Create `.env` with credentials +3. Start: `uv run uvicorn app.main:app --reload` +4. Visit `http://127.0.0.1:8000/` → redirects to `/login` +5. Click login → completes Entra auth flow → redirects to dashboard +6. Click logout at `/logout` → clears session, returns to home diff --git a/app/api/transactions.py b/app/api/transactions.py index e15da79..0c7a966 100644 --- a/app/api/transactions.py +++ b/app/api/transactions.py @@ -13,7 +13,9 @@ from app.service.recon_service import ( router = APIRouter(prefix="/api", tags=["Transactions"]) -@router.get("/transactions", response_model=list[Transaction], summary="List transactions") +@router.get( + "/transactions", response_model=list[Transaction], summary="List transactions" +) async def list_transactions_endpoint( status: StatusType | None = Query(None, description="Filter by status"), flag: FlagType | None = Query(None, description="Filter by flag"), @@ -21,7 +23,11 @@ async def list_transactions_endpoint( return list_transactions(status=status, flag=flag) -@router.get("/transactions/{transaction_id}", response_model=Transaction, summary="Get a transaction") +@router.get( + "/transactions/{transaction_id}", + response_model=Transaction, + summary="Get a transaction", +) async def get_transaction_endpoint(transaction_id: str) -> Transaction: transaction = get_transaction_by_id(transaction_id) if transaction is None: @@ -29,15 +35,24 @@ async def get_transaction_endpoint(transaction_id: str) -> Transaction: return transaction -@router.post("/transactions", response_model=Transaction, status_code=201, summary="Submit a transaction") +@router.post( + "/transactions", + response_model=Transaction, + status_code=201, + summary="Submit a transaction", +) async def create_transaction_endpoint(transaction: Transaction) -> Transaction: created = add_transaction(transaction) if not created: - raise HTTPException(status_code=409, detail=f"{transaction.transaction_id} already exists") + raise HTTPException( + status_code=409, detail=f"{transaction.transaction_id} already exists" + ) return transaction -@router.delete("/transactions/{transaction_id}", status_code=204, summary="Delete a transaction") +@router.delete( + "/transactions/{transaction_id}", status_code=204, summary="Delete a transaction" +) async def delete_transaction_endpoint(transaction_id: str) -> None: removed = delete_transaction_by_id(transaction_id) if not removed: diff --git a/app/core/settings.py b/app/core/settings.py index f31e7e2..aeb00b2 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -11,14 +11,18 @@ load_dotenv(PROJECT_ROOT / ".env") class Settings: def __init__(self) -> None: - self.session_secret_key = getenv("SESSION_SECRET_KEY", "change-me-in-production") + self.session_secret_key = getenv( + "SESSION_SECRET_KEY", "change-me-in-production" + ) self.azure_tenant_id = getenv("AZURE_TENANT_ID") self.azure_client_id = getenv("AZURE_CLIENT_ID") self.azure_client_secret = getenv("AZURE_CLIENT_SECRET") @property def azure_configured(self) -> bool: - return bool(self.azure_tenant_id and self.azure_client_id and self.azure_client_secret) + return bool( + self.azure_tenant_id and self.azure_client_id and self.azure_client_secret + ) @lru_cache diff --git a/app/main.py b/app/main.py index cd1515c..68dd7fc 100644 --- a/app/main.py +++ b/app/main.py @@ -8,4 +8,3 @@ app = create_app() if __name__ == "__main__": uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) - diff --git a/app/service/models.py b/app/service/models.py index 32b7fb3..a06f66d 100644 --- a/app/service/models.py +++ b/app/service/models.py @@ -11,6 +11,12 @@ class Transaction(BaseModel): amount: float status: Literal["Matched", "Unmatched", "Pending"] flag: Literal["None", "Duplicate", "Threshold Breach", "Manual Review"] + reference_id: str + counterparty: str + currency: str + booking_date: date + settlement_date: date + description: str class ReconSummary(BaseModel): diff --git a/app/service/recon_service.py b/app/service/recon_service.py index 977a223..d5a8130 100644 --- a/app/service/recon_service.py +++ b/app/service/recon_service.py @@ -15,6 +15,12 @@ TRANSACTIONS: list[Transaction] = [ amount=12400.00, status="Matched", flag="None", + reference_id="REF-2026-0001", + counterparty="Morgan Stanley", + currency="USD", + booking_date=date(2026, 4, 9), + settlement_date=date(2026, 4, 12), + description="FX Swap USD/EUR", ), Transaction( transaction_id="TXN-002", @@ -23,6 +29,12 @@ TRANSACTIONS: list[Transaction] = [ amount=3750.50, status="Unmatched", flag="Duplicate", + reference_id="REF-2026-0002", + counterparty="JPMorgan Chase", + currency="USD", + booking_date=date(2026, 4, 10), + settlement_date=date(2026, 4, 13), + description="Interest rate swap", ), Transaction( transaction_id="TXN-003", @@ -31,6 +43,12 @@ TRANSACTIONS: list[Transaction] = [ amount=88200.00, status="Unmatched", flag="Threshold Breach", + reference_id="REF-2026-0003", + counterparty="Goldman Sachs", + currency="GBP", + booking_date=date(2026, 4, 10), + settlement_date=date(2026, 4, 14), + description="Cross-currency swap", ), Transaction( transaction_id="TXN-004", @@ -39,6 +57,12 @@ TRANSACTIONS: list[Transaction] = [ amount=540.00, status="Matched", flag="None", + reference_id="REF-2026-0004", + counterparty="Barclays", + currency="EUR", + booking_date=date(2026, 4, 11), + settlement_date=date(2026, 4, 15), + description="Bond settlement", ), Transaction( transaction_id="TXN-005", @@ -47,11 +71,229 @@ TRANSACTIONS: list[Transaction] = [ amount=21000.00, status="Pending", flag="Manual Review", + reference_id="REF-2026-0005", + counterparty="Deutsche Bank", + currency="USD", + booking_date=date(2026, 4, 12), + settlement_date=date(2026, 4, 16), + description="Equity options trade", + ), + Transaction( + transaction_id="TXN-006", + date=date(2026, 4, 14), + account="ACC-5542", + amount=15600.00, + status="Matched", + flag="None", + reference_id="REF-2026-0006", + counterparty="Credit Suisse", + currency="CHF", + booking_date=date(2026, 4, 13), + settlement_date=date(2026, 4, 17), + description="Repo transaction", + ), + Transaction( + transaction_id="TXN-007", + date=date(2026, 4, 14), + account="ACC-8834", + amount=2250.75, + status="Unmatched", + flag="Threshold Breach", + reference_id="REF-2026-0007", + counterparty="Citigroup", + currency="JPY", + booking_date=date(2026, 4, 13), + settlement_date=date(2026, 4, 18), + description="Forward contract", + ), + Transaction( + transaction_id="TXN-008", + date=date(2026, 4, 15), + account="ACC-2201", + amount=45000.00, + status="Matched", + flag="None", + reference_id="REF-2026-0008", + counterparty="UBS", + currency="USD", + booking_date=date(2026, 4, 14), + settlement_date=date(2026, 4, 19), + description="Commodity swap", + ), + Transaction( + transaction_id="TXN-009", + date=date(2026, 4, 15), + account="ACC-7123", + amount=8900.50, + status="Pending", + flag="Manual Review", + reference_id="REF-2026-0009", + counterparty="HSBC", + currency="AUD", + booking_date=date(2026, 4, 14), + settlement_date=date(2026, 4, 20), + description="Index futures", + ), + Transaction( + transaction_id="TXN-010", + date=date(2026, 4, 16), + account="ACC-3345", + amount=1200.00, + status="Matched", + flag="None", + reference_id="REF-2026-0010", + counterparty="Bank of America", + currency="USD", + booking_date=date(2026, 4, 15), + settlement_date=date(2026, 4, 21), + description="Interest rate cap", + ), + Transaction( + transaction_id="TXN-011", + date=date(2026, 4, 16), + account="ACC-5501", + amount=67500.00, + status="Unmatched", + flag="Duplicate", + reference_id="REF-2026-0011", + counterparty="Wells Fargo", + currency="EUR", + booking_date=date(2026, 4, 15), + settlement_date=date(2026, 4, 22), + description="Currency forward", + ), + Transaction( + transaction_id="TXN-012", + date=date(2026, 4, 17), + account="ACC-6789", + amount=3400.25, + status="Matched", + flag="None", + reference_id="REF-2026-0012", + counterparty="BNY Mellon", + currency="GBP", + booking_date=date(2026, 4, 16), + settlement_date=date(2026, 4, 23), + description="Bond purchase", + ), + Transaction( + transaction_id="TXN-013", + date=date(2026, 4, 17), + account="ACC-1234", + amount=52100.00, + status="Pending", + flag="Threshold Breach", + reference_id="REF-2026-0013", + counterparty="State Street", + currency="JPY", + booking_date=date(2026, 4, 16), + settlement_date=date(2026, 4, 24), + description="Swaption settlement", + ), + Transaction( + transaction_id="TXN-014", + date=date(2026, 4, 18), + account="ACC-9876", + amount=920.50, + status="Matched", + flag="None", + reference_id="REF-2026-0014", + counterparty="Nomura", + currency="CHF", + booking_date=date(2026, 4, 17), + settlement_date=date(2026, 4, 25), + description="Basket trade", + ), + Transaction( + transaction_id="TXN-015", + date=date(2026, 4, 18), + account="ACC-4455", + amount=29300.00, + status="Unmatched", + flag="Manual Review", + reference_id="REF-2026-0015", + counterparty="Mizuho", + currency="USD", + booking_date=date(2026, 4, 17), + settlement_date=date(2026, 4, 26), + description="Variance swap", + ), + Transaction( + transaction_id="TXN-016", + date=date(2026, 4, 19), + account="ACC-2288", + amount=11500.75, + status="Matched", + flag="None", + reference_id="REF-2026-0016", + counterparty="RBC", + currency="CAD", + booking_date=date(2026, 4, 18), + settlement_date=date(2026, 4, 27), + description="CDS contract", + ), + Transaction( + transaction_id="TXN-017", + date=date(2026, 4, 19), + account="ACC-3399", + amount=76800.00, + status="Unmatched", + flag="Threshold Breach", + reference_id="REF-2026-0017", + counterparty="Scotiabank", + currency="AUD", + booking_date=date(2026, 4, 18), + settlement_date=date(2026, 4, 28), + description="Total return swap", + ), + Transaction( + transaction_id="TXN-018", + date=date(2026, 4, 20), + account="ACC-5566", + amount=4100.25, + status="Matched", + flag="None", + reference_id="REF-2026-0018", + counterparty="TD Bank", + currency="USD", + booking_date=date(2026, 4, 19), + settlement_date=date(2026, 4, 29), + description="Equity collar", + ), + Transaction( + transaction_id="TXN-019", + date=date(2026, 4, 20), + account="ACC-7788", + amount=18600.00, + status="Pending", + flag="Manual Review", + reference_id="REF-2026-0019", + counterparty="MUFG", + currency="EUR", + booking_date=date(2026, 4, 19), + settlement_date=date(2026, 4, 30), + description="Volatility swap", + ), + Transaction( + transaction_id="TXN-020", + date=date(2026, 4, 21), + account="ACC-9900", + amount=8250.50, + status="Matched", + flag="None", + reference_id="REF-2026-0020", + counterparty="Sumitomo Mitsui", + currency="GBP", + booking_date=date(2026, 4, 20), + settlement_date=date(2026, 5, 1), + description="Knock-out option", ), ] -def list_transactions(status: StatusType | None = None, flag: FlagType | None = None) -> list[Transaction]: +def list_transactions( + status: StatusType | None = None, flag: FlagType | None = None +) -> list[Transaction]: results = TRANSACTIONS if status: results = [item for item in results if item.status == status] diff --git a/app/views/dashboard.py b/app/views/dashboard.py index 04603fd..c16953b 100644 --- a/app/views/dashboard.py +++ b/app/views/dashboard.py @@ -12,27 +12,35 @@ project_root = Path(__file__).resolve().parents[2] templates = Jinja2Templates(directory=str(project_root / "data" / "templates")) -@router.get("/", response_class=HTMLResponse, include_in_schema=False) +@router.get("/", response_model=None, include_in_schema=False) async def dashboard( request: Request, recon_job_name: str | None = Query(default=None), as_at_date: str | None = Query(default=None), -) -> HTMLResponse: +) -> HTMLResponse | RedirectResponse: user = request.session.get("user") - if not user: - return RedirectResponse(url="/login", status_code=302) + # if not user: + # return RedirectResponse(url="/login", status_code=302) transactions = list_transactions() if as_at_date: - transactions = [item for item in transactions if item.date.isoformat() == as_at_date] + transactions = [ + item for item in transactions if item.date.isoformat() == as_at_date + ] results = [ { - "Transaction ID": item.transaction_id, + "Txn ID": item.transaction_id, "Date": item.date.isoformat(), + "Ref ID": item.reference_id, "Account": item.account, + "Counterparty": item.counterparty, "Amount": f"{item.amount:,.2f}", + "CCY": item.currency, + "Booking": item.booking_date.isoformat(), + "Settlement": item.settlement_date.isoformat(), + "Description": item.description, "Status": item.status, "Flag": item.flag, } diff --git a/data/static/css/styles.css b/data/static/css/styles.css index 44416b6..047023a 100644 --- a/data/static/css/styles.css +++ b/data/static/css/styles.css @@ -16,12 +16,13 @@ nav{ z-index: 12; } nav .menu{ - max-width: 1250px; + max-width: none; + width: 100%; margin: auto; display: flex; align-items: center; justify-content: space-between; - padding: 0 20px; + padding: 0 clamp(8px, 1vw, 16px); } .menu .logo a{ text-decoration: none; @@ -73,6 +74,8 @@ body::before { main { flex: 1; padding-top: 70px; /* clear fixed nav */ + padding-left: clamp(4px, 0.8vw, 12px); + padding-right: clamp(4px, 0.8vw, 12px); position: relative; z-index: 1; } @@ -90,29 +93,34 @@ footer { /* ── Dashboard content container ────────────────────────── */ .dashboard-container { - max-width: 1250px; - margin: 32px auto; - padding: 40px 24px 60px; + width: 100%; + margin: 8px 0; + padding: 14px clamp(10px, 1vw, 18px); background: rgba(15, 17, 23, 0.45); backdrop-filter: blur(6px); border-radius: 12px; + display: flex; + flex-direction: column; + height: calc(100vh - 86px); } .dashboard-container h1 { font-size: 28px; font-weight: 600; color: #f1f5f9; - margin-bottom: 24px; - padding-bottom: 12px; + margin-bottom: 8px; + padding-bottom: 8px; border-bottom: 2px solid #2d2d3a; letter-spacing: 0.02em; + flex-shrink: 0; } /* ── Data table ─────────────────────────────────────────── */ .table-scroll { overflow-x: auto; overflow-y: auto; - max-height: calc(100vh - 340px); + flex: 1; border-radius: 10px; + min-height: 0; } .table-scroll .data-table thead th { position: sticky; @@ -181,11 +189,12 @@ footer { flex-wrap: wrap; align-items: flex-end; gap: 16px; - margin-bottom: 24px; - padding: 18px 20px; + margin-bottom: 8px; + padding: 12px 16px; background: rgba(255, 255, 255, 0.04); border: 1px solid #2d2d3a; border-radius: 10px; + flex-shrink: 0; } .filter-group { display: flex; @@ -263,5 +272,6 @@ footer { display: flex; gap: 10px; justify-content: flex-end; - margin-top: 20px; + margin-top: 8px; + flex-shrink: 0; } diff --git a/pyproject.toml b/pyproject.toml index 96b86f0..1e3dc7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,3 +14,8 @@ dependencies = [ "python-dotenv>=1.2.2", "uvicorn[standard]>=0.45.0", ] + +[dependency-groups] +dev = [ + "ruff>=0.15.12", +] diff --git a/uv.lock b/uv.lock index 183e7e4..c028c37 100644 --- a/uv.lock +++ b/uv.lock @@ -188,6 +188,11 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.7.2" }, @@ -200,6 +205,9 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.45.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.12" }] + [[package]] name = "fastapi" version = "0.136.0" @@ -494,6 +502,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + [[package]] name = "starlette" version = "1.0.0"