feat: durcit l'exécution SQL IA, borne les uploads et ajoute des tests

- sql_executor: remplace le filtre regex fragile par une autorisation SQLite
  (set_authorizer) en complément de mode=ro ; rejette les instructions multiples
- uploads: lecture bornée des PDF (helper read_upload_limited, limite 20 Mo,
  HTTP 413) branchée sur /extract et /save-with-pdf
- tests: suite pytest (54 tests) couvrant amounts, dates, storage, sql_executor,
  uploads et DatabaseService.save_document ; pytest ajouté en dépendance dev

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:11:07 +02:00
parent c15b65f0d9
commit 635a094591
13 changed files with 646 additions and 22 deletions

View File

@@ -15,6 +15,16 @@ dependencies = [
[project.scripts] [project.scripts]
plesna-gerance = "plesna_gerance.cli:main" plesna-gerance = "plesna_gerance.cli:main"
[dependency-groups]
dev = [
"pytest>=8.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-q"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"

View File

@@ -13,6 +13,7 @@ from ...database import get_session, DatabaseService, storage
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
from ...database.service import DuplicateDocumentError from ...database.service import DuplicateDocumentError
from ...extractor import extract_compte_rendu from ...extractor import extract_compte_rendu
from ...utils.uploads import read_upload_limited, UploadTooLargeError
from ..schemas import SaveRequest, SaveResponse, DocumentSummary from ..schemas import SaveRequest, SaveResponse, DocumentSummary
router = APIRouter(prefix="/api", tags=["documents"]) router = APIRouter(prefix="/api", tags=["documents"])
@@ -103,9 +104,11 @@ async def save_document_with_pdf(
status_code=400, detail=f"JSON invalide pour depenses_tags: {e}" status_code=400, detail=f"JSON invalide pour depenses_tags: {e}"
) )
# Read PDF content # Read PDF content (taille bornée)
try: try:
pdf_content = await pdf_file.read() pdf_content = await read_upload_limited(pdf_file)
except UploadTooLargeError as e:
raise HTTPException(status_code=413, detail=str(e))
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}") raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}")

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from ...extractor import extract_compte_rendu from ...extractor import extract_compte_rendu
from ...utils.uploads import read_upload_limited, UploadTooLargeError
router = APIRouter(prefix="/api", tags=["extraction"]) router = APIRouter(prefix="/api", tags=["extraction"])
@@ -47,10 +48,15 @@ async def extract_pdf(
tmp_path: Path | None = None tmp_path: Path | None = None
# Lecture bornée du contenu uploadé
try:
content = await read_upload_limited(file)
except UploadTooLargeError as e:
raise HTTPException(status_code=413, detail=str(e))
# Sauvegarde temporaire du fichier uploadé # Sauvegarde temporaire du fichier uploadé
try: try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file: with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
content = await file.read()
tmp_file.write(content) tmp_file.write(content)
tmp_path = Path(tmp_file.name) tmp_path = Path(tmp_file.name)

View File

@@ -1,49 +1,73 @@
"""Exécution SQL read-only sécurisée pour l'assistant IA.""" """Exécution SQL read-only sécurisée pour l'assistant IA.
Trois niveaux de défense, du plus fort au plus faible :
1. Connexion SQLite ouverte en ``mode=ro`` — garantie réelle au niveau du fichier.
2. Autorisation SQLite (``set_authorizer``) qui refuse toute opération non
read-only (writes, DDL, ATTACH…) au niveau du moteur, sans faux positifs.
3. Garde lexicale légère : une seule instruction, débutant par SELECT/WITH/PRAGMA,
uniquement pour produire des messages d'erreur clairs en amont.
"""
import re
import sqlite3 import sqlite3
from ..database.connection import get_db_path from ..database.connection import get_db_path
# Requêtes interdites (défense en profondeur) # PRAGMA autorisés (lecture de métadonnées uniquement)
_FORBIDDEN_PATTERN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|ATTACH|DETACH|REPLACE|GRANT|REVOKE)\b",
re.IGNORECASE,
)
# PRAGMA autorisés
_ALLOWED_PRAGMAS = {"table_info", "database_list", "table_list"} _ALLOWED_PRAGMAS = {"table_info", "database_list", "table_list"}
# Limite de résultats par défaut # Limite de résultats par défaut
MAX_ROWS = 500 MAX_ROWS = 500
# Codes d'action de l'autorisation SQLite (stables dans l'ABI SQLite ; définis
# en dur car les constantes sqlite3.SQLITE_* ne sont disponibles qu'à partir de
# Python 3.11 alors que le projet cible >= 3.10).
_SQLITE_OK = 0
_SQLITE_DENY = 1
_SQLITE_READ = 20
_SQLITE_SELECT = 21
_SQLITE_PRAGMA = 19
_SQLITE_FUNCTION = 31
_SQLITE_RECURSIVE = 33
_READONLY_ACTIONS = {_SQLITE_READ, _SQLITE_SELECT, _SQLITE_FUNCTION, _SQLITE_RECURSIVE}
def _authorizer(action: int, arg1, arg2, db_name, trigger) -> int:
"""Callback d'autorisation SQLite : n'autorise que les opérations de lecture."""
if action in _READONLY_ACTIONS:
return _SQLITE_OK
if action == _SQLITE_PRAGMA:
# arg1 = nom du PRAGMA
if arg1 and arg1.lower() in _ALLOWED_PRAGMAS:
return _SQLITE_OK
return _SQLITE_DENY
return _SQLITE_DENY
def _validate_sql(query: str) -> None: def _validate_sql(query: str) -> None:
"""Valide qu'une requête SQL est read-only. """Garde lexicale légère pour messages d'erreur clairs (pas la garde principale).
Raises: Raises:
ValueError: si la requête n'est pas autorisée. ValueError: si la requête n'est manifestement pas une lecture.
""" """
stripped = query.strip().rstrip(";").strip() stripped = query.strip().rstrip(";").strip()
# Refuser les instructions multiples (stacked queries)
if ";" in stripped:
raise ValueError("Une seule instruction SQL est autorisée")
upper = stripped.upper() upper = stripped.upper()
# Autoriser les PRAGMA spécifiques
if upper.startswith("PRAGMA"): if upper.startswith("PRAGMA"):
pragma_name = stripped.split("(")[0].split()[-1].lower().strip() pragma_name = stripped.split("(")[0].split()[-1].lower().strip()
if pragma_name not in _ALLOWED_PRAGMAS: if pragma_name not in _ALLOWED_PRAGMAS:
raise ValueError(f"PRAGMA '{pragma_name}' non autorisé") raise ValueError(f"PRAGMA '{pragma_name}' non autorisé")
return return
# La requête doit commencer par SELECT ou WITH
if not (upper.startswith("SELECT") or upper.startswith("WITH")): if not (upper.startswith("SELECT") or upper.startswith("WITH")):
raise ValueError("Seules les requêtes SELECT ou WITH sont autorisées") raise ValueError("Seules les requêtes SELECT ou WITH sont autorisées")
# Vérifier l'absence de mots-clés dangereux
match = _FORBIDDEN_PATTERN.search(stripped)
if match:
raise ValueError(f"Mot-clé SQL interdit détecté : {match.group()}")
def _ensure_limit(query: str) -> str: def _ensure_limit(query: str) -> str:
"""Ajoute LIMIT si absent.""" """Ajoute LIMIT si absent."""
@@ -64,7 +88,7 @@ def execute_readonly_sql(query: str) -> dict:
Raises: Raises:
ValueError: si la requête n'est pas autorisée. ValueError: si la requête n'est pas autorisée.
sqlite3.Error: si l'exécution échoue. sqlite3.Error: si l'exécution échoue (y compris refus de l'autorisation).
""" """
_validate_sql(query) _validate_sql(query)
query = _ensure_limit(query) query = _ensure_limit(query)
@@ -73,6 +97,7 @@ def execute_readonly_sql(query: str) -> dict:
uri = f"file:{db_path}?mode=ro" uri = f"file:{db_path}?mode=ro"
conn = sqlite3.connect(uri, uri=True) conn = sqlite3.connect(uri, uri=True)
try: try:
conn.set_authorizer(_authorizer)
cursor = conn.execute(query) cursor = conn.execute(query)
columns = [desc[0] for desc in cursor.description] if cursor.description else [] columns = [desc[0] for desc in cursor.description] if cursor.description else []
rows = cursor.fetchall() rows = cursor.fetchall()

View File

@@ -0,0 +1,48 @@
"""Lecture bornée des fichiers uploadés (protection mémoire / DoS)."""
from fastapi import UploadFile
# Taille maximale acceptée pour un PDF uploadé (20 Mo)
MAX_UPLOAD_SIZE = 20 * 1024 * 1024
_CHUNK_SIZE = 1024 * 1024 # 1 Mo
class UploadTooLargeError(Exception):
"""Levée quand un fichier uploadé dépasse la taille maximale autorisée."""
def __init__(self, max_size: int):
self.max_size = max_size
super().__init__(
f"Fichier trop volumineux (maximum {max_size // (1024 * 1024)} Mo)"
)
async def read_upload_limited(
file: UploadFile, max_size: int = MAX_UPLOAD_SIZE
) -> bytes:
"""Lit un fichier uploadé par morceaux en plafonnant la taille totale.
Évite de charger en mémoire un fichier arbitrairement gros.
Args:
file: Fichier uploadé (FastAPI UploadFile).
max_size: Taille maximale en octets.
Returns:
Contenu binaire du fichier.
Raises:
UploadTooLargeError: si le fichier dépasse ``max_size``.
"""
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(_CHUNK_SIZE)
if not chunk:
break
total += len(chunk)
if total > max_size:
raise UploadTooLargeError(max_size)
chunks.append(chunk)
return b"".join(chunks)

79
tests/conftest.py Normal file
View File

@@ -0,0 +1,79 @@
"""Fixtures partagées pour les tests."""
import pytest
from plesna_gerance.database import connection
@pytest.fixture
def db_session(tmp_path, monkeypatch):
"""Session SQLAlchemy sur une base SQLite temporaire et isolée.
Configure aussi PLESNA_DB_PATH / PLESNA_STORAGE_PATH pour que les helpers
qui lisent l'environnement (sql_executor, storage) ciblent le temp dir.
"""
db_path = tmp_path / "test.sqlite"
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
connection.reset_connection()
connection.init_db(db_path)
SessionLocal = connection.get_session_factory()
session = SessionLocal()
try:
yield session
finally:
session.close()
connection.reset_connection()
@pytest.fixture
def sample_data():
"""Données extraites minimales mais complètes pour save_document."""
return {
"metadata": {
"document": {
"reference": "REF001",
"date": "2024-01-15",
"type": "COMPTE RENDU DE GESTION",
},
"immeuble": {
"code": "IMM1",
"adresse": "4 RUE SERVIENT",
"ville": "LYON",
"code_postal": "69003",
},
"editeur": {"nom": "ORALIA", "siret": "12345678901234"},
"solde": {
"montant": 100.0,
"type": "crediteur",
"date_arrete": "2024-01-15",
},
},
"situation_locataires": [
{
"lot": {"numero": "001", "type": "Appartement"},
"locataire": {"nom": "DUPONT"},
"lignes": [
{
"type": "loyer",
"periode": {"debut": "2024-01-01", "fin": "2024-01-31"},
"loyers": 500.0,
"total": 500.0,
"regles": 500.0,
"impayes": 0.0,
}
],
}
],
"recapitulatif_operations": [
{
"categorie": "DEPENSES_LOCATIVES",
"sous_categorie": "Nettoyage",
"fournisseur": "ACME",
"description": "Nettoyage immeuble",
"montants": {"debit": 50.0},
}
],
}

39
tests/test_amounts.py Normal file
View File

@@ -0,0 +1,39 @@
"""Tests du parsing des montants français."""
import pytest
from plesna_gerance.utils.amounts import parse_amount, extract_amounts_from_line
@pytest.mark.parametrize(
"text,expected",
[
("123,45", 123.45),
("123.45", 123.45),
("1 234,56", 1234.56),
("1.234,56", 1234.56),
("1 234,56", 1234.56),
("123,45 €", 123.45),
("", 0.0),
("abc", 0.0),
("-45,67", -45.67),
],
)
def test_parse_amount(text, expected):
assert parse_amount(text) == pytest.approx(expected)
def test_extract_amounts_ignores_dates():
# 01.01.25 est une date, pas un montant
line = "Loyer du 01.01.25 au 31.01.25 : 500,00 réglé 500,00"
amounts = extract_amounts_from_line(line)
assert amounts == [500.00, 500.00]
def test_extract_amounts_negative_and_thousands():
line = "Solde -1 234,56 et frais 12,00"
assert extract_amounts_from_line(line) == [-1234.56, 12.00]
def test_extract_amounts_none_found():
assert extract_amounts_from_line("aucun montant ici") == []

View File

@@ -0,0 +1,103 @@
"""Tests de DatabaseService.save_document (logique métier centrale)."""
from datetime import date
import pytest
from plesna_gerance.database.service import DatabaseService, DuplicateDocumentError
from plesna_gerance.database.models import (
Document,
Immeuble,
Lot,
Locataire,
Revenu,
Depense,
Tag,
)
def test_save_document_creates_full_graph(db_session, sample_data):
service = DatabaseService(db_session)
doc = service.save_document(data=sample_data, source_file="cr.pdf")
assert doc.id is not None
assert doc.reference == "REF001"
assert doc.date == date(2024, 1, 15)
assert doc.solde_montant == 100.0
# Immeuble / lot / locataire créés
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
assert db_session.query(Lot).filter_by(numero="001").count() == 1
assert db_session.query(Locataire).filter_by(nom="DUPONT").count() == 1
# Revenu et dépense rattachés
revenus = db_session.query(Revenu).all()
assert len(revenus) == 1 and revenus[0].loyers == 500.0
depenses = db_session.query(Depense).all()
assert len(depenses) == 1 and depenses[0].debit == 50.0
def test_save_document_requires_reference_and_date(db_session):
service = DatabaseService(db_session)
with pytest.raises(ValueError):
service.save_document(data={"metadata": {"document": {}}})
def test_save_document_duplicate_raises(db_session, sample_data):
service = DatabaseService(db_session)
service.save_document(data=sample_data)
with pytest.raises(DuplicateDocumentError):
service.save_document(data=sample_data)
def test_save_document_overwrite_replaces(db_session, sample_data):
service = DatabaseService(db_session)
first = service.save_document(data=sample_data)
first_id = first.id
# Réécriture : l'ancien document est supprimé puis recréé (cascade incluse).
# NB : SQLite peut réutiliser le même rowid, on ne compare donc pas les ids.
assert first_id is not None
service.save_document(data=sample_data, overwrite=True)
assert db_session.query(Document).count() == 1
assert db_session.query(Revenu).count() == 1
assert db_session.query(Depense).count() == 1
def test_save_document_reuses_immeuble(db_session, sample_data):
service = DatabaseService(db_session)
service.save_document(data=sample_data)
# Deuxième document, même immeuble, référence différente
data2 = {**sample_data}
data2["metadata"] = {
**sample_data["metadata"],
"document": {**sample_data["metadata"]["document"], "reference": "REF002"},
}
service.save_document(data=data2)
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
assert db_session.query(Document).count() == 2
def test_save_document_assigns_tags(db_session, sample_data):
service = DatabaseService(db_session)
tag = db_session.query(Tag).first()
assert tag is not None
doc = service.save_document(
data=sample_data,
depenses_tags=[{"index": 0, "tag_id": tag.id}],
)
depense = db_session.query(Depense).filter_by(document_id=doc.id).first()
assert depense.tag_id == tag.id
def test_check_duplicate(db_session, sample_data):
service = DatabaseService(db_session)
assert service.check_duplicate("REF001", date(2024, 1, 15)) is None
service.save_document(data=sample_data)
found = service.check_duplicate("REF001", date(2024, 1, 15))
assert found is not None and found.reference == "REF001"

22
tests/test_dates.py Normal file
View File

@@ -0,0 +1,22 @@
"""Tests de la conversion des dates françaises en ISO."""
import pytest
from plesna_gerance.utils.dates import parse_french_date
@pytest.mark.parametrize(
"raw,expected",
[
("01.01.25", "2025-01-01"),
("31.12.99", "1999-12-31"), # < 50 -> 20xx, >= 50 -> 19xx
("15.06.49", "2049-06-15"),
("15.06.50", "1950-06-15"),
("01/01/2025", "2025-01-01"),
("01.01.2025", "2025-01-01"),
("", ""),
("pas une date", "pas une date"), # format inconnu -> renvoyé tel quel
],
)
def test_parse_french_date(raw, expected):
assert parse_french_date(raw) == expected

View File

@@ -0,0 +1,94 @@
"""Tests de l'exécution SQL read-only de l'assistant IA."""
import sqlite3
import pytest
from plesna_gerance.services.sql_executor import (
execute_readonly_sql,
_validate_sql,
_ensure_limit,
_authorizer,
_SQLITE_OK,
_SQLITE_DENY,
_SQLITE_SELECT,
_SQLITE_READ,
_SQLITE_PRAGMA,
MAX_ROWS,
)
# --- Garde lexicale -------------------------------------------------------
def test_validate_rejects_non_select():
with pytest.raises(ValueError):
_validate_sql("DELETE FROM tags")
def test_validate_rejects_stacked_statements():
with pytest.raises(ValueError):
_validate_sql("SELECT 1; DROP TABLE tags")
def test_validate_rejects_unknown_pragma():
with pytest.raises(ValueError):
_validate_sql("PRAGMA writable_schema = ON")
def test_validate_allows_select_and_with():
_validate_sql("SELECT * FROM tags")
_validate_sql("WITH t AS (SELECT 1) SELECT * FROM t")
_validate_sql("PRAGMA table_info(tags)")
def test_ensure_limit_adds_limit():
assert _ensure_limit("SELECT * FROM tags").endswith(f"LIMIT {MAX_ROWS}")
def test_ensure_limit_preserves_existing():
q = "SELECT * FROM tags LIMIT 5"
assert _ensure_limit(q) == q
# --- Autorisation SQLite (unitaire) ---------------------------------------
def test_authorizer_allows_reads():
assert _authorizer(_SQLITE_SELECT, None, None, None, None) == _SQLITE_OK
assert _authorizer(_SQLITE_READ, "tags", "nom", "main", None) == _SQLITE_OK
def test_authorizer_allows_whitelisted_pragma():
assert _authorizer(_SQLITE_PRAGMA, "table_info", "tags", None, None) == _SQLITE_OK
def test_authorizer_denies_unknown_pragma():
assert _authorizer(_SQLITE_PRAGMA, "writable_schema", "ON", None, None) == _SQLITE_DENY
def test_authorizer_denies_unknown_action():
# 9 = SQLITE_DELETE, doit être refusé
assert _authorizer(9, "tags", None, "main", None) == _SQLITE_DENY
# --- Exécution réelle (nécessite une base) --------------------------------
def test_execute_select_returns_rows(db_session):
# init_db seed des tags prédéfinis
result = execute_readonly_sql("SELECT nom FROM tags ORDER BY nom")
assert "nom" in result["columns"]
assert result["row_count"] >= 1
def test_execute_write_blocked(db_session):
# Bloqué par la garde lexicale (ValueError) ou, à défaut, par mode=ro /
# l'autorisation au niveau SQLite (DatabaseError). Dans tous les cas : refusé.
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("DELETE FROM tags")
def test_execute_attach_blocked(db_session):
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("ATTACH DATABASE 'x.db' AS x")

50
tests/test_storage.py Normal file
View File

@@ -0,0 +1,50 @@
"""Tests des helpers de stockage (chemins, sanitisation)."""
from datetime import date
import pytest
from plesna_gerance.database.storage import (
extract_street_letter,
sanitize_filename,
compute_document_paths,
)
@pytest.mark.parametrize(
"adresse,expected",
[
("4 RUE SERVIENT", "S"),
("33 RUE MARC BLOCH", "M"),
("12 AVENUE JEAN JAURES", "J"),
("1 BOULEVARD GAMBETTA", "G"),
(None, "X"),
("", "X"),
("42", "X"), # que des chiffres -> rien d'alpha
],
)
def test_extract_street_letter(adresse, expected):
assert extract_street_letter(adresse) == expected
def test_sanitize_filename_removes_problematic_chars():
assert sanitize_filename('a/b:c*d?"e') == "a_b_c_d__e"
def test_sanitize_filename_strips_dots_and_spaces():
assert sanitize_filename(" .nom. ") == "nom"
def test_compute_document_paths():
pdf, json_p = compute_document_paths(
reference="REF-01234",
doc_date=date(2024, 1, 15),
immeuble_adresse="4 RUE SERVIENT",
)
assert pdf == "2024/S_REF-01234_2024-01-15.pdf"
assert json_p == "2024/S_REF-01234_2024-01-15.json"
def test_compute_document_paths_unknown_address():
pdf, _ = compute_document_paths("R1", date(2023, 7, 9), None)
assert pdf == "2023/X_R1_2023-07-09.pdf"

29
tests/test_uploads.py Normal file
View File

@@ -0,0 +1,29 @@
"""Tests de la lecture bornée des uploads."""
import asyncio
import io
import pytest
from fastapi import UploadFile
from plesna_gerance.utils.uploads import read_upload_limited, UploadTooLargeError
def _upload(content: bytes) -> UploadFile:
return UploadFile(filename="x.pdf", file=io.BytesIO(content))
def test_read_upload_under_limit():
content = b"hello world"
result = asyncio.run(read_upload_limited(_upload(content), max_size=1024))
assert result == content
def test_read_upload_over_limit_raises():
content = b"x" * 2048
with pytest.raises(UploadTooLargeError):
asyncio.run(read_upload_limited(_upload(content), max_size=1024))
def test_read_upload_empty():
assert asyncio.run(read_upload_limited(_upload(b""), max_size=1024)) == b""

116
uv.lock generated
View File

@@ -229,6 +229,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]] [[package]]
name = "plesna-gerance" name = "plesna-gerance"
version = "0.1.0" version = "0.1.0"
@@ -242,6 +260,11 @@ dependencies = [
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.0" }, { name = "click", specifier = ">=8.0" },
@@ -252,6 +275,18 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.20.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.20.0" },
] ]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8.0" }]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@@ -385,6 +420,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
] ]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@@ -529,6 +591,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
] ]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"