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:
@@ -15,6 +15,16 @@ dependencies = [
|
||||
[project.scripts]
|
||||
plesna-gerance = "plesna_gerance.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
addopts = "-q"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
@@ -13,6 +13,7 @@ from ...database import get_session, DatabaseService, storage
|
||||
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||
from ...database.service import DuplicateDocumentError
|
||||
from ...extractor import extract_compte_rendu
|
||||
from ...utils.uploads import read_upload_limited, UploadTooLargeError
|
||||
from ..schemas import SaveRequest, SaveResponse, DocumentSummary
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
# Read PDF content
|
||||
# Read PDF content (taille bornée)
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ...extractor import extract_compte_rendu
|
||||
from ...utils.uploads import read_upload_limited, UploadTooLargeError
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["extraction"])
|
||||
|
||||
@@ -47,10 +48,15 @@ async def extract_pdf(
|
||||
|
||||
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é
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
|
||||
content = await file.read()
|
||||
tmp_file.write(content)
|
||||
tmp_path = Path(tmp_file.name)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
from ..database.connection import get_db_path
|
||||
|
||||
|
||||
# Requêtes interdites (défense en profondeur)
|
||||
_FORBIDDEN_PATTERN = re.compile(
|
||||
r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|ATTACH|DETACH|REPLACE|GRANT|REVOKE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# PRAGMA autorisés
|
||||
# PRAGMA autorisés (lecture de métadonnées uniquement)
|
||||
_ALLOWED_PRAGMAS = {"table_info", "database_list", "table_list"}
|
||||
|
||||
# Limite de résultats par défaut
|
||||
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:
|
||||
"""Valide qu'une requête SQL est read-only.
|
||||
"""Garde lexicale légère pour messages d'erreur clairs (pas la garde principale).
|
||||
|
||||
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()
|
||||
|
||||
# Refuser les instructions multiples (stacked queries)
|
||||
if ";" in stripped:
|
||||
raise ValueError("Une seule instruction SQL est autorisée")
|
||||
|
||||
upper = stripped.upper()
|
||||
|
||||
# Autoriser les PRAGMA spécifiques
|
||||
if upper.startswith("PRAGMA"):
|
||||
pragma_name = stripped.split("(")[0].split()[-1].lower().strip()
|
||||
if pragma_name not in _ALLOWED_PRAGMAS:
|
||||
raise ValueError(f"PRAGMA '{pragma_name}' non autorisé")
|
||||
return
|
||||
|
||||
# La requête doit commencer par SELECT ou WITH
|
||||
if not (upper.startswith("SELECT") or upper.startswith("WITH")):
|
||||
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:
|
||||
"""Ajoute LIMIT si absent."""
|
||||
@@ -64,7 +88,7 @@ def execute_readonly_sql(query: str) -> dict:
|
||||
|
||||
Raises:
|
||||
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)
|
||||
query = _ensure_limit(query)
|
||||
@@ -73,6 +97,7 @@ def execute_readonly_sql(query: str) -> dict:
|
||||
uri = f"file:{db_path}?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
conn.set_authorizer(_authorizer)
|
||||
cursor = conn.execute(query)
|
||||
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
||||
rows = cursor.fetchall()
|
||||
|
||||
48
src/plesna_gerance/utils/uploads.py
Normal file
48
src/plesna_gerance/utils/uploads.py
Normal 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
79
tests/conftest.py
Normal 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
39
tests/test_amounts.py
Normal 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") == []
|
||||
103
tests/test_database_service.py
Normal file
103
tests/test_database_service.py
Normal 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
22
tests/test_dates.py
Normal 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
|
||||
94
tests/test_sql_executor.py
Normal file
94
tests/test_sql_executor.py
Normal 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
50
tests/test_storage.py
Normal 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
29
tests/test_uploads.py
Normal 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
116
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "plesna-gerance"
|
||||
version = "0.1.0"
|
||||
@@ -242,6 +260,11 @@ dependencies = [
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.0" },
|
||||
@@ -252,6 +275,18 @@ requires-dist = [
|
||||
{ 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]]
|
||||
name = "pydantic"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dotenv"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user