feat: Enhance transaction model and dashboard with Azure OAuth integration

This commit is contained in:
2026-05-10 22:36:13 +12:00
parent d50c1c5bba
commit 9130629b58
10 changed files with 403 additions and 26 deletions
+55
View File
@@ -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
+20 -5
View File
@@ -13,7 +13,9 @@ from app.service.recon_service import (
router = APIRouter(prefix="/api", tags=["Transactions"]) 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( async def list_transactions_endpoint(
status: StatusType | None = Query(None, description="Filter by status"), status: StatusType | None = Query(None, description="Filter by status"),
flag: FlagType | None = Query(None, description="Filter by flag"), 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) 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: async def get_transaction_endpoint(transaction_id: str) -> Transaction:
transaction = get_transaction_by_id(transaction_id) transaction = get_transaction_by_id(transaction_id)
if transaction is None: if transaction is None:
@@ -29,15 +35,24 @@ async def get_transaction_endpoint(transaction_id: str) -> Transaction:
return 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: async def create_transaction_endpoint(transaction: Transaction) -> Transaction:
created = add_transaction(transaction) created = add_transaction(transaction)
if not created: 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 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: async def delete_transaction_endpoint(transaction_id: str) -> None:
removed = delete_transaction_by_id(transaction_id) removed = delete_transaction_by_id(transaction_id)
if not removed: if not removed:
+6 -2
View File
@@ -11,14 +11,18 @@ load_dotenv(PROJECT_ROOT / ".env")
class Settings: class Settings:
def __init__(self) -> None: 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_tenant_id = getenv("AZURE_TENANT_ID")
self.azure_client_id = getenv("AZURE_CLIENT_ID") self.azure_client_id = getenv("AZURE_CLIENT_ID")
self.azure_client_secret = getenv("AZURE_CLIENT_SECRET") self.azure_client_secret = getenv("AZURE_CLIENT_SECRET")
@property @property
def azure_configured(self) -> bool: 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 @lru_cache
-1
View File
@@ -8,4 +8,3 @@ app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
+6
View File
@@ -11,6 +11,12 @@ class Transaction(BaseModel):
amount: float amount: float
status: Literal["Matched", "Unmatched", "Pending"] status: Literal["Matched", "Unmatched", "Pending"]
flag: Literal["None", "Duplicate", "Threshold Breach", "Manual Review"] 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): class ReconSummary(BaseModel):
+243 -1
View File
@@ -15,6 +15,12 @@ TRANSACTIONS: list[Transaction] = [
amount=12400.00, amount=12400.00,
status="Matched", status="Matched",
flag="None", 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(
transaction_id="TXN-002", transaction_id="TXN-002",
@@ -23,6 +29,12 @@ TRANSACTIONS: list[Transaction] = [
amount=3750.50, amount=3750.50,
status="Unmatched", status="Unmatched",
flag="Duplicate", 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(
transaction_id="TXN-003", transaction_id="TXN-003",
@@ -31,6 +43,12 @@ TRANSACTIONS: list[Transaction] = [
amount=88200.00, amount=88200.00,
status="Unmatched", status="Unmatched",
flag="Threshold Breach", 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(
transaction_id="TXN-004", transaction_id="TXN-004",
@@ -39,6 +57,12 @@ TRANSACTIONS: list[Transaction] = [
amount=540.00, amount=540.00,
status="Matched", status="Matched",
flag="None", 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(
transaction_id="TXN-005", transaction_id="TXN-005",
@@ -47,11 +71,229 @@ TRANSACTIONS: list[Transaction] = [
amount=21000.00, amount=21000.00,
status="Pending", status="Pending",
flag="Manual Review", 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 results = TRANSACTIONS
if status: if status:
results = [item for item in results if item.status == status] results = [item for item in results if item.status == status]
+14 -6
View File
@@ -12,27 +12,35 @@ project_root = Path(__file__).resolve().parents[2]
templates = Jinja2Templates(directory=str(project_root / "data" / "templates")) 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( async def dashboard(
request: Request, request: Request,
recon_job_name: str | None = Query(default=None), recon_job_name: str | None = Query(default=None),
as_at_date: str | None = Query(default=None), as_at_date: str | None = Query(default=None),
) -> HTMLResponse: ) -> HTMLResponse | RedirectResponse:
user = request.session.get("user") user = request.session.get("user")
if not user: # if not user:
return RedirectResponse(url="/login", status_code=302) # return RedirectResponse(url="/login", status_code=302)
transactions = list_transactions() transactions = list_transactions()
if as_at_date: 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 = [ results = [
{ {
"Transaction ID": item.transaction_id, "Txn ID": item.transaction_id,
"Date": item.date.isoformat(), "Date": item.date.isoformat(),
"Ref ID": item.reference_id,
"Account": item.account, "Account": item.account,
"Counterparty": item.counterparty,
"Amount": f"{item.amount:,.2f}", "Amount": f"{item.amount:,.2f}",
"CCY": item.currency,
"Booking": item.booking_date.isoformat(),
"Settlement": item.settlement_date.isoformat(),
"Description": item.description,
"Status": item.status, "Status": item.status,
"Flag": item.flag, "Flag": item.flag,
} }
+21 -11
View File
@@ -16,12 +16,13 @@ nav{
z-index: 12; z-index: 12;
} }
nav .menu{ nav .menu{
max-width: 1250px; max-width: none;
width: 100%;
margin: auto; margin: auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 0 clamp(8px, 1vw, 16px);
} }
.menu .logo a{ .menu .logo a{
text-decoration: none; text-decoration: none;
@@ -73,6 +74,8 @@ body::before {
main { main {
flex: 1; flex: 1;
padding-top: 70px; /* clear fixed nav */ padding-top: 70px; /* clear fixed nav */
padding-left: clamp(4px, 0.8vw, 12px);
padding-right: clamp(4px, 0.8vw, 12px);
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -90,29 +93,34 @@ footer {
/* ── Dashboard content container ────────────────────────── */ /* ── Dashboard content container ────────────────────────── */
.dashboard-container { .dashboard-container {
max-width: 1250px; width: 100%;
margin: 32px auto; margin: 8px 0;
padding: 40px 24px 60px; padding: 14px clamp(10px, 1vw, 18px);
background: rgba(15, 17, 23, 0.45); background: rgba(15, 17, 23, 0.45);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-radius: 12px; border-radius: 12px;
display: flex;
flex-direction: column;
height: calc(100vh - 86px);
} }
.dashboard-container h1 { .dashboard-container h1 {
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 600;
color: #f1f5f9; color: #f1f5f9;
margin-bottom: 24px; margin-bottom: 8px;
padding-bottom: 12px; padding-bottom: 8px;
border-bottom: 2px solid #2d2d3a; border-bottom: 2px solid #2d2d3a;
letter-spacing: 0.02em; letter-spacing: 0.02em;
flex-shrink: 0;
} }
/* ── Data table ─────────────────────────────────────────── */ /* ── Data table ─────────────────────────────────────────── */
.table-scroll { .table-scroll {
overflow-x: auto; overflow-x: auto;
overflow-y: auto; overflow-y: auto;
max-height: calc(100vh - 340px); flex: 1;
border-radius: 10px; border-radius: 10px;
min-height: 0;
} }
.table-scroll .data-table thead th { .table-scroll .data-table thead th {
position: sticky; position: sticky;
@@ -181,11 +189,12 @@ footer {
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-end; align-items: flex-end;
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 8px;
padding: 18px 20px; padding: 12px 16px;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border: 1px solid #2d2d3a; border: 1px solid #2d2d3a;
border-radius: 10px; border-radius: 10px;
flex-shrink: 0;
} }
.filter-group { .filter-group {
display: flex; display: flex;
@@ -263,5 +272,6 @@ footer {
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
margin-top: 20px; margin-top: 8px;
flex-shrink: 0;
} }
+5
View File
@@ -14,3 +14,8 @@ dependencies = [
"python-dotenv>=1.2.2", "python-dotenv>=1.2.2",
"uvicorn[standard]>=0.45.0", "uvicorn[standard]>=0.45.0",
] ]
[dependency-groups]
dev = [
"ruff>=0.15.12",
]
Generated
+33
View File
@@ -188,6 +188,11 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "authlib", specifier = ">=1.7.2" }, { name = "authlib", specifier = ">=1.7.2" },
@@ -200,6 +205,9 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.45.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.45.0" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.12" }]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.0" 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" }, { 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]] [[package]]
name = "starlette" name = "starlette"
version = "1.0.0" version = "1.0.0"