feat: add homepage

This commit is contained in:
2026-01-18 21:11:35 +01:00
parent ee1db93298
commit c5e51a7513
17 changed files with 1550 additions and 115 deletions

7
data/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Ignore database files but keep the directory
*.sqlite
*.sqlite-journal
*.db
# Keep this .gitignore
!.gitignore

View File

@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"pdfjs-dist": "^4.0.379",
"vue": "^3.4.21"
"vue": "^3.4.21",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
@@ -1216,6 +1217,12 @@
"@vue/shared": "3.5.26"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
@@ -2554,6 +2561,21 @@
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
}
}

View File

@@ -9,8 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"pdfjs-dist": "^4.0.379",
"vue": "^3.4.21",
"pdfjs-dist": "^4.0.379"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",

View File

@@ -3,143 +3,60 @@
<!-- Header compact -->
<header class="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div class="flex items-center justify-between">
<h1 class="text-lg font-semibold text-white">
<router-link to="/" class="text-lg font-semibold text-white hover:text-blue-400 transition-colors">
Plesna Gerance
<span class="text-gray-400 font-normal text-sm ml-2">Extracteur de comptes rendus</span>
</h1>
</router-link>
<!-- Upload button in header when file is loaded -->
<div class="flex items-center gap-3">
<label
v-if="pdfFile"
class="cursor-pointer px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
<!-- Navigation -->
<nav class="flex items-center gap-3">
<router-link
to="/"
class="px-3 py-1.5 text-sm rounded transition-colors"
:class="$route.path === '/' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'"
>
Changer de PDF
Accueil
</router-link>
<!-- Import button with file input -->
<label
class="px-3 py-1.5 text-sm rounded transition-colors cursor-pointer bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white"
:class="$route.path === '/extract' ? 'bg-blue-600 text-white' : ''"
>
Importer un PDF
<input
ref="fileInput"
type="file"
accept="application/pdf,.pdf"
class="hidden"
@change="onNewFileSelect"
@change="onFileSelect"
/>
</label>
<span v-if="pdfFile" class="text-gray-400 text-sm truncate max-w-[200px]">
{{ pdfFile.name }}
</span>
</div>
</nav>
</div>
</header>
<!-- Main content -->
<main class="flex-1 flex overflow-hidden">
<!-- Upload zone (when no file) -->
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8">
<PdfUploader
class="max-w-xl w-full"
@file-selected="onFileSelected"
@extraction-complete="onExtractionComplete"
@extraction-error="onExtractionError"
/>
</div>
<!-- Split view when file is loaded -->
<template v-else>
<!-- Left panel: PDF Preview -->
<div class="w-1/2 flex flex-col border-r border-gray-700">
<PdfPreview
:file="pdfFile"
:file-name="pdfFile.name"
class="flex-1"
/>
</div>
<!-- Right panel: JSON Viewer -->
<div class="w-1/2 flex flex-col bg-gray-50">
<!-- Extract button bar -->
<div v-if="!extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<button
@click="extractCurrentFile"
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Extraire les donnees du PDF
</button>
</div>
<JsonViewer
:data="extractedData"
:is-loading="isExtracting"
class="flex-1 overflow-hidden"
/>
</div>
</template>
<router-view />
</main>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PdfUploader from './components/PdfUploader.vue'
import PdfPreview from './components/PdfPreview.vue'
import JsonViewer from './components/JsonViewer.vue'
import { useRouter } from 'vue-router'
import { pendingFile } from './store'
const pdfFile = ref(null)
const extractedData = ref(null)
const isExtracting = ref(false)
const router = useRouter()
const fileInput = ref(null)
function onFileSelected(file) {
pdfFile.value = file
extractedData.value = null
if (file) {
// Auto-extract when file is selected
extractCurrentFile()
}
}
function onNewFileSelect(e) {
function onFileSelect(e) {
const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pdfFile.value = file
extractedData.value = null
extractCurrentFile()
pendingFile.value = file
router.push('/extract')
}
e.target.value = '' // Reset input
}
async function extractCurrentFile() {
if (!pdfFile.value) return
isExtracting.value = true
try {
const formData = new FormData()
formData.append('file', pdfFile.value)
const response = await fetch('/api/extract', {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || 'Erreur lors de l\'extraction')
}
const data = await response.json()
extractedData.value = data
} catch (err) {
console.error('Extraction error:', err)
alert('Erreur: ' + err.message)
} finally {
isExtracting.value = false
}
}
function onExtractionComplete(data) {
extractedData.value = data
isExtracting.value = false
}
function onExtractionError(error) {
console.error('Extraction error:', error)
isExtracting.value = false
}
</script>

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
createApp(App).mount('#app')
createApp(App).use(router).mount('#app')

View File

@@ -0,0 +1,224 @@
<template>
<div class="flex-1 flex overflow-hidden">
<!-- Left panel: Upload or PDF Preview -->
<div class="w-1/2 flex flex-col border-r border-gray-700">
<!-- Upload zone when no file -->
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center p-8 bg-gray-800">
<div class="max-w-md w-full">
<div
class="border-2 border-dashed border-gray-600 rounded-lg p-8 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
:class="{ 'border-blue-500 bg-blue-500/10': isDragging }"
>
<input
ref="fileInput"
type="file"
accept="application/pdf,.pdf"
class="hidden"
@change="onFileChange"
/>
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-gray-400 mb-2">Glisser un PDF ici</p>
<p class="text-sm text-gray-500">ou <span class="text-blue-400">cliquer pour parcourir</span></p>
</div>
</div>
</div>
<!-- PDF Preview when file loaded -->
<PdfPreview
v-else
:file="pdfFile"
:file-name="pdfFile.name"
class="flex-1"
/>
</div>
<!-- Right panel: JSON Viewer -->
<div class="w-1/2 flex flex-col bg-gray-50">
<!-- Extract button bar -->
<div v-if="pdfFile && !extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<button
@click="extractCurrentFile"
class="w-full px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Extraire les donnees du PDF
</button>
</div>
<!-- Save button bar (when data is extracted) -->
<div v-if="extractedData && !isExtracting" class="flex-shrink-0 p-4 bg-white border-b border-gray-200">
<div class="flex items-center gap-3">
<button
@click="saveToDatabase"
:disabled="isSaving"
class="flex-1 px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isSaving">Sauvegarde en cours...</span>
<span v-else>Sauvegarder en base</span>
</button>
</div>
<!-- Save feedback message -->
<div v-if="saveMessage" class="mt-3">
<div
:class="[
'px-4 py-3 rounded-lg text-sm',
saveMessage.type === 'success' ? 'bg-green-100 text-green-800 border border-green-200' : '',
saveMessage.type === 'duplicate' ? 'bg-yellow-100 text-yellow-800 border border-yellow-200' : '',
saveMessage.type === 'error' ? 'bg-red-100 text-red-800 border border-red-200' : ''
]"
>
<div class="font-medium">{{ saveMessage.title }}</div>
<div v-if="saveMessage.details" class="mt-1 text-xs opacity-80">{{ saveMessage.details }}</div>
</div>
</div>
</div>
<!-- Placeholder when no file -->
<div v-if="!pdfFile" class="flex-1 flex items-center justify-center text-gray-400">
<p>Selectionnez un PDF pour commencer</p>
</div>
<JsonViewer
v-else
:data="extractedData"
:is-loading="isExtracting"
class="flex-1 overflow-hidden"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { pendingFile } from '../store'
import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue'
const router = useRouter()
const fileInput = ref(null)
const isDragging = ref(false)
const pdfFile = ref(null)
const extractedData = ref(null)
const isExtracting = ref(false)
const isSaving = ref(false)
const saveMessage = ref(null)
// Check for pending file from store on mount
onMounted(() => {
if (pendingFile.value) {
pdfFile.value = pendingFile.value
pendingFile.value = null // Clear the store
extractCurrentFile()
}
})
function triggerFileInput() {
fileInput.value?.click()
}
function onFileChange(e) {
const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pdfFile.value = file
extractedData.value = null
saveMessage.value = null
extractCurrentFile()
}
e.target.value = ''
}
function onDrop(e) {
isDragging.value = false
const file = e.dataTransfer.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pdfFile.value = file
extractedData.value = null
saveMessage.value = null
extractCurrentFile()
}
}
async function extractCurrentFile() {
if (!pdfFile.value) return
isExtracting.value = true
try {
const formData = new FormData()
formData.append('file', pdfFile.value)
const response = await fetch('/api/extract', {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.detail || 'Erreur lors de l\'extraction')
}
const data = await response.json()
extractedData.value = data
} catch (err) {
console.error('Extraction error:', err)
alert('Erreur: ' + err.message)
} finally {
isExtracting.value = false
}
}
async function saveToDatabase() {
if (!extractedData.value) return
isSaving.value = true
saveMessage.value = null
try {
const response = await fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
source_file: extractedData.value.source_file,
data: extractedData.value.data
})
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.detail || 'Erreur lors de la sauvegarde')
}
if (result.success) {
// Success - redirect to home
router.push('/')
} else {
// Duplicate detected - stay on current view
saveMessage.value = {
type: 'duplicate',
title: 'Document deja existant',
details: `Reference: ${result.reference} | Date: ${result.date}`
}
}
} catch (err) {
console.error('Save error:', err)
saveMessage.value = {
type: 'error',
title: 'Erreur lors de la sauvegarde',
details: err.message
}
} finally {
isSaving.value = false
}
}
</script>

View File

@@ -0,0 +1,164 @@
<template>
<div class="flex-1 overflow-auto p-6 bg-gray-800">
<div class="max-w-4xl mx-auto space-y-6">
<!-- Upload zone compacte -->
<div
class="border-2 border-dashed border-gray-600 rounded-lg p-6 text-center hover:border-blue-500 transition-colors cursor-pointer bg-gray-900/50"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
:class="{ 'border-blue-500 bg-blue-500/10': isDragging }"
>
<input
ref="fileInput"
type="file"
accept="application/pdf,.pdf"
class="hidden"
@change="onFileSelect"
/>
<div class="text-gray-400">
<svg class="mx-auto h-10 w-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-sm">
<span v-if="!isDragging">Glisser un PDF ici ou <span class="text-blue-400">cliquer pour importer</span></span>
<span v-else class="text-blue-400 font-medium">Déposer le fichier ici</span>
</p>
</div>
</div>
<!-- Stats rapides -->
<div class="grid grid-cols-3 md:grid-cols-6 gap-3">
<div v-for="stat in statsDisplay" :key="stat.key" class="bg-gray-900 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-white">{{ stats[stat.key] ?? '-' }}</div>
<div class="text-xs text-gray-400">{{ stat.label }}</div>
</div>
</div>
<!-- Derniers documents -->
<div class="bg-gray-900 rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-gray-700">
<h2 class="text-sm font-medium text-gray-300">Derniers documents importes</h2>
</div>
<div v-if="isLoading" class="p-8 text-center text-gray-500">
Chargement...
</div>
<div v-else-if="documents.length === 0" class="p-8 text-center text-gray-500">
Aucun document importe pour le moment.
</div>
<table v-else class="w-full">
<thead class="bg-gray-800/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Reference</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Date</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-400 uppercase">Immeuble</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-400 uppercase">Solde</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-800">
<tr v-for="doc in documents" :key="doc.id" class="hover:bg-gray-800/50">
<td class="px-4 py-3 text-sm text-white font-mono">{{ doc.reference }}</td>
<td class="px-4 py-3 text-sm text-gray-300">{{ formatDate(doc.date) }}</td>
<td class="px-4 py-3 text-sm text-gray-300">
<span class="text-gray-500">{{ doc.immeuble_code }}</span>
<span v-if="doc.immeuble_adresse" class="ml-2">{{ doc.immeuble_adresse }}</span>
</td>
<td class="px-4 py-3 text-sm text-right">
<span :class="doc.solde_type === 'crediteur' ? 'text-green-400' : 'text-red-400'">
{{ formatAmount(doc.solde_montant) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { pendingFile } from '../store'
const router = useRouter()
const fileInput = ref(null)
const isDragging = ref(false)
const isLoading = ref(true)
const stats = ref({})
const documents = ref([])
const statsDisplay = [
{ key: 'documents', label: 'Documents' },
{ key: 'immeubles', label: 'Immeubles' },
{ key: 'lots', label: 'Lots' },
{ key: 'locataires', label: 'Locataires' },
{ key: 'revenus', label: 'Revenus' },
{ key: 'depenses', label: 'Depenses' },
]
function formatDate(dateStr) {
if (!dateStr) return '-'
const [year, month, day] = dateStr.split('-')
return `${day}/${month}/${year}`
}
function formatAmount(amount) {
if (amount == null) return '-'
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(amount)
}
async function loadData() {
isLoading.value = true
try {
const [statsRes, docsRes] = await Promise.all([
fetch('/api/stats'),
fetch('/api/documents?limit=10')
])
if (statsRes.ok) {
stats.value = await statsRes.json()
}
if (docsRes.ok) {
documents.value = await docsRes.json()
}
} catch (err) {
console.error('Failed to load dashboard data:', err)
} finally {
isLoading.value = false
}
}
function triggerFileInput() {
fileInput.value?.click()
}
function onFileSelect(e) {
const file = e.target.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pendingFile.value = file
router.push('/extract')
}
e.target.value = ''
}
function onDrop(e) {
isDragging.value = false
const file = e.dataTransfer.files[0]
if (file && file.name.toLowerCase().endsWith('.pdf')) {
pendingFile.value = file
router.push('/extract')
}
}
onMounted(() => {
loadData()
})
</script>

23
frontend/src/router.js Normal file
View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomePage from './pages/HomePage.vue'
import ExtractPage from './pages/ExtractPage.vue'
const routes = [
{
path: '/',
name: 'home',
component: HomePage
},
{
path: '/extract',
name: 'extract',
component: ExtractPage
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

4
frontend/src/store.js Normal file
View File

@@ -0,0 +1,4 @@
import { ref } from 'vue'
// Simple store for sharing state between components
export const pendingFile = ref(null)

View File

@@ -8,6 +8,7 @@ dependencies = [
"fastapi>=0.100.0",
"uvicorn[standard]>=0.20.0",
"python-multipart>=0.0.6",
"sqlalchemy>=2.0.0",
]
[project.scripts]

View File

@@ -2,13 +2,18 @@
import tempfile
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi import FastAPI, File, HTTPException, UploadFile, Depends
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sqlalchemy.orm import Session
from .. import __version__
from ..extractor import extract_compte_rendu
from ..database import init_db, get_session, DatabaseService
from ..database.service import DuplicateDocumentError
app = FastAPI(
title="Plesna Gérance API",
@@ -20,6 +25,46 @@ app = FastAPI(
)
# Initialize database on startup
@app.on_event("startup")
async def startup_event():
"""Initialize database on application startup."""
init_db()
# Pydantic models for API
class SaveRequest(BaseModel):
"""Request body for saving extracted data."""
source_file: str | None = None
data: dict[str, Any]
class SaveResponse(BaseModel):
"""Response for save operation."""
success: bool
message: str
document_id: int | None = None
reference: str | None = None
date: str | None = None
class DocumentSummary(BaseModel):
"""Summary of a saved document."""
id: int
reference: str
date: str
type: str | None
source_file: str | None
immeuble_code: str | None
immeuble_adresse: str | None
solde_montant: float | None
solde_type: str | None
created_at: str
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
@@ -105,6 +150,184 @@ async def extract_pdf(
tmp_path.unlink()
@app.post("/api/save", tags=["database"], response_model=SaveResponse)
async def save_document(
request: SaveRequest,
session: Session = Depends(get_session),
) -> SaveResponse:
"""Sauvegarde les donnees extraites en base de donnees.
- **source_file**: Nom du fichier PDF original (optionnel)
- **data**: Donnees extraites (format identique a la reponse de /api/extract)
Retourne un message de succes avec l'ID du document cree,
ou une erreur si le document existe deja (doublon).
"""
try:
db_service = DatabaseService(session)
document = db_service.save_document(
data=request.data, source_file=request.source_file
)
return SaveResponse(
success=True,
message="Document sauvegarde avec succes",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=f"Document deja existant: reference={e.reference}, date={e.date}",
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
)
@app.get("/api/stats", tags=["database"])
async def get_stats(
session: Session = Depends(get_session),
) -> dict:
"""Retourne les statistiques globales de la base de donnees.
Compteurs pour chaque table principale.
"""
from sqlalchemy import func, select
from ..database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
return {
"documents": session.execute(select(func.count(Document.id))).scalar() or 0,
"immeubles": session.execute(select(func.count(Immeuble.id))).scalar() or 0,
"lots": session.execute(select(func.count(Lot.id))).scalar() or 0,
"locataires": session.execute(select(func.count(Locataire.id))).scalar() or 0,
"revenus": session.execute(select(func.count(Revenu.id))).scalar() or 0,
"depenses": session.execute(select(func.count(Depense.id))).scalar() or 0,
}
@app.get("/api/documents", tags=["database"])
async def list_documents(
limit: int = 100,
offset: int = 0,
session: Session = Depends(get_session),
) -> list[DocumentSummary]:
"""Liste les documents importes en base.
- **limit**: Nombre maximum de documents a retourner (defaut: 100)
- **offset**: Decalage pour la pagination (defaut: 0)
Retourne une liste de documents avec leurs metadonnees principales.
"""
db_service = DatabaseService(session)
documents = db_service.list_documents(limit=limit, offset=offset)
return [
DocumentSummary(
id=doc.id,
reference=doc.reference,
date=str(doc.date),
type=doc.type,
source_file=doc.source_file,
immeuble_code=doc.immeuble.code if doc.immeuble else None,
immeuble_adresse=doc.immeuble.adresse if doc.immeuble else None,
solde_montant=doc.solde_montant,
solde_type=doc.solde_type,
created_at=doc.created_at.isoformat() if doc.created_at else None,
)
for doc in documents
]
@app.get("/api/documents/{document_id}", tags=["database"])
async def get_document(
document_id: int,
session: Session = Depends(get_session),
) -> dict:
"""Recupere un document par son ID avec toutes ses donnees.
Retourne le document complet incluant le JSON original.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
import json
return {
"id": document.id,
"reference": document.reference,
"date": str(document.date),
"type": document.type,
"source_file": document.source_file,
"immeuble": {
"code": document.immeuble.code,
"adresse": document.immeuble.adresse,
"ville": document.immeuble.ville,
"code_postal": document.immeuble.code_postal,
}
if document.immeuble
else None,
"solde": {
"montant": document.solde_montant,
"type": document.solde_type,
"date_arrete": str(document.solde_date_arrete)
if document.solde_date_arrete
else None,
},
"editeur": {
"nom": document.editeur_nom,
"siret": document.editeur_siret,
},
"json_data": json.loads(document.json_data) if document.json_data else None,
"created_at": document.created_at.isoformat() if document.created_at else None,
}
@app.get("/api/check-duplicate", tags=["database"])
async def check_duplicate(
reference: str,
date: str,
session: Session = Depends(get_session),
) -> dict:
"""Verifie si un document existe deja en base.
- **reference**: Reference du document
- **date**: Date du document (format YYYY-MM-DD)
Retourne {exists: true/false, document_id: ...}
"""
from datetime import datetime
try:
doc_date = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail="Format de date invalide. Utiliser YYYY-MM-DD"
)
db_service = DatabaseService(session)
existing = db_service.check_duplicate(reference, doc_date)
return {
"exists": existing is not None,
"document_id": existing.id if existing else None,
"reference": reference,
"date": date,
}
# Mount static files for production (if dist exists)
if FRONTEND_DIST.exists():
# Serve static assets

View File

@@ -153,5 +153,68 @@ def serve(host: str, port: int, reload: bool) -> None:
)
@main.command()
@click.option(
"--db-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Chemin de la base de donnees (defaut: ~/.plesna_gerance/database.sqlite)",
)
def init_db(db_path: Path | None) -> None:
"""Initialise la base de donnees SQLite.
Cree les tables necessaires pour stocker les documents,
immeubles, lots, locataires, revenus et depenses.
Exemples:
plesna-gerance init-db
plesna-gerance init-db --db-path ./data/gerance.db
"""
from .database import init_db as do_init_db
result_path = do_init_db(db_path)
click.echo(f"Base de donnees initialisee: {result_path}", err=True)
@main.command()
@click.option(
"--db-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Chemin de la base de donnees",
)
def db_info(db_path: Path | None) -> None:
"""Affiche des informations sur la base de donnees.
Montre le nombre de documents, immeubles, lots, etc.
"""
from .database import init_db as do_init_db, get_session_factory
from .database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
from sqlalchemy import func, select
# Initialize/connect to database
result_path = do_init_db(db_path)
SessionLocal = get_session_factory()
with SessionLocal() as session:
# Count records in each table
doc_count = session.execute(select(func.count(Document.id))).scalar()
imm_count = session.execute(select(func.count(Immeuble.id))).scalar()
lot_count = session.execute(select(func.count(Lot.id))).scalar()
loc_count = session.execute(select(func.count(Locataire.id))).scalar()
rev_count = session.execute(select(func.count(Revenu.id))).scalar()
dep_count = session.execute(select(func.count(Depense.id))).scalar()
click.echo(f"Base de donnees: {result_path}")
click.echo(f"")
click.echo(f"Statistiques:")
click.echo(f" - Documents: {doc_count}")
click.echo(f" - Immeubles: {imm_count}")
click.echo(f" - Lots: {lot_count}")
click.echo(f" - Locataires: {loc_count}")
click.echo(f" - Revenus: {rev_count}")
click.echo(f" - Depenses: {dep_count}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,21 @@
"""Database module for plesna-gerance."""
from .connection import get_engine, get_session, get_session_factory, init_db
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense
from .service import DatabaseService, DuplicateDocumentError
__all__ = [
"get_engine",
"get_session",
"get_session_factory",
"init_db",
"Base",
"Document",
"Immeuble",
"Lot",
"Locataire",
"Revenu",
"Depense",
"DatabaseService",
"DuplicateDocumentError",
]

View File

@@ -0,0 +1,118 @@
"""Database connection management for SQLite."""
import os
from pathlib import Path
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from .models import Base
# Default database path - relative to project root
def _get_project_root() -> Path:
"""Find project root by looking for pyproject.toml."""
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists():
return parent
# Fallback to home directory if not found
return Path.home() / ".plesna_gerance"
DEFAULT_DB_PATH = _get_project_root() / "data" / "database.sqlite"
# Global engine instance
_engine = None
_SessionLocal = None
def get_db_path() -> Path:
"""Get database path from environment or default."""
env_path = os.environ.get("PLESNA_DB_PATH")
if env_path:
return Path(env_path)
return DEFAULT_DB_PATH
def get_engine(db_path: Path | None = None):
"""Get or create SQLAlchemy engine (singleton pattern)."""
global _engine
if _engine is None:
if db_path is None:
db_path = get_db_path()
# Create parent directory if needed
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create engine with SQLite
_engine = create_engine(
f"sqlite:///{db_path}",
echo=False, # Set to True for SQL debugging
connect_args={"check_same_thread": False}, # Required for FastAPI
)
return _engine
def get_session_factory(engine=None):
"""Get or create session factory."""
global _SessionLocal
if _SessionLocal is None:
if engine is None:
engine = get_engine()
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
return _SessionLocal
def get_session() -> Generator[Session, None, None]:
"""Dependency for FastAPI to get database session."""
SessionLocal = get_session_factory()
session = SessionLocal()
try:
yield session
finally:
session.close()
def init_db(db_path: Path | None = None) -> Path:
"""Initialize database: create all tables.
Returns the path to the database file.
"""
if db_path is None:
db_path = get_db_path()
# Reset globals to use new path
global _engine, _SessionLocal
_engine = None
_SessionLocal = None
# Create parent directory
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create engine and tables
engine = create_engine(
f"sqlite:///{db_path}", echo=False, connect_args={"check_same_thread": False}
)
Base.metadata.create_all(bind=engine)
# Update globals
_engine = engine
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
return db_path
def reset_connection():
"""Reset global connection (useful for testing)."""
global _engine, _SessionLocal
if _engine is not None:
_engine.dispose()
_engine = None
_SessionLocal = None

View File

@@ -0,0 +1,232 @@
"""SQLAlchemy models for plesna-gerance database."""
from datetime import date, datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Float,
Date,
DateTime,
Text,
ForeignKey,
UniqueConstraint,
Index,
)
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
"""Base class for all models."""
pass
class Immeuble(Base):
"""Table des immeubles gérés."""
__tablename__ = "immeubles"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(20), unique=True, nullable=False, index=True)
adresse = Column(String(255), nullable=True)
ville = Column(String(100), nullable=True)
code_postal = Column(String(10), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relations
lots = relationship("Lot", back_populates="immeuble", cascade="all, delete-orphan")
documents = relationship("Document", back_populates="immeuble")
depenses = relationship("Depense", back_populates="immeuble")
def __repr__(self) -> str:
return f"<Immeuble(code={self.code}, adresse={self.adresse})>"
class Lot(Base):
"""Table des lots (appartements, locaux commerciaux, etc.)."""
__tablename__ = "lots"
id = Column(Integer, primary_key=True, autoincrement=True)
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
numero = Column(String(10), nullable=False)
type = Column(String(50), nullable=True) # "Loc. Commercial", "Appartement", etc.
created_at = Column(DateTime, default=datetime.utcnow)
# Contrainte unique: un numéro de lot par immeuble
__table_args__ = (
UniqueConstraint("immeuble_id", "numero", name="uq_lot_immeuble_numero"),
Index("ix_lot_immeuble_numero", "immeuble_id", "numero"),
)
# Relations
immeuble = relationship("Immeuble", back_populates="lots")
locataires = relationship(
"Locataire", back_populates="lot", cascade="all, delete-orphan"
)
revenus = relationship("Revenu", back_populates="lot")
depenses = relationship("Depense", back_populates="lot")
def __repr__(self) -> str:
return f"<Lot(numero={self.numero}, type={self.type})>"
class Locataire(Base):
"""Table des locataires avec historique."""
__tablename__ = "locataires"
id = Column(Integer, primary_key=True, autoincrement=True)
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False)
nom = Column(String(255), nullable=False)
date_debut = Column(Date, nullable=True) # Date d'entrée dans le lot
date_fin = Column(Date, nullable=True) # Date de sortie (NULL si actif)
created_at = Column(DateTime, default=datetime.utcnow)
# Contrainte unique: un locataire par lot et période
__table_args__ = (
UniqueConstraint(
"lot_id", "nom", "date_debut", name="uq_locataire_lot_nom_debut"
),
Index("ix_locataire_nom", "nom"),
)
# Relations
lot = relationship("Lot", back_populates="locataires")
revenus = relationship("Revenu", back_populates="locataire")
def __repr__(self) -> str:
return f"<Locataire(nom={self.nom})>"
class Document(Base):
"""Table des documents PDF importés avec traçabilité."""
__tablename__ = "documents"
id = Column(Integer, primary_key=True, autoincrement=True)
reference = Column(String(50), nullable=False, index=True)
date = Column(Date, nullable=False, index=True)
type = Column(String(100), nullable=True) # "COMPTE RENDU DE GESTION"
source_file = Column(String(255), nullable=True) # Nom du fichier PDF original
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
# JSON brut pour traçabilité complète
json_data = Column(Text, nullable=False)
# Métadonnées éditeur
editeur_nom = Column(String(255), nullable=True)
editeur_siret = Column(String(20), nullable=True)
# Solde à la date du document
solde_montant = Column(Float, nullable=True)
solde_type = Column(String(20), nullable=True) # "crediteur" ou "debiteur"
solde_date_arrete = Column(Date, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Clé unique: référence + date (évite les doublons)
__table_args__ = (
UniqueConstraint("reference", "date", name="uq_document_reference_date"),
)
# Relations
immeuble = relationship("Immeuble", back_populates="documents")
revenus = relationship(
"Revenu", back_populates="document", cascade="all, delete-orphan"
)
depenses = relationship(
"Depense", back_populates="document", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Document(reference={self.reference}, date={self.date})>"
class Revenu(Base):
"""Table des revenus locatifs (lignes détaillées)."""
__tablename__ = "revenus"
id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False)
locataire_id = Column(Integer, ForeignKey("locataires.id"), nullable=False)
# Type de ligne: loyer, solde_anterieur, rappel_loyer, divers
type_ligne = Column(String(50), nullable=False)
# Période concernée
periode_debut = Column(Date, nullable=True)
periode_fin = Column(Date, nullable=True)
# Montants détaillés
loyers = Column(Float, default=0.0)
taxes = Column(Float, default=0.0)
provisions = Column(Float, default=0.0)
divers_montant = Column(Float, default=0.0)
divers_libelle = Column(String(255), nullable=True)
total = Column(Float, default=0.0)
regles = Column(Float, default=0.0) # Montant réglé
impayes = Column(Float, default=0.0)
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
Index("ix_revenu_document", "document_id"),
Index("ix_revenu_lot", "lot_id"),
Index("ix_revenu_periode", "periode_debut", "periode_fin"),
)
# Relations
document = relationship("Document", back_populates="revenus")
lot = relationship("Lot", back_populates="revenus")
locataire = relationship("Locataire", back_populates="revenus")
def __repr__(self) -> str:
return f"<Revenu(type={self.type_ligne}, total={self.total})>"
class Depense(Base):
"""Table des dépenses/opérations."""
__tablename__ = "depenses"
id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
lot_id = Column(
Integer, ForeignKey("lots.id"), nullable=True
) # NULL si dépense immeuble
# Catégorisation
categorie = Column(String(100), nullable=True) # DEPENSES_LOCATIVES, etc.
sous_categorie = Column(String(255), nullable=True) # Nettoyage immeuble, etc.
fournisseur = Column(String(255), nullable=True)
description = Column(String(500), nullable=True)
# Montants
debit = Column(Float, default=0.0)
credit = Column(Float, default=0.0)
tva = Column(Float, default=0.0)
locatif = Column(Float, default=0.0) # Part locative
deductible = Column(Float, default=0.0) # Part déductible
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
Index("ix_depense_document", "document_id"),
Index("ix_depense_immeuble", "immeuble_id"),
Index("ix_depense_categorie", "categorie"),
)
# Relations
document = relationship("Document", back_populates="depenses")
immeuble = relationship("Immeuble", back_populates="depenses")
lot = relationship("Lot", back_populates="depenses")
def __repr__(self) -> str:
return f"<Depense(categorie={self.categorie}, debit={self.debit})>"

View File

@@ -0,0 +1,314 @@
"""Database service for saving and querying extracted data."""
import json
from datetime import date, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense
class DuplicateDocumentError(Exception):
"""Raised when attempting to save a document that already exists."""
def __init__(self, reference: str, doc_date: date):
self.reference = reference
self.date = doc_date
super().__init__(
f"Document avec reference={reference} et date={doc_date} existe deja"
)
class DatabaseService:
"""Service for database operations."""
def __init__(self, session: Session):
self.session = session
def check_duplicate(self, reference: str, doc_date: date) -> Document | None:
"""Check if a document with this reference and date already exists."""
stmt = select(Document).where(
Document.reference == reference, Document.date == doc_date
)
return self.session.execute(stmt).scalar_one_or_none()
def get_or_create_immeuble(
self, code: str, adresse: str = None, ville: str = None, code_postal: str = None
) -> Immeuble:
"""Get existing immeuble or create new one."""
stmt = select(Immeuble).where(Immeuble.code == code)
immeuble = self.session.execute(stmt).scalar_one_or_none()
if immeuble is None:
immeuble = Immeuble(
code=code, adresse=adresse, ville=ville, code_postal=code_postal
)
self.session.add(immeuble)
self.session.flush() # Get the ID
return immeuble
def get_or_create_lot(
self, immeuble_id: int, numero: str, lot_type: str = None
) -> Lot:
"""Get existing lot or create new one."""
stmt = select(Lot).where(Lot.immeuble_id == immeuble_id, Lot.numero == numero)
lot = self.session.execute(stmt).scalar_one_or_none()
if lot is None:
lot = Lot(immeuble_id=immeuble_id, numero=numero, type=lot_type)
self.session.add(lot)
self.session.flush()
return lot
def get_or_create_locataire(
self, lot_id: int, nom: str, date_debut: date = None
) -> Locataire:
"""Get existing locataire or create new one."""
stmt = select(Locataire).where(Locataire.lot_id == lot_id, Locataire.nom == nom)
# If date_debut is provided, include it in the search
if date_debut:
stmt = stmt.where(Locataire.date_debut == date_debut)
else:
stmt = stmt.where(Locataire.date_debut.is_(None))
locataire = self.session.execute(stmt).scalar_one_or_none()
if locataire is None:
locataire = Locataire(lot_id=lot_id, nom=nom, date_debut=date_debut)
self.session.add(locataire)
self.session.flush()
return locataire
def _parse_date(self, date_str: str | None) -> date | None:
"""Parse ISO date string to date object."""
if not date_str:
return None
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
def save_document(self, data: dict[str, Any], source_file: str = None) -> Document:
"""Save extracted JSON data to database.
Args:
data: The 'data' portion of the extracted JSON (contains metadata,
situation_locataires, recapitulatif_operations)
source_file: Original PDF filename
Returns:
The created Document instance
Raises:
DuplicateDocumentError: If document already exists
"""
metadata = data.get("metadata", {})
doc_info = metadata.get("document", {})
immeuble_info = metadata.get("immeuble", {})
editeur_info = metadata.get("editeur", {})
solde_info = metadata.get("solde", {})
# Extract key fields
reference = doc_info.get("reference", "")
doc_date = self._parse_date(doc_info.get("date"))
if not reference or not doc_date:
raise ValueError("Document must have reference and date")
# Check for duplicates
existing = self.check_duplicate(reference, doc_date)
if existing:
raise DuplicateDocumentError(reference, doc_date)
# Get or create immeuble
immeuble = self.get_or_create_immeuble(
code=immeuble_info.get("code", "UNKNOWN"),
adresse=immeuble_info.get("adresse"),
ville=immeuble_info.get("ville"),
code_postal=immeuble_info.get("code_postal"),
)
# Create document
document = Document(
reference=reference,
date=doc_date,
type=doc_info.get("type"),
source_file=source_file,
immeuble_id=immeuble.id,
json_data=json.dumps(data, ensure_ascii=False, default=str),
editeur_nom=editeur_info.get("nom"),
editeur_siret=editeur_info.get("siret"),
solde_montant=solde_info.get("montant"),
solde_type=solde_info.get("type"),
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
)
self.session.add(document)
self.session.flush()
# Process situation_locataires (revenus)
for situation in data.get("situation_locataires", []):
self._save_situation_locataire(document, immeuble, situation)
# Process recapitulatif_operations (depenses)
for operation in data.get("recapitulatif_operations", []):
self._save_operation(document, immeuble, operation)
self.session.commit()
return document
def _save_situation_locataire(
self, document: Document, immeuble: Immeuble, situation: dict
) -> None:
"""Save locataire situation (revenus lines)."""
lot_info = situation.get("lot", {})
locataire_info = situation.get("locataire", {})
# Get or create lot
lot = self.get_or_create_lot(
immeuble_id=immeuble.id,
numero=lot_info.get("numero", "0000"),
lot_type=lot_info.get("type"),
)
# Get or create locataire
locataire = self.get_or_create_locataire(
lot_id=lot.id, nom=locataire_info.get("nom", "INCONNU")
)
# Save each revenue line
for ligne in situation.get("lignes", []):
periode = ligne.get("periode", {})
divers = ligne.get("divers", {})
revenu = Revenu(
document_id=document.id,
lot_id=lot.id,
locataire_id=locataire.id,
type_ligne=ligne.get("type", "loyer"),
periode_debut=self._parse_date(periode.get("debut")),
periode_fin=self._parse_date(periode.get("fin")),
loyers=ligne.get("loyers", 0.0) or 0.0,
taxes=ligne.get("taxes", 0.0) or 0.0,
provisions=ligne.get("provisions", 0.0) or 0.0,
divers_montant=divers.get("montant", 0.0) or 0.0 if divers else 0.0,
divers_libelle=divers.get("libelle") if divers else None,
total=ligne.get("total", 0.0) or 0.0,
regles=ligne.get("regles", 0.0) or 0.0,
impayes=ligne.get("impayes", 0.0) or 0.0,
)
self.session.add(revenu)
def _save_operation(
self, document: Document, immeuble: Immeuble, operation: dict
) -> None:
"""Save operation (depense)."""
montants = operation.get("montants", {})
# Check if operation is linked to a specific lot
lot_id = None
lot_numero = operation.get("lot_numero")
if lot_numero:
lot = self.get_or_create_lot(immeuble_id=immeuble.id, numero=lot_numero)
lot_id = lot.id
depense = Depense(
document_id=document.id,
immeuble_id=immeuble.id,
lot_id=lot_id, # Can be NULL for immeuble-level expenses
categorie=operation.get("categorie"),
sous_categorie=operation.get("sous_categorie"),
fournisseur=operation.get("fournisseur"),
description=operation.get("description"),
debit=montants.get("debit", 0.0) or 0.0,
credit=montants.get("credit", 0.0) or 0.0,
tva=montants.get("tva", 0.0) or 0.0,
locatif=montants.get("locatif", 0.0) or 0.0,
deductible=montants.get("deductible", 0.0) or 0.0,
)
self.session.add(depense)
def list_documents(self, limit: int = 100, offset: int = 0) -> list[Document]:
"""List all imported documents."""
stmt = (
select(Document)
.order_by(Document.date.desc(), Document.created_at.desc())
.limit(limit)
.offset(offset)
)
result = self.session.execute(stmt)
return list(result.scalars().all())
def get_document_by_id(self, doc_id: int) -> Document | None:
"""Get a document by ID."""
return self.session.get(Document, doc_id)
def get_revenus_summary(
self, immeuble_id: int = None, year: int = None
) -> list[dict]:
"""Get revenue summary grouped by period."""
stmt = select(Revenu)
if immeuble_id:
stmt = stmt.join(Lot).where(Lot.immeuble_id == immeuble_id)
if year:
stmt = stmt.where(
Revenu.periode_debut >= date(year, 1, 1),
Revenu.periode_debut <= date(year, 12, 31),
)
result = self.session.execute(stmt)
revenus = result.scalars().all()
# Aggregate
total_loyers = sum(r.loyers for r in revenus)
total_regles = sum(r.regles for r in revenus)
total_impayes = sum(r.impayes for r in revenus)
return {
"total_loyers": total_loyers,
"total_regles": total_regles,
"total_impayes": total_impayes,
"count": len(revenus),
}
def get_depenses_summary(self, immeuble_id: int = None, year: int = None) -> dict:
"""Get expenses summary grouped by category."""
stmt = select(Depense)
if immeuble_id:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if year:
stmt = stmt.join(Document).where(
Document.date >= date(year, 1, 1), Document.date <= date(year, 12, 31)
)
result = self.session.execute(stmt)
depenses = result.scalars().all()
# Aggregate by category
by_category = {}
for d in depenses:
cat = d.categorie or "AUTRE"
if cat not in by_category:
by_category[cat] = {"debit": 0.0, "credit": 0.0, "count": 0}
by_category[cat]["debit"] += d.debit
by_category[cat]["credit"] += d.credit
by_category[cat]["count"] += 1
total_debit = sum(d.debit for d in depenses)
total_credit = sum(d.credit for d in depenses)
return {
"by_category": by_category,
"total_debit": total_debit,
"total_credit": total_credit,
"count": len(depenses),
}

100
uv.lock generated
View File

@@ -82,6 +82,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
]
[[package]]
name = "greenlet"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" },
{ url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" },
{ url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/d4e73f5dfa888364bbf02efa85616c6714ae7c631c201349782e5b428925/greenlet-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:b49e7ed51876b459bd645d83db257f0180e345d3f768a35a85437a24d5a49082", size = 300740, upload-time = "2025-12-04T14:47:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -151,6 +200,7 @@ dependencies = [
{ name = "click" },
{ name = "fastapi" },
{ name = "python-multipart" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -159,6 +209,7 @@ requires-dist = [
{ name = "click", specifier = ">=8.0" },
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "python-multipart", specifier = ">=0.0.6" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.20.0" },
]
@@ -377,6 +428,55 @@ 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 = "sqlalchemy"
version = "2.0.45"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/70/75b1387d72e2847220441166c5eb4e9846dd753895208c13e6d66523b2d9/sqlalchemy-2.0.45-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c64772786d9eee72d4d3784c28f0a636af5b0a29f3fe26ff11f55efe90c0bd85", size = 2154148, upload-time = "2025-12-10T20:03:21.023Z" },
{ url = "https://files.pythonhosted.org/packages/d8/a4/7805e02323c49cb9d1ae5cd4913b28c97103079765f520043f914fca4cb3/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ae64ebf7657395824a19bca98ab10eb9a3ecb026bf09524014f1bb81cb598d4", size = 3233051, upload-time = "2025-12-09T22:06:04.768Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ec/32ae09139f61bef3de3142e85c47abdee8db9a55af2bb438da54a4549263/sqlalchemy-2.0.45-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f02325709d1b1a1489f23a39b318e175a171497374149eae74d612634b234c0", size = 3232781, upload-time = "2025-12-09T22:09:54.435Z" },
{ url = "https://files.pythonhosted.org/packages/ad/bd/bf7b869b6f5585eac34222e1cf4405f4ba8c3b85dd6b1af5d4ce8bca695f/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2c3684fca8a05f0ac1d9a21c1f4a266983a7ea9180efb80ffeb03861ecd01a0", size = 3182096, upload-time = "2025-12-09T22:06:06.169Z" },
{ url = "https://files.pythonhosted.org/packages/21/6a/c219720a241bb8f35c88815ccc27761f5af7fdef04b987b0e8a2c1a6dcaa/sqlalchemy-2.0.45-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040f6f0545b3b7da6b9317fc3e922c9a98fc7243b2a1b39f78390fc0942f7826", size = 3205109, upload-time = "2025-12-09T22:09:55.969Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c4/6ccf31b2bc925d5d95fab403ffd50d20d7c82b858cf1a4855664ca054dce/sqlalchemy-2.0.45-cp310-cp310-win32.whl", hash = "sha256:830d434d609fe7bfa47c425c445a8b37929f140a7a44cdaf77f6d34df3a7296a", size = 2114240, upload-time = "2025-12-09T21:29:54.007Z" },
{ url = "https://files.pythonhosted.org/packages/de/29/a27a31fca07316def418db6f7c70ab14010506616a2decef1906050a0587/sqlalchemy-2.0.45-cp310-cp310-win_amd64.whl", hash = "sha256:0209d9753671b0da74da2cfbb9ecf9c02f72a759e4b018b3ab35f244c91842c7", size = 2137615, upload-time = "2025-12-09T21:29:55.85Z" },
{ url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" },
{ url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" },
{ url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" },
{ url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" },
{ url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" },
{ url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" },
{ url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" },
{ url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" },
{ url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" },
{ url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" },
{ url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" },
{ url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" },
{ url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" },
{ url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" },
{ url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" },
{ url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" },
{ url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" },
{ url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
]
[[package]]
name = "starlette"
version = "0.50.0"