Update manifest.json and infra README

- Updated timestamps and AST hashes in manifest.json for various backend and frontend files to reflect recent changes.
- Added new files related to MCC categories and optional hold reasons in the manifest.
- Modified the README in the infra directory to correct the linting tool from 'mypy' to 'ty' for type checking in the backend project.
This commit is contained in:
k1nq
2026-09-23 10:57:27 +05:00
parent db4dd7572e
commit 63d065011b
40 changed files with 11352 additions and 7475 deletions
@@ -0,0 +1,149 @@
"""MCC dictionary tables, category MCC links and default category seeding."""
import uuid
import sqlalchemy as sa
from alembic import op
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
TENANT_TABLE = "category_mcc_codes"
GLOBAL_TABLES = ("mcc_codes", "bank_category_mappings")
def _has_table(bind, table: str) -> bool:
return sa.inspect(bind).has_table(table)
def _space_ids(bind) -> list[str]:
return [str(row[0]) for row in bind.execute(sa.text("SELECT id FROM financial_spaces"))]
def _create_tenant_policy(table: str) -> None:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.execute(
f"CREATE POLICY tenant_isolation ON {table} "
"USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) "
"WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid)"
)
def upgrade() -> None:
bind = op.get_bind()
# Revision 0001 materializes the schema from live model metadata, so a
# database created fresh already carries these objects; guard every step.
if not _has_table(bind, "mcc_codes"):
op.create_table(
"mcc_codes",
sa.Column("code", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=300), nullable=False),
sa.Column("dictionary_version", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("code", name="pk_mcc_codes"),
)
if not _has_table(bind, "bank_category_mappings"):
op.create_table(
"bank_category_mappings",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("bank_code", sa.String(length=32), nullable=False),
sa.Column("bank_category", sa.String(length=200), nullable=False),
sa.Column("mcc_code", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_bank_category_mappings"),
sa.UniqueConstraint(
"bank_code", "bank_category", name="uq_bank_mappings_bank_category"
),
)
op.create_index(
"ix_bank_category_mappings_bank_code",
"bank_category_mappings",
["bank_code"],
)
if not _has_table(bind, TENANT_TABLE):
op.create_table(
TENANT_TABLE,
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("space_id", sa.Uuid(), nullable=False),
sa.Column("category_id", sa.Uuid(), nullable=False),
sa.Column("mcc_code", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id", name="pk_category_mcc_codes"),
sa.UniqueConstraint("space_id", "mcc_code", name="uq_category_mcc_space_code"),
sa.ForeignKeyConstraint(
["space_id", "category_id"],
["categories.space_id", "categories.id"],
ondelete="CASCADE",
name="fk_category_mcc_category_tenant",
),
sa.ForeignKeyConstraint(
["mcc_code"],
["mcc_codes.code"],
ondelete="RESTRICT",
name="fk_category_mcc_dictionary",
),
)
op.create_index("ix_category_mcc_codes_space_id", TENANT_TABLE, ["space_id"])
op.create_index("ix_category_mcc_codes_category_id", TENANT_TABLE, ["category_id"])
if bind.dialect.name == "postgresql":
_create_tenant_policy(TENANT_TABLE)
from sqlalchemy.orm import Session
from app.categorization.service import ensure_default_categories, ensure_mcc_dictionary
from app.db.session import set_space_context
session = Session(bind=bind)
try:
ensure_mcc_dictionary(session)
for space_id in _space_ids(bind):
set_space_context(session, space_id)
ensure_default_categories(session, space_id=uuid.UUID(hex=space_id))
session.flush()
finally:
session.close()
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
keys = _default_keys()
if inspector.has_table("categories"):
dependent = (
("operations", "operations o JOIN categories c ON c.id = o.category_id"),
("funding movements", "funding_movements f JOIN categories c ON c.id = f.category_id"),
("plan assignments", "plan_assignments p JOIN categories c ON c.id = p.category_id"),
(
"categorization rules",
"categorization_rules r JOIN categories c ON c.id = r.category_id",
),
)
blocked_by = []
for label, join in dependent:
query = f"SELECT 1 FROM {join} WHERE c.system_key IN :keys LIMIT 1"
if not inspector.has_table(join.split()[0]):
continue
stmt = sa.text(query).bindparams(sa.bindparam("keys", expanding=True))
if bind.execute(stmt, {"keys": keys}).first() is not None:
blocked_by.append(label)
if blocked_by:
raise RuntimeError(
"Cannot downgrade MCC migration while default categories hold history: "
+ ", ".join(blocked_by)
)
bind.execute(
sa.text("DELETE FROM categories WHERE system_key IN :keys").bindparams(
sa.bindparam("keys", expanding=True)
),
{"keys": keys},
)
if _has_table(bind, TENANT_TABLE):
op.drop_table(TENANT_TABLE)
def _default_keys() -> list[str]:
from app.categorization.mcc import default_category_system_keys
return list(default_category_system_keys())
@@ -0,0 +1,48 @@
"""Allow manual HOLD resolutions without a reason."""
import sqlalchemy as sa
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def _reason_constraint_name() -> str | None:
return next(
(
name
for item in sa.inspect(op.get_bind()).get_check_constraints("hold_resolutions")
if (name := item.get("name")) and name.endswith("manual_reason_required")
),
None,
)
def upgrade() -> None:
if name := _reason_constraint_name():
with op.batch_alter_table("hold_resolutions") as batch_op:
batch_op.drop_constraint(op.f(name), type_="check")
def downgrade() -> None:
if _reason_constraint_name():
return
bind = op.get_bind()
missing_reason = bind.execute(
sa.text(
"SELECT 1 FROM hold_resolutions WHERE resolution IN "
"('confirmed_as_posted', 'discarded_as_cancelled') "
"AND (reason IS NULL OR reason = '') LIMIT 1"
)
).first()
if missing_reason:
raise RuntimeError("Cannot require reasons while reasonless HOLD resolutions exist")
with op.batch_alter_table("hold_resolutions") as batch_op:
batch_op.create_check_constraint(
op.f("ck_hold_resolutions_manual_reason_required"),
"NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') "
"AND (reason IS NULL OR reason = ''))",
)
+49 -11
View File
@@ -1,4 +1,5 @@
import uuid
from collections import defaultdict
from datetime import date
from typing import Annotated, Literal
@@ -7,6 +8,7 @@ from pydantic import BaseModel, Field
from app.budgeting import service
from app.categorization import service as categorization
from app.categorization.models import CategoryMcc
from app.dependencies import Context, Csrf, Db
router = APIRouter(prefix="/budget", tags=["budget"])
@@ -15,6 +17,7 @@ router = APIRouter(prefix="/budget", tags=["budget"])
class CategoryBody(BaseModel):
name: str = Field(min_length=1, max_length=200)
kind: Literal["monthly", "savings_envelope"] = "monthly"
mcc_codes: list[int] = Field(default_factory=list, alias="mccCodes")
class AssignmentItem(BaseModel):
@@ -144,7 +147,7 @@ def categories(month: str, db: Db, context: Context) -> list[dict]:
return result
def category_definition_json(category) -> dict:
def category_definition_json(category, mcc_codes: list[int]) -> dict:
return {
"id": category.id,
"name": categorization.resolve_category_name(category),
@@ -153,6 +156,7 @@ def category_definition_json(category) -> dict:
"systemKey": category.system_key,
"linkedAccountId": category.linked_account_id,
"archived": category.archived,
"mccCodes": mcc_codes,
}
@@ -165,12 +169,20 @@ def category_definitions(
items = service.list_categories(
db, space_id=context.space.id, include_archived=include_archived
)
return [category_definition_json(item) for item in items]
codes_by_category: dict[uuid.UUID, list[int]] = defaultdict(list)
for category_id, code in (
db.query(CategoryMcc.category_id, CategoryMcc.mcc_code)
.filter_by(space_id=context.space.id)
.order_by(CategoryMcc.mcc_code)
):
codes_by_category[category_id].append(code)
return [category_definition_json(item, codes_by_category[item.id]) for item in items]
class CategoryDefinitionPatchBody(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
archived: bool | None = None
mcc_codes: list[int] | None = Field(default=None, alias="mccCodes")
@router.patch("/category-definitions/{category_id}")
@@ -181,15 +193,29 @@ def patch_category_definition(
context: Context,
_csrf: Csrf,
) -> dict:
category = service.update_category_definition(
db,
space_id=context.space.id,
user=context.user,
category_id=category_id,
name=body.name,
archived=body.archived,
category = service.get_category(db, space_id=context.space.id, category_id=category_id)
if body.name is not None or body.archived is not None:
category = service.update_category_definition(
db,
space_id=context.space.id,
user=context.user,
category_id=category_id,
name=body.name,
archived=body.archived,
)
if body.mcc_codes is not None:
categorization.set_category_mcc_codes(
db,
space_id=context.space.id,
category_id=category_id,
codes=body.mcc_codes,
)
return category_definition_json(
category,
categorization.get_category_mcc_codes(
db, space_id=context.space.id, category_id=category.id
),
)
return category_definition_json(category)
@router.post("/categories", status_code=201)
@@ -201,7 +227,19 @@ def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -
kind=body.kind,
name=body.name,
)
return category_definition_json(category)
if body.mcc_codes:
categorization.set_category_mcc_codes(
db,
space_id=context.space.id,
category_id=category.id,
codes=body.mcc_codes,
)
return category_definition_json(
category,
categorization.get_category_mcc_codes(
db, space_id=context.space.id, category_id=category.id
),
)
@router.patch("/categories/{category_id}")
+4 -2
View File
@@ -16,6 +16,7 @@ from app.budgeting.models import (
PeriodClosure,
PlanAssignment,
)
from app.categorization import mcc
from app.categorization.service import resolve_category_name
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
@@ -351,6 +352,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
"category_id": str(cat.id),
"name": resolve_category_name(cat),
"name_key": cat.system_key,
"system": not mcc.is_budgetable_category_key(cat.system_key),
"archived": cat.archived,
"planned": 0,
"funded": funded_period[cat.id],
@@ -681,7 +683,7 @@ def set_assignments(
result = []
for item in items:
category = get_category(db, space_id=space_id, category_id=item["category_id"])
if category.system_key is not None:
if not mcc.is_budgetable_category_key(category.system_key):
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be planned")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
@@ -800,7 +802,7 @@ def fund(
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
category = get_category(db, space_id=space_id, category_id=category_id)
if category.system_key is not None:
if not mcc.is_budgetable_category_key(category.system_key):
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be funded")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
+379
View File
@@ -0,0 +1,379 @@
"""MCC catalog backed by the iso18245 library with Russian labels.
The global dictionary seeds the ``mcc_codes`` table; the curated default
categories below pre-create the 20 most popular budget categories with their
associated MCC codes (one code may belong to exactly one category per space).
Codes outside the curated categories resolve to the system fallback category
«Остальное» without any manual category creation.
"""
from collections.abc import Iterator
from functools import lru_cache
from typing import NamedTuple
import iso18245
MCC_DICTIONARY_VERSION = 1
OTHERS_SYSTEM_KEY = "others"
OTHERS_CATEGORY_NAME = "Остальное"
_DESCRIPTION_PRIORITY = (
"iso_description",
"visa_description",
"usda_description",
"alipay_description",
"mastercard_description",
"amex_description",
"stripe_description",
)
class DefaultCategory(NamedTuple):
system_key: str
name: str
mcc_codes: tuple[int, ...]
_RU_TITLES: dict[int, str] = {
# Продукты и супермаркеты
5411: "Продуктовые магазины и супермаркеты",
5422: "Мясные и замороженные продукты",
5441: "Кондитерские магазины",
5451: "Молочные продукты",
5462: "Пекарни и булочные",
5499: "Прочие продуктовые магазины",
5300: "Оптовые магазины-склады",
# Кафе и рестораны
5811: "Кейтеринг и обслуживание мероприятий",
5812: "Рестораны и кафе",
5813: "Бары и ночные клубы",
5814: "Фастфуд",
5817: "Цифровые товары — приложения",
5818: "Цифровые товары — крупные покупки",
# Транспорт
4111: "Городской и пригородный транспорт",
4112: "Пассажирские железные дороги",
4121: "Такси и лимузины",
4131: "Автобусные линии",
4784: "Платные дороги и мосты",
4789: "Транспортные услуги (прочие)",
4411: "Круизные линии и пароходства",
# Топливо
5541: "АЗС (автозаправочные станции)",
5542: "Автоматические АЗС",
5172: "Нефть и нефтепродукты",
# Здоровье и аптеки
5912: "Аптеки",
5122: "Лекарственные препараты",
8099: "Медицинские услуги (прочие)",
8011: "Врачи и клиники",
8021: "Стоматологи и ортодонты",
8041: "Хиропрактики",
8042: "Офтальмологи и оптометристы",
8049: "Подологи",
8062: "Больницы",
8071: "Медицинские и стоматологические лаборатории",
5975: "Слуховые аппараты",
5976: "Ортопедические товары и протезы",
# Электроника
5732: "Магазины электроники",
5734: "Магазины компьютерного ПО",
5045: "Компьютеры и периферия",
5046: "Коммерческое оборудование",
7372: "Программирование и ИТ-услуги",
7379: "Обслуживание и ремонт компьютеров",
# Одежда и обувь
5611: "Мужская одежда",
5621: "Женская одежда",
5631: "Женские аксессуары",
5651: "Одежда для всей семьи",
5655: "Спортивная одежда",
5661: "Обувные магазины",
5691: "Одежда (мужская и женская)",
5697: "Ателье и ремонт одежды",
5681: "Меховые салоны",
5949: "Ткани и швейные товары",
5699: "Прочие магазины одежды и аксессуаров",
# Красота и уход
7230: "Парикмахерские и салоны красоты",
7297: "Массажные салоны",
5977: "Магазины косметики",
# Развлечения
7832: "Кинотеатры",
7841: "Прокат видео и стриминг",
7922: "Театры и билетные агентства",
7933: "Боулинг",
7991: "Туристические достопримечательности",
7994: "Игровые автоматы и залы",
7996: "Парки развлечений и цирки",
7997: "Спортивные и членские клубы",
7999: "Развлекательные услуги (прочие)",
7992: "Гольф-клубы",
7998: "Океанариумы и дельфинарии",
7995: "Ставки и лотереи",
# Спорт
5941: "Магазины спортивных товаров",
7941: "Профессиональный спорт и спортивные клубы",
# Путешествия
7011: "Отели и курорты",
7012: "Таймшер",
7032: "Спортивные и рекреационные лагеря",
7033: "Кемпинги и трейлерные парки",
# Дом и ремонт
5211: "Стройматериалы и пиломатериалы",
5251: "Хозяйственные магазины",
5261: "Садовые товары и питомники",
5712: "Мебель и предметы интерьера",
5713: "Напольные покрытия",
5714: "Шторы и обивка",
5718: "Камины и аксессуары",
5719: "Прочие товары для дома",
7622: "Ремонт электроники",
7623: "Ремонт кондиционеров и холодильников",
7629: "Ремонт электроприборов",
7641: "Ремонт и перетяжка мебели",
7692: "Сварочные работы",
7699: "Прочие ремонтные услуги",
1520: "Генеральные подрядчики",
1711: "Отопление, сантехника и кондиционирование",
1731: "Электромонтажные работы",
1740: "Каменные и штукатурные работы",
1750: "Плотницкие работы",
1761: "Кровельные работы",
1771: "Бетонные работы",
1799: "Прочие строительные подрядчики",
5198: "Краски и лаки",
# Связь и интернет
4812: "Продажа телефонов и телеком-оборудования",
4814: "Телекоммуникационные услуги",
4815: "Ежемесячная плата за телефон",
4816: "Компьютерные сети и интернет-услуги",
4821: "Телеграфные услуги",
4899: "Кабельное телевидение",
# Коммунальные услуги
4900: "Коммунальные услуги (электричество, газ, вода)",
# Образование
8211: "Школы",
8220: "Вузы и колледжи",
8241: "Заочные школы",
8244: "Бизнес-школы",
8249: "Профессиональные и технические училища",
8299: "Образовательные услуги (прочие)",
5943: "Канцелярские и школьные товары",
# Дети
5945: "Детские игрушки и игры",
5641: "Детская одежда",
# Питомцы
742: "Ветеринарные услуги",
5995: "Зоомагазины",
# Подарки и хобби
5944: "Ювелирные магазины и часы",
5946: "Фототовары",
5971: "Художественные салоны и галереи",
5947: "Подарки и сувениры",
5950: "Стекло и хрусталь",
5931: "Комиссионные и секонд-хенды",
5942: "Книжные магазины",
5992: "Цветочные магазины",
# Авто
7531: "Кузовной ремонт",
7534: "Шиномонтаж и ремонт шин",
7535: "Покраска автомобилей",
7538: "Автосервисы (не дилеры)",
7542: "Автомойки",
7549: "Эвакуация автомобилей",
7523: "Парковки и гаражи",
7519: "Прокат домов на колёсах",
# Финансы и страхование
6010: "Снятие наличных (через оператора)",
6011: "Снятие наличных в банкоматах",
6012: "Финансовые учреждения — товары и услуги",
6050: "Квазиналичные операции",
6051: "Иностранная валюта и денежные переводы",
6211: "Ценные бумаги и брокеры",
6300: "Страхование",
6381: "Страховые премии",
6399: "Страхование (прочее)",
6513: "Риелторы и управление недвижимостью",
# Прочие популярные подписи без категории
5309: "Магазины беспошлинной торговли",
5310: "Магазины скидок",
5331: "Универсальные магазины",
5399: "Прочие товары общего назначения",
5722: "Магазины бытовой техники",
5994: "Газетные киоски",
5970: "Товары для творчества",
5999: "Прочие специализированные магазины",
5111: "Канцелярия и офисные принадлежности",
5192: "Книги и периодика",
5193: "Товары для цветоводства",
8111: "Юридические услуги",
7276: "Подготовка налоговых деклараций",
8734: "Испытательные лаборатории",
2741: "Издательские и полиграфические услуги",
8911: "Архитектурные и инженерные услуги",
}
_AIRLINE_MCC_RANGE = range(3000, 3351)
_CAR_RENTAL_MCC_RANGE = range(3351, 3501)
_DEFAULT_CATEGORY_KEYS = (
("groceries", "Продукты и супермаркеты", (5411, 5422, 5441, 5451, 5462, 5499, 5300)),
("cafes", "Кафе и рестораны", (5811, 5812, 5813, 5814)),
("transport", "Транспорт", (4111, 4112, 4121, 4131, 4784, 4789, 4411)),
("fuel", "Топливо и АЗС", (5541, 5542, 5172)),
(
"health",
"Здоровье и аптеки",
(5912, 5122, 8099, 8011, 8021, 8041, 8042, 8049, 8062, 8071, 5975, 5976),
),
("electronics", "Электроника", (5732, 5734, 5045, 5046, 7372, 7379)),
(
"clothing",
"Одежда и обувь",
(5611, 5621, 5631, 5651, 5655, 5661, 5691, 5697, 5681, 5949, 5699),
),
("beauty", "Красота и уход", (7230, 7297, 5977)),
(
"entertainment",
"Развлечения",
(7832, 7841, 7922, 7933, 7991, 7994, 7996, 7997, 7999, 7992, 7998, 7995),
),
("sport", "Спорт и фитнес", (5941, 7941)),
(
"travel",
"Путешествия",
(7011, 7012, 7032, 7033, *_AIRLINE_MCC_RANGE, *_CAR_RENTAL_MCC_RANGE),
),
(
"home",
"Дом и ремонт",
(
5211,
5251,
5261,
5712,
5713,
5714,
5718,
5719,
7622,
7623,
7629,
7641,
7692,
7699,
1520,
1711,
1731,
1740,
1750,
1761,
1771,
1799,
5198,
),
),
("telecom", "Связь и интернет", (4812, 4814, 4815, 4816, 4821, 4899)),
("utilities", "Коммунальные услуги", (4900,)),
("education", "Образование", (8211, 8220, 8241, 8244, 8249, 8299, 5943)),
("kids", "Дети", (5945, 5641)),
("pets", "Питомцы", (742, 5995)),
("gifts", "Подарки и хобби", (5944, 5946, 5971, 5947, 5950, 5931, 5942, 5992)),
("auto", "Авто", (7531, 7534, 7535, 7538, 7542, 7549, 7523, 7519)),
(
"finance",
"Финансы и страхование",
(6010, 6011, 6012, 6050, 6051, 6211, 6300, 6381, 6399, 6513),
),
)
_TRANSLIT_MAP = {
"a": "а",
"b": "б",
"c": "к",
"d": "д",
"e": "е",
"f": "ф",
"g": "г",
"h": "х",
"i": "и",
"j": "дж",
"k": "к",
"l": "л",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"q": "к",
"r": "р",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"w": "в",
"x": "кс",
"y": "й",
"z": "з",
}
@lru_cache(maxsize=1)
def _english_titles() -> dict[int, str]:
titles: dict[int, str] = {}
for entry in iso18245.get_all_mccs():
for field in _DESCRIPTION_PRIORITY:
value = getattr(entry, field)
if value:
titles[int(entry.mcc)] = value
break
else:
titles[int(entry.mcc)] = entry.range.description
return titles
@lru_cache(maxsize=1)
def _default_categories() -> tuple[DefaultCategory, ...]:
known = _english_titles()
result = []
for key, name, codes in _DEFAULT_CATEGORY_KEYS:
result.append(DefaultCategory(key, name, tuple(c for c in codes if c in known)))
return tuple(result)
def english_title(code: int) -> str | None:
return _english_titles().get(code)
def transliterate(text: str) -> str:
return "".join(_TRANSLIT_MAP.get(ch.lower(), ch) for ch in text)
def russian_title(code: int) -> str | None:
"""Curated Russian translation, else a Cyrillic transcription of the label."""
curated = _RU_TITLES.get(code)
if curated is not None:
return curated
english = english_title(code)
if english is None:
return None
return transliterate(english).capitalize()
def iter_mcc_dictionary() -> Iterator[tuple[int, str]]:
"""Yield ``(code, russian_title)`` for every MCC known to iso18245."""
for code, english in sorted(_english_titles().items()):
yield code, _RU_TITLES.get(code, transliterate(english).capitalize())
def default_categories() -> tuple[DefaultCategory, ...]:
"""The 20 pre-created popular categories with validated MCC code lists."""
return _default_categories()
def default_category_system_keys() -> tuple[str, ...]:
return tuple(item.system_key for item in default_categories()) + (OTHERS_SYSTEM_KEY,)
def is_budgetable_category_key(key: str | None) -> bool:
return key is None or key in default_category_system_keys()
+28
View File
@@ -100,6 +100,34 @@ class MccCode(Base):
dictionary_version: Mapped[int] = mapped_column(nullable=False, default=1)
class CategoryMcc(Base, PkMixin):
"""Space-scoped MCC codes of a category: many codes per category, but every
code maps to at most one category inside the space."""
__tablename__ = "category_mcc_codes"
__table_args__ = (
UniqueConstraint("space_id", "mcc_code", name="uq_category_mcc_space_code"),
ForeignKeyConstraint(
["space_id", "category_id"],
["categories.space_id", "categories.id"],
ondelete="CASCADE",
name="fk_category_mcc_category_tenant",
),
ForeignKeyConstraint(
["mcc_code"],
["mcc_codes.code"],
ondelete="RESTRICT",
name="fk_category_mcc_dictionary",
),
)
space_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
)
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
mcc_code: Mapped[int] = mapped_column(Integer, nullable=False)
class BankCategoryMapping(Base, PkMixin):
"""Maps a bank's raw category string to an MCC for suggestion purposes."""
+152 -1
View File
@@ -1,6 +1,17 @@
import uuid
from collections.abc import Iterable
from sqlalchemy.orm import Session
from app.categorization.models import CategorizationRule, IncomeSource, Payee, PayeeAlias
from app.categorization import mcc
from app.categorization.models import (
CategorizationRule,
CategoryMcc,
IncomeSource,
MccCode,
Payee,
PayeeAlias,
)
from app.errors import conflict, not_found
from app.ledger.models import Category
@@ -102,3 +113,143 @@ def set_payee_rule(db: Session, *, space_id, payee_id, category_id, user_id) ->
rule.category_id = category_id
db.flush()
return rule
def ensure_mcc_dictionary(db: Session) -> None:
"""Idempotently seed the global ``mcc_codes`` table from iso18245."""
existing = {code for (code,) in db.query(MccCode.code).all()}
for code, title in mcc.iter_mcc_dictionary():
if code in existing:
continue
db.add(MccCode(code=code, title=title[:300], dictionary_version=mcc.MCC_DICTIONARY_VERSION))
db.flush()
def ensure_default_categories(db: Session, *, space_id: uuid.UUID) -> dict[str, Category]:
"""Idempotently pre-create the 20 popular categories plus «Остальное».
Each default category is keyed by ``system_key`` and carries its MCC code
links; every link is only created while its MCC code is unclaimed inside
the space, so user re-mappings are never overwritten.
"""
ensure_mcc_dictionary(db)
keys = mcc.default_category_system_keys()
existing: dict[str, Category] = {
category.system_key: category
for category in db.query(Category)
.filter(Category.space_id == space_id, Category.system_key.in_(keys))
.all()
if category.system_key is not None
}
result: dict[str, Category] = {}
claimed = {
code for (code,) in db.query(CategoryMcc.mcc_code).filter_by(space_id=space_id).all()
}
for definition in mcc.default_categories():
category = existing.get(definition.system_key)
if category is None:
category = Category(
space_id=space_id,
kind="monthly",
system_key=definition.system_key,
custom_name=definition.name,
)
db.add(category)
db.flush()
for code in definition.mcc_codes:
if code in claimed:
continue
db.add(CategoryMcc(space_id=space_id, category_id=category.id, mcc_code=code))
claimed.add(code)
result[definition.system_key] = category
others = existing.get(mcc.OTHERS_SYSTEM_KEY)
if others is None:
others = Category(
space_id=space_id,
kind="monthly",
system_key=mcc.OTHERS_SYSTEM_KEY,
custom_name=mcc.OTHERS_CATEGORY_NAME,
)
db.add(others)
db.flush()
result[mcc.OTHERS_SYSTEM_KEY] = others
db.flush()
return result
def category_for_mcc(db: Session, *, space_id: uuid.UUID, code: int) -> Category | None:
"""Category owning the MCC code, else the system «Остальное» fallback."""
link = db.query(CategoryMcc).filter_by(space_id=space_id, mcc_code=code).one_or_none()
if link is not None:
category = db.get(Category, link.category_id)
if category is not None and not category.archived:
return category
return (
db.query(Category)
.filter_by(space_id=space_id, system_key=mcc.OTHERS_SYSTEM_KEY)
.one_or_none()
)
def get_category_mcc_codes(
db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID
) -> list[int]:
codes = (
db.query(CategoryMcc.mcc_code)
.filter_by(space_id=space_id, category_id=category_id)
.order_by(CategoryMcc.mcc_code)
.all()
)
return [code for (code,) in codes]
def set_category_mcc_codes(
db: Session,
*,
space_id: uuid.UUID,
category_id: uuid.UUID,
codes: Iterable[int],
) -> list[int]:
"""Replace the category's MCC code set; codes are unique per space."""
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
if category is None:
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
wanted = sorted({int(code) for code in codes})
if wanted and category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Archived categories cannot have MCC codes")
if wanted and category.kind != "monthly":
raise conflict("CATEGORY_MCC_KIND_INVALID", "Only monthly categories can have MCC codes")
for code in wanted:
if db.get(MccCode, code) is None:
raise not_found("MCC_CODE_NOT_FOUND", f"Unknown MCC code {code}")
current = {
row for row in db.query(CategoryMcc).filter_by(space_id=space_id, category_id=category_id)
}
wanted_set = set(wanted)
for row in current:
if row.mcc_code not in wanted_set:
db.delete(row)
owned_elsewhere = {
code
for (code,) in (
db.query(CategoryMcc.mcc_code)
.filter(
CategoryMcc.space_id == space_id,
CategoryMcc.category_id != category_id,
CategoryMcc.mcc_code.in_(wanted_set),
)
.all()
)
}
if owned_elsewhere:
raise conflict(
"CATEGORY_MCC_TAKEN",
"MCC codes already mapped to another category",
details={"mcc_codes": sorted(owned_elsewhere)},
)
current_codes = {row.mcc_code for row in current}
for code in wanted:
if code not in current_codes:
db.add(CategoryMcc(space_id=space_id, category_id=category_id, mcc_code=code))
db.flush()
return wanted
+2
View File
@@ -12,6 +12,7 @@ from app.budgeting.models import (
from app.categorization.models import (
BankCategoryMapping,
CategorizationRule,
CategoryMcc,
IncomeSource,
MccCode,
Payee,
@@ -35,6 +36,7 @@ __all__ = [
"BankCategoryMapping",
"BudgetPeriod",
"Category",
"CategoryMcc",
"CategorizationRule",
"DataExport",
"DedupCandidate",
+3 -3
View File
@@ -214,9 +214,9 @@ def _find_header(rows: list[tuple]) -> tuple[int, dict[str, int]]:
("category", CATEGORY_HEADERS),
("external", ("id операции", "номер операции")),
):
columns[key] = next( # type: ignore[assignment]
(i for i, c in enumerate(cells) if c in synonyms), None
)
optional_col = next((i for i, c in enumerate(cells) if c in synonyms), None)
if optional_col is not None:
columns[key] = optional_col
return index, columns
raise UnsupportedStatementLayout("statement header row with date/amount columns not found")
+1 -6
View File
@@ -222,7 +222,7 @@ class HoldResolution(Base, PkMixin, TimestampMixin):
The extracted HOLD row itself is never mutated. At most one resolution
exists per hold source record; automatic matches (`matched_posted`) are
created only when a later statement is confirmed, and manual actions
(`confirmed_as_posted`, `discarded_as_cancelled`) always carry a reason.
(`confirmed_as_posted`, `discarded_as_cancelled`) may carry a reason.
"""
__tablename__ = "hold_resolutions"
@@ -231,11 +231,6 @@ class HoldResolution(Base, PkMixin, TimestampMixin):
"resolution IN ('matched_posted', 'confirmed_as_posted', 'discarded_as_cancelled')",
name="resolution_valid",
),
CheckConstraint(
"NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') "
"AND (reason IS NULL OR reason = ''))",
name="manual_reason_required",
),
ForeignKeyConstraint(
["space_id", "hold_source_record_id"],
["source_records.space_id", "source_records.id"],
+4 -3
View File
@@ -30,14 +30,13 @@ class ConfirmBody(BaseModel):
confirmed_without_full_reconciliation: bool = Field(
default=False, alias="confirmedWithoutFullReconciliation"
)
reason: str | None = None
class HoldResolveBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
action: str
reason: str
reason: str | None = None
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
@@ -160,6 +159,8 @@ def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
"direction": item["direction"],
"bankStatus": item.get("bank_status", "posted"),
"description": item["description"],
"mcc": item["mcc"],
"mccName": item["mcc_name"],
"payeeHint": None,
"warnings": (item["warnings"] or {}).get("codes", []),
"decision": "pending"
@@ -306,7 +307,7 @@ def confirm(
document_id=document_id,
account_id=document.account_id,
confirmed_without_full_reconciliation=True,
reason=body.reason,
reason=None,
)
document = service.confirm_document(
db, space_id=context.space.id, document_id=document_id, user=context.user
+37 -15
View File
@@ -9,6 +9,9 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.categorization import mcc
from app.categorization import service as categorization
from app.categorization.models import MccCode
from app.config import get_settings
from app.db.base import utcnow
from app.db.session import session_scope
@@ -169,6 +172,15 @@ def _source_from_row(
)
def _category_id_for_source(
db: Session, *, space_id: uuid.UUID, source: SourceRecord
) -> uuid.UUID | None:
if source.mcc is None:
return None
category = categorization.category_for_mcc(db, space_id=space_id, code=source.mcc)
return category.id if category is not None else None
def _reconciliation_summary(draft: Any) -> dict[str, Any]:
"""Reconcile parsed rows against declared bank controls with stable diagnostics.
@@ -471,7 +483,8 @@ def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> s
document.adapter_id = adapter.adapter_id
document.account_fingerprint = draft.account_fingerprint
document.active_parse_run_id = run.id
document.reconciliation = _reconciliation_summary(draft)
reconciliation = _reconciliation_summary(draft)
document.reconciliation = reconciliation
if document.account_id is None and draft.account_fingerprint:
matches = (
db.query(Account)
@@ -492,14 +505,14 @@ def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> s
db.query(SourceRecord.id).filter_by(parse_run_id=run.id, critical=True).first()
)
document.needs_account_mapping = document.account_id is None
needs_manual_reconciliation = not document.reconciliation["full"]
needs_manual_reconciliation = not reconciliation["full"]
document.status = (
"review_required"
if critical
or document.needs_account_mapping
or needs_manual_reconciliation
or has_candidates
or document.reconciliation.get("matched") is False
or reconciliation.get("matched") is False
else "ready"
)
except AppError as exc:
@@ -601,6 +614,15 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID)
.all()
)
record_ids = [record.id for record in records]
codes = {record.mcc for record in records if record.mcc is not None}
mcc_names: dict[int, str] = {
code: title
for code, title in (
db.query(MccCode.code, MccCode.title).filter(MccCode.code.in_(codes)).all()
if codes
else []
)
}
resolutions: dict[uuid.UUID, dict[str, Any]] = {}
candidates: dict[uuid.UUID, list[dict[str, Any]]] = {}
if record_ids:
@@ -656,6 +678,9 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID)
"direction": item.direction,
"bank_status": item.bank_status,
"description": item.description,
"mcc": item.mcc,
"mcc_name": mcc_names.get(item.mcc)
or (mcc.russian_title(item.mcc) if item.mcc is not None else None),
"critical": item.critical,
"warnings": item.field_warnings,
"review_decision": item.review_decision,
@@ -755,7 +780,7 @@ def set_review_confirmation(
document_id: uuid.UUID,
account_id: uuid.UUID,
confirmed_without_full_reconciliation: bool,
reason: str | None,
reason: str | None = None,
) -> StatementDocument:
document = _document(db, space_id=space_id, document_id=document_id)
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
@@ -766,13 +791,11 @@ def set_review_confirmation(
"IMPORT_SYSTEM_ACCOUNT_INVALID",
"System accounts cannot be used as statement accounts",
)
if confirmed_without_full_reconciliation and not reason:
raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required")
document.account_id = account.id
document.needs_account_mapping = False
reconciliation = dict(document.reconciliation or {})
reconciliation["confirmed_without_full_reconciliation"] = confirmed_without_full_reconciliation
reconciliation["reason"] = reason
reconciliation["reason"] = reason.strip() or None if reason is not None else None
document.reconciliation = reconciliation
critical = (
db.query(SourceRecord.id)
@@ -801,7 +824,7 @@ def resolve_hold(
source_record_id: uuid.UUID,
user: User,
action: str,
reason: str | None,
reason: str | None = None,
account_id: uuid.UUID | None = None,
) -> HoldResolution:
"""Manually settle one HOLD: book it as a normal operation or discard it."""
@@ -813,8 +836,7 @@ def resolve_hold(
)
if action not in HOLD_MANUAL_ACTIONS:
raise bad_request("IMPORT_HOLD_ACTION_INVALID", "Unknown hold resolution action")
if reason is None or not reason.strip():
raise conflict("IMPORT_HOLD_REASON_REQUIRED", "A resolution reason is required")
reason = reason.strip() or None if reason is not None else None
source = (
db.query(SourceRecord)
.filter_by(
@@ -842,7 +864,7 @@ def resolve_hold(
if action == "confirm_as_posted"
else "discarded_as_cancelled",
resolved_by_user_id=user.id,
reason=reason.strip(),
reason=reason,
resolved_at=utcnow(),
)
if action == "confirm_as_posted":
@@ -874,6 +896,7 @@ def resolve_hold(
user=user,
account_id=account.id,
amount_kopecks=source.amount_kopecks,
category_id=_category_id_for_source(db, space_id=space_id, source=source),
occurred_on=source.occurred_on,
posted_on=source.posted_on,
description=source.description,
@@ -900,7 +923,7 @@ def resolve_hold(
detail={
"document_id": str(document.id),
"source_record_id": str(source.id),
"reason": reason.strip(),
"reason": reason,
},
)
return resolution
@@ -1073,9 +1096,7 @@ def confirm_document(
if source.review_decision == "savings_transfer":
from app.ledger.savings import ensure_savings_resources
savings_account, _savings_category = ensure_savings_resources(
db, space_id=space_id
)
savings_account, _savings_category = ensure_savings_resources(db, space_id=space_id)
if document.account_id == savings_account.id:
raise conflict(
"IMPORT_SAVINGS_SOURCE_INVALID",
@@ -1160,6 +1181,7 @@ def confirm_document(
user=user,
account_id=document.account_id,
amount_kopecks=source.amount_kopecks,
category_id=_category_id_for_source(db, space_id=space_id, source=source),
occurred_on=source.occurred_on,
posted_on=source.posted_on,
description=source.description,
+2 -1
View File
@@ -7,6 +7,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.categorization import mcc
from app.categorization import service as categorization
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
@@ -67,7 +68,7 @@ def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UU
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
if category.system_key is not None:
if not mcc.is_budgetable_category_key(category.system_key):
raise conflict(
"BUDGET_CATEGORY_SYSTEM",
"System categories cannot be assigned to regular operations",
+2
View File
@@ -15,9 +15,11 @@ def create_space(db: Session, *, user: User, name: str) -> FinancialSpace:
db.flush()
set_space_context(db, space.id)
db.add(Membership(space_id=space.id, user_id=user.id, role="owner"))
from app.categorization.service import ensure_default_categories
from app.ledger.savings import ensure_savings_resources
ensure_savings_resources(db, space_id=space.id)
ensure_default_categories(db, space_id=space.id)
audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id)
return space
+5 -10
View File
@@ -21,14 +21,15 @@ dependencies = [
"pdfplumber>=0.11",
"python-multipart>=0.0.9",
"boto3>=1.35",
"iso18245>=1.4.1",
]
[dependency-groups]
dev = [
"pytest>=8.2",
"httpx>=0.27",
"httpx2>=2.0",
"ruff>=0.5",
"mypy>=1.10",
"ty>=0.0.18",
"hypothesis>=6.112",
]
@@ -52,11 +53,5 @@ target-version = "py314"
select = ["E", "F", "I", "UP", "B"]
ignore = ["B008"]
[tool.mypy]
python_version = "3.14"
ignore_missing_imports = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
disable_error_code = ["import-untyped"]
exclude = ["alembic/", "tests/"]
[tool.ty.src]
include = ["app"]
+55
View File
@@ -13,8 +13,10 @@ from app.budgeting.service import (
reopen_period,
set_assignments,
)
from app.categorization import service as categorization
from app.errors import AppError
from app.identity.models import User
from app.ledger.models import Category
from app.ledger.service import create_account, create_expense
from app.spaces.service import create_space
@@ -51,6 +53,59 @@ def test_uncategorized_expense_blocks_close(db: Session) -> None:
assert exc.value.code == "BUDGET_CLOSE_BLOCKED"
def test_default_mcc_categories_are_budgetable_and_editable_mapping(db: Session) -> None:
user, space = seed(db)
defaults = db.query(Category).filter_by(space_id=space.id).all()
by_key = {category.system_key: category for category in defaults}
assert len([key for key in by_key if key not in {None, "savings"}]) == 21
groceries = by_key["groceries"]
others = by_key["others"]
assert {5411, 5422}.issubset(
categorization.get_category_mcc_codes(db, space_id=space.id, category_id=groceries.id)
)
period = get_or_create_period(db, space_id=space.id, year=2026, month=5)
set_assignments(
db,
space_id=space.id,
user=user,
period=period,
items=[{"category_id": groceries.id, "planned_kopecks": 2_000}],
)
account = create_account(
db,
space_id=space.id,
user=user,
name="Cash",
kind="cash",
opened_on=date(2026, 5, 1),
)
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=500,
occurred_on=date(2026, 5, 2),
category_id=others.id,
)
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == others.id
custom = create_category(db, space_id=space.id, user=user, kind="monthly", name="My shops")
codes = categorization.set_category_mcc_codes(
db, space_id=space.id, category_id=custom.id, codes=[5999, 5994]
)
assert codes == [5994, 5999]
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == custom.id
custom.archived = True
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == others.id
custom.archived = False
with pytest.raises(AppError) as exc:
categorization.set_category_mcc_codes(
db, space_id=space.id, category_id=custom.id, codes=[5411]
)
assert exc.value.code == "CATEGORY_MCC_TAKEN"
def test_copy_previous_month_clamps_expected_day(db: Session) -> None:
from app.budgeting.service import add_expected_income
+2 -1
View File
@@ -167,7 +167,8 @@ def test_adjustment_on_regular_account_keeps_pool_and_totals(db: Session) -> Non
# The adjustment reconciles the account but never enters the budget pool.
assert state["pool"] == 300_000
assert state["uncategorized_expenses"] == 0
assert state["monthly_categories"] == []
assert len(state["monthly_categories"]) == 21
assert all(row["spent"] == 0 for row in state["monthly_categories"])
def test_savings_envelope_available_uses_month_end_balance(db: Session) -> None:
+48 -3
View File
@@ -71,6 +71,53 @@ def test_space_has_protected_savings_pair(client: TestClient) -> None:
assert delete_response.status_code == 409
def test_categories_expose_and_update_multiple_mcc_codes(client: TestClient) -> None:
csrf = bootstrap(client)
definitions = client.get("/api/v1/budget/category-definitions").json()
groceries = next(item for item in definitions if item["systemKey"] == "groceries")
assert {5411, 5422}.issubset(groceries["mccCodes"])
assert any(item["systemKey"] == "others" for item in definitions)
created = client.post(
"/api/v1/budget/categories",
headers={"X-CSRF-Token": csrf},
json={"name": "Shops", "kind": "monthly", "mccCodes": [5999, 5994]},
)
assert created.status_code == 201, created.text
assert created.json()["mccCodes"] == [5994, 5999]
category_id = created.json()["id"]
updated = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"mccCodes": [5999]},
)
assert updated.status_code == 200, updated.text
assert updated.json()["mccCodes"] == [5999]
taken = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"mccCodes": [5411]},
)
assert taken.status_code == 409
assert taken.json()["error"]["code"] == "CATEGORY_MCC_TAKEN"
released = client.patch(
f"/api/v1/budget/category-definitions/{groceries['id']}",
headers={"X-CSRF-Token": csrf},
json={"mccCodes": [code for code in groceries["mccCodes"] if code != 5411]},
)
assert released.status_code == 200, released.text
remapped = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"mccCodes": [5411, 5999]},
)
assert remapped.status_code == 200, remapped.text
assert remapped.json()["mccCodes"] == [5411, 5999]
def test_category_lifecycle_create_rename_archive_restore(client: TestClient) -> None:
csrf = bootstrap(client)
created = client.post(
@@ -163,9 +210,7 @@ def test_adjust_and_savings_conversion_http_contracts(client: TestClient) -> Non
"occurredOn": "2026-09-10",
"reason": "Statement reconciliation",
}
denied = client.post(
f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body
)
denied = client.post(f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body)
assert denied.status_code == 403
adjusted = client.post(
f"/api/v1/accounts/{account_id}/adjust-balance",
+74 -34
View File
@@ -1,12 +1,19 @@
import uuid
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import pytest
import sqlalchemy as sa
from alembic.config import Config
from alembic.migration import MigrationContext
from alembic.operations import Operations
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.budgeting.service import close_period, compute_period_state, get_or_create_period
from app.errors import AppError
from app.identity.models import User
@@ -200,20 +207,8 @@ def test_unresolved_hold_blocks_only_its_occurred_month(db: Session) -> None:
)
def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) -> None:
def test_discard_as_cancelled_without_reason_unblocks_month(db: Session) -> None:
user, space, _account, document, hold = hold_document(db)
with pytest.raises(AppError) as exc:
service.resolve_hold(
db,
space_id=space.id,
document_id=document.id,
source_record_id=hold.id,
user=user,
action="discard_as_cancelled",
reason=" ",
)
assert exc.value.code == "IMPORT_HOLD_REASON_REQUIRED"
assert db.query(HoldResolution).count() == 0
resolution = service.resolve_hold(
db,
space_id=space.id,
@@ -221,7 +216,6 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) ->
source_record_id=hold.id,
user=user,
action="discard_as_cancelled",
reason="Cancelled by the bank",
)
db.flush()
assert resolution.resolution == "discarded_as_cancelled"
@@ -230,7 +224,7 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) ->
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
detail = service.document_detail(db, space_id=space.id, document_id=document.id)
hold_row = next(item for item in detail["records"] if item["id"] == hold.id)
assert hold_row["hold_resolution"]["reason"] == "Cancelled by the bank"
assert hold_row["hold_resolution"]["reason"] is None
april = get_or_create_period(db, space_id=space.id, year=2026, month=4)
close_period(db, space_id=space.id, user=user, period=april)
may = get_or_create_period(db, space_id=space.id, year=2026, month=5)
@@ -238,21 +232,20 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) ->
assert may.status == "closed"
def test_confirm_as_posted_requires_reason(db: Session) -> None:
def test_confirm_as_posted_accepts_blank_reason(db: Session) -> None:
user, space, _account, document, hold = hold_document(db)
for reason in (None, ""):
with pytest.raises(AppError) as exc:
service.resolve_hold(
db,
space_id=space.id,
document_id=document.id,
source_record_id=hold.id,
user=user,
action="confirm_as_posted",
reason=reason,
)
assert exc.value.code == "IMPORT_HOLD_REASON_REQUIRED"
assert db.query(HoldResolution).count() == 0
resolution = service.resolve_hold(
db,
space_id=space.id,
document_id=document.id,
source_record_id=hold.id,
user=user,
action="confirm_as_posted",
reason=" ",
)
db.flush()
assert resolution.reason is None
assert resolution.operation_id is not None
def test_hold_cannot_be_resolved_before_document_confirmation(db: Session) -> None:
@@ -736,7 +729,7 @@ def test_hold_resolution_rejects_unknown_kind(db: Session) -> None:
db.rollback()
def test_manual_resolution_requires_reason_at_database_level(db: Session) -> None:
def test_manual_resolution_accepts_no_reason_at_database_level(db: Session) -> None:
_user, space, _account, _document, hold = hold_document(db)
db.add(
HoldResolution(
@@ -746,8 +739,8 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non
reason=None,
)
)
with pytest.raises(IntegrityError):
db.flush()
db.flush()
assert db.query(HoldResolution).filter_by(hold_source_record_id=hold.id).one().reason is None
db.rollback()
_user, space, _account, _document, hold = hold_document(db)
db.add(
@@ -761,6 +754,49 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non
db.flush()
def test_upgrade_drops_legacy_manual_reason_constraint(tmp_path: Path, monkeypatch) -> None:
from app.config import get_settings
database_url = f"sqlite:///{tmp_path / 'hold-reason.db'}"
monkeypatch.setenv("DATABASE_URL", database_url)
get_settings.cache_clear()
config = Config(str(Path(__file__).parents[1] / "alembic.ini"))
config.set_main_option("script_location", str(Path(__file__).parents[1] / "alembic"))
try:
command.upgrade(config, "0003")
engine = create_engine(database_url)
try:
with engine.begin() as connection:
operations = Operations(MigrationContext.configure(connection))
with operations.batch_alter_table("hold_resolutions") as batch_op:
batch_op.create_check_constraint(
"ck_hold_resolutions_manual_reason_required",
"NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') "
"AND (reason IS NULL OR reason = ''))",
)
command.upgrade(config, "0005")
constraints = sa.inspect(engine).get_check_constraints("hold_resolutions")
assert not any(
(constraint.get("name") or "").endswith("manual_reason_required")
for constraint in constraints
)
with Session(engine) as db:
db.add(
HoldResolution(
space_id=uuid.uuid4(),
hold_source_record_id=uuid.uuid4(),
resolution="discarded_as_cancelled",
reason=None,
)
)
db.flush()
finally:
engine.dispose()
finally:
get_settings.cache_clear()
def _draft(rows, *, opening=1_000, closing=1_200, **declared):
return SimpleNamespace(
currency="RUB",
@@ -920,6 +956,7 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db
space = SimpleNamespace(id=space_id)
document, run = make_document(db, space, account, user=user)
hold = add_record(db, document, run, bank_status="hold")
hold.mcc = 5411
service.confirm_document(db, space_id=space_id, document_id=document.id, user=user)
db.commit()
@@ -932,14 +969,17 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db
resolved = client.post(
f"/api/v1/imports/{document.id}/rows/{hold.id}/resolve",
headers={"X-CSRF-Token": csrf},
json={"action": "confirm_as_posted", "reason": "Checked in the bank app"},
json={"action": "confirm_as_posted"},
)
assert resolved.status_code == 200, resolved.text
rows = resolved.json()["rows"]
row = next(item for item in rows if item["id"] == str(hold.id))
assert row["bankStatus"] == "hold"
assert row["mcc"] == 5411
assert row["mccName"] == "Продуктовые магазины и супермаркеты"
assert row["holdResolution"]["resolution"] == "confirmed_as_posted"
assert row["holdResolution"]["reason"] == "Checked in the bank app"
assert row["holdResolution"]["reason"] is None
assert row["holdResolution"]["operationId"] is not None
operations = db.query(Operation).filter_by(space_id=space_id).all()
assert len(operations) == 1
assert operations[0].category_id is not None
+41 -4
View File
@@ -18,7 +18,7 @@ from app.imports.models import (
SourceRecordLink,
StatementDocument,
)
from app.ledger.models import Operation
from app.ledger.models import Category, Operation
from app.ledger.service import create_account
from app.spaces.service import create_space
@@ -93,6 +93,45 @@ def test_confirm_and_revert_are_idempotent(db: Session) -> None:
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0
def test_import_review_and_confirmation_do_not_require_reason(db: Session) -> None:
user, space, document = ready_document(db)
document.status = "review_required"
document.reconciliation = {"full": False, "matched": None}
service.set_review_confirmation(
db,
space_id=space.id,
document_id=document.id,
account_id=document.account_id,
confirmed_without_full_reconciliation=True,
)
assert document.reconciliation["reason"] is None
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert document.status == "confirmed"
@pytest.mark.parametrize(
("code", "system_key", "label"),
[
(5411, "groceries", "Продуктовые магазины и супермаркеты"),
(5999, "others", "Прочие специализированные магазины"),
(9999, "others", None),
],
)
def test_import_mcc_assigns_default_category_and_label(
db: Session, code: int, system_key: str, label: str | None
) -> None:
user, space, document = ready_document(db)
source = db.query(SourceRecord).filter_by(document_id=document.id).one()
source.mcc = code
row = service.document_detail(db, space_id=space.id, document_id=document.id)["records"][0]
assert row["mcc"] == code
assert row["mcc_name"] == label
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
operation = db.query(Operation).filter_by(space_id=space.id).one()
assert db.get(Category, operation.category_id).system_key == system_key
def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None:
user, space, document = ready_document(db)
document.reconciliation = {"full": True, "matched": False}
@@ -222,9 +261,7 @@ def test_duplicate_candidate_can_reuse_unique_operation_without_client_id(db: Se
def test_duplicate_candidates_ignore_rows_from_same_parse_run(db: Session) -> None:
user, space, document = ready_document(db)
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
first_source = (
db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one()
)
first_source = db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one()
existing_link = db.query(SourceRecordLink).filter_by(source_record_id=first_source.id).one()
second_source = SourceRecord(
space_id=first_source.space_id,
+34 -3
View File
@@ -2,10 +2,11 @@ from datetime import date
from pathlib import Path
from alembic.config import Config
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import Session
from alembic import command
from app.categorization.models import CategoryMcc
from app.config import get_settings
from app.identity.models import User
from app.ledger.models import Account, Category
@@ -13,7 +14,8 @@ from app.spaces.models import FinancialSpace, Membership
def test_upgrade_backfills_one_pair_without_claiming_same_named_rows(
tmp_path: Path, monkeypatch,
tmp_path: Path,
monkeypatch,
) -> None:
database_url = f"sqlite:///{tmp_path / 'migration.db'}"
monkeypatch.setenv("DATABASE_URL", database_url)
@@ -61,8 +63,37 @@ def test_upgrade_backfills_one_pair_without_claiming_same_named_rows(
assert system_category[0].linked_account_id == system_account[0].id
assert any(item.name == "Savings" and item.system_key is None for item in accounts)
assert any(
item.name_override == "Savings" and item.system_key is None
item.name_override == "Savings" and item.system_key is None for item in categories
)
default_categories = {
item.system_key: item
for item in categories
if item.system_key not in {None, "savings"}
}
assert len(default_categories) == 21
assert "groceries" in default_categories and "others" in default_categories
assert (
db.query(CategoryMcc)
.filter_by(
space_id=space_id,
category_id=default_categories["groceries"].id,
mcc_code=5411,
)
.count()
== 1
)
command.downgrade(config, "0003")
schema = inspect(engine)
assert schema.has_table("mcc_codes")
assert schema.has_table("bank_category_mappings")
assert not schema.has_table("category_mcc_codes")
with Session(engine) as db:
assert (
db.query(Category)
.filter(Category.space_id == space_id, Category.system_key == "groceries")
.count()
== 0
)
finally:
get_settings.cache_clear()
+83 -174
View File
@@ -103,69 +103,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" },
]
[[package]]
name = "ast-serialize"
version = "0.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/a2/04a9383e7512c91c54b2f34b3ff86dc7d2610506f588c2bda36e952a68f7/ast_serialize-0.11.1.tar.gz", hash = "sha256:cc5db2983805f6be786488aac8c5998d5b71965488d1b18c44d435a2205a5cb4", size = 953785, upload-time = "2026-09-09T16:05:27.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/cb/3577c66278e4ea4ff6968aee04327447335ecfc58c76123f68cf7f76d75e/ast_serialize-0.11.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7ca1557553fdab313999ad69156de154ff87152cae963698be2e765164bb6c0", size = 897021, upload-time = "2026-09-09T16:03:59.316Z" },
{ url = "https://files.pythonhosted.org/packages/47/db/9b4eed53ab0bb63653f56ef71062fa91693c8c26b0b648c58d98365c990c/ast_serialize-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8b54e44763a851c336ca137c60a2434511f0d155e0b924ef374bfbc8d8db8927", size = 1235148, upload-time = "2026-09-09T16:04:01.201Z" },
{ url = "https://files.pythonhosted.org/packages/fd/97/1ebe323015c3cf530f08e88df9929bfd9f5cf8165842bd4b37a7c958565c/ast_serialize-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:753afabcc4abf295f515ea33185cf997a1990b88a29d3a3336a9acf62ea85950", size = 1216082, upload-time = "2026-09-09T16:04:02.87Z" },
{ url = "https://files.pythonhosted.org/packages/ac/72/0d3a368edc2d1e0e188c0d3f23821f4e20a6a2291b23d9df482a6b11277f/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98b8aba6bb682b9e8859c628382b4881c0600b28056d63c3168c227c449f3284", size = 1282848, upload-time = "2026-09-09T16:04:04.316Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fb/2a546a57d7caa708d739c1747bbdf45cc3aa9dac9407a11d9d67e8a0e255/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:651a7890558896c3e08089e51ab3b3a43710eab15b72ad861c400bc9a51923ba", size = 1285337, upload-time = "2026-09-09T16:04:05.899Z" },
{ url = "https://files.pythonhosted.org/packages/bf/16/4ff4179584f8b8bec348787bf721cb7ce3d4c707c7878778065249b59b9a/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff8ddc1453bfe7934292409c7b8e9f68b0a6cff4e4c12535e4b4a66fd83aca0c", size = 1554906, upload-time = "2026-09-09T16:04:07.42Z" },
{ url = "https://files.pythonhosted.org/packages/df/e8/e54a54676ca8965e1ae3cfe065dc26c75158e0b3a3ce35af283da106fbf9/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:daec16b026c5741950a00c708acdf698c4f17babbe4829dd8232324822e9514f", size = 1301472, upload-time = "2026-09-09T16:04:08.782Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ab/618835a2479868a0d4ac807dd04cf2f89d24afe22c4171538d9779c2772e/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9961abcb93bf03652ab09c00862a2396abacd57551c769ad34490a2ae508a61d", size = 1301293, upload-time = "2026-09-09T16:04:10.17Z" },
{ url = "https://files.pythonhosted.org/packages/61/cb/8acc29a1b279ccdfb30dcbdf099e1d8d34c4ee77be9be751c48061433129/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:8ca9e48edf246f09fd9bdb64fbd9cd8d18cd2a69ffc24d44891466e17595094f", size = 1307807, upload-time = "2026-09-09T16:04:11.989Z" },
{ url = "https://files.pythonhosted.org/packages/da/74/bada875132f452cb5c1179e3156df43cee6df6cb51f773220688be3d4bb4/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a57e0e025e9dcee48e466b3cf909178e298d238148664e796fc0f60ded5e52e", size = 1356265, upload-time = "2026-09-09T16:04:13.429Z" },
{ url = "https://files.pythonhosted.org/packages/4f/ae/720a8042fc8c1d170f4cc7ed46295ef078f8198ed3476f58210a1e675e0c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:110386eebccf200446d5f4dcc215f80866a5d3d3d054d3ced6ec1bf386cd3f02", size = 1459957, upload-time = "2026-09-09T16:04:14.878Z" },
{ url = "https://files.pythonhosted.org/packages/7d/2c/ce2ff1ffd4d70376073393c154033728c8535c17211b7710fee7b0f04474/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ef3950747f3a588f692cf4d6f2937eedd1a2d5e199f863af29e8ec7da78a4f36", size = 1562369, upload-time = "2026-09-09T16:04:16.563Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3e/d7a5dd5f609bd0bac45617ce9425e4bd1e369b998c8678edfd70c985408c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0bc94b81878a3f999fe4cdb606bf00ef7dff6b4f66d0e089b2cf7bfc00d59beb", size = 1556466, upload-time = "2026-09-09T16:04:18.396Z" },
{ url = "https://files.pythonhosted.org/packages/99/cd/a45179802a8637f2be252163ec4e4948534fa8d88798dd391c9cc54e096e/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:341dfc9e6b4e47e5c37e1b7257a77d39f407cf49a371ee90225ab624c2fe8b36", size = 1687190, upload-time = "2026-09-09T16:04:19.965Z" },
{ url = "https://files.pythonhosted.org/packages/01/eb/ef206d764ec0554368501b8b5268f5468819dc0588815acb0575465643df/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7b524cb1b08e15db0f116075c39ab51674b6d3c8103ec1a59c8ca464be83e28a", size = 1481188, upload-time = "2026-09-09T16:04:21.368Z" },
{ url = "https://files.pythonhosted.org/packages/8b/37/9a724c523af7c8b97a659bf4dc28a8e60f97d0cf4d19be277db4067ca734/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c2358b341aca08bd2e1005713bc480bc06eee9f345b67fcb826829aa7b8d0d3", size = 1500731, upload-time = "2026-09-09T16:04:22.905Z" },
{ url = "https://files.pythonhosted.org/packages/48/10/890d5fcaf568088de7989e8cdb4ca525f485bd6cb25eea49d45f017500af/ast_serialize-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b9e61143a5904f46daa0cde74ba60d3b7c0e3e000752ee138e2bc68e126a5803", size = 1119009, upload-time = "2026-09-09T16:04:24.469Z" },
{ url = "https://files.pythonhosted.org/packages/9d/61/2bd32bd16b5e2ee07badc8c32a73c545dc5a9f087ed19c07eee1243d5a1b/ast_serialize-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:31df8649761d5cb6eb0de2209bd4a167c7477c0516147c70c0b2f5bddc7839bb", size = 1155665, upload-time = "2026-09-09T16:04:26.638Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b6/84bb5e1dc418e660524b873c5bac9be2bf13d38676c96feef4cd4e7d03f3/ast_serialize-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2f8f33e416a4c7aad12ad757e895c676e953b4027b80b90718dccea83d45111a", size = 1131854, upload-time = "2026-09-09T16:04:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/8c/dd/16de2c0d23a6b298c735d4e4b56d86fa70476eef98e2c66ceaa65503b62f/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:a015ede631eeb098a23de8ec0cfe11a1dd02159ab7a26bdc7c6bde2befbbc7b4", size = 1235495, upload-time = "2026-09-09T16:04:29.654Z" },
{ url = "https://files.pythonhosted.org/packages/27/f7/302d2251e6298bbeabc8a1815c9147127031517b305001a02f34fd46b6b5/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:57b7d52d1f5c92905cd648bda9efc83cac33097de1e5bb68de8feca9b5f7e87a", size = 1215642, upload-time = "2026-09-09T16:04:31.186Z" },
{ url = "https://files.pythonhosted.org/packages/93/06/93b6527646613502f364cebfb23783fa6701cdbe90761ee26144f1fa20b9/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e47b9b028efbc0486263a49f782ad0ae3e879855fe5a2897b59feefb78e1c40c", size = 1283526, upload-time = "2026-09-09T16:04:32.597Z" },
{ url = "https://files.pythonhosted.org/packages/2f/ac/ffb216262c9a582d039d846081b8c3017dc368cbed5a2520deca5cb7f8cb/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00adb748034c7b1938ec56925f9f6230ffcd9c56d47355fe32485df29b90b977", size = 1287526, upload-time = "2026-09-09T16:04:34.214Z" },
{ url = "https://files.pythonhosted.org/packages/a2/06/19a4c4837c4d5e73b982938d6da3c9ae10513b61527575b3e1b8396f9298/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82359d00ea808c260c327e43955c0190bb7baef152a01ae0b5c74d05387b9552", size = 1558354, upload-time = "2026-09-09T16:04:35.669Z" },
{ url = "https://files.pythonhosted.org/packages/e5/29/2373199907b2d97feed176115308dfb3dd0446566b047009828f266d2d66/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35bb9ddeed4008c7d3e272dd1999885d712e1ce623b0a65ba8a8ed1d51c35cf", size = 1302731, upload-time = "2026-09-09T16:04:37.423Z" },
{ url = "https://files.pythonhosted.org/packages/33/55/ab6805a1457dbe565c84f8d36e2aa4207ef22408adfd01d225823cd07484/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74093ed682ba58456f0d4450da7fb62b826fddd97824d44fc4a81ca6e0bf9e23", size = 1301594, upload-time = "2026-09-09T16:04:38.861Z" },
{ url = "https://files.pythonhosted.org/packages/52/e2/4f54eab2201bb420d39bf61423abcb5d4daffd28e2270e1c5eee2bf3c0f1/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4dd7218870c203eff4533cfc04a39f29077d6789b20f5b746e7648fe5762a548", size = 1309355, upload-time = "2026-09-09T16:04:40.543Z" },
{ url = "https://files.pythonhosted.org/packages/10/07/755dd98664e2374080b72ccb5fea63d9f11b4bec2409a05b2a4ebc97618d/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e8d20700766171a8a17f89cf53d7bd9712c509460d28a51f7f98340577b78aa", size = 1356645, upload-time = "2026-09-09T16:04:42.299Z" },
{ url = "https://files.pythonhosted.org/packages/34/a0/fadbde3e108064236e2679728ce6d8247aefc81bac5fd82505b59cf69172/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:20b3371c0403099cd55d59f4bcedb6d3994d9db3fa41711c648760a89ef2a575", size = 1459696, upload-time = "2026-09-09T16:04:44.069Z" },
{ url = "https://files.pythonhosted.org/packages/02/fd/b4f95249bd895368ea8290668e27d1f0a5c4ad888bd72a34c7beb2a500d6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:cc1a78b913f8665dda145999b1b4805ad2ef442ecbe3b819512cf7f95e70498a", size = 1562517, upload-time = "2026-09-09T16:04:45.461Z" },
{ url = "https://files.pythonhosted.org/packages/68/98/6afac594380410710d9d863b9ef41e9c6ca89bd901bda1464ee9e99180a8/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:38f7141881e783bccc362d201d7fd7437934216b669b9fef39febc2f63fd2d9b", size = 1556951, upload-time = "2026-09-09T16:04:47.12Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6f/5a81b5cbf1731f4722bc90adf4b4ace7dda69ca29837844147063aeafb22/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:13903a3f212ab9e5d05a6c3f2337288f4f8f4c18e6ab3817417ae7a3d46dee14", size = 1691039, upload-time = "2026-09-09T16:04:48.691Z" },
{ url = "https://files.pythonhosted.org/packages/99/eb/d9b903206cbd6d5143838367d385fa88ec49eaae2cc12f284327c6e0af05/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e5a9c53e64732118ae8235d0e57eb90e2333e90ff749a1a7dca6f8bfdfa5200e", size = 1483297, upload-time = "2026-09-09T16:04:50.136Z" },
{ url = "https://files.pythonhosted.org/packages/ed/48/ee13b4079ec67e9b333a87e5bb8eee643ab1b49f7173b3fca71158c6cdf6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:aecb606f67f21fd1c0ffba1826470d2ee400e41b23881ad9a1b2157306865b1a", size = 1501686, upload-time = "2026-09-09T16:04:51.734Z" },
{ url = "https://files.pythonhosted.org/packages/15/67/2175b79e1ee6b042e4bf8ed6871e60cafea7e9cf3fc69e87d7a9d520ae77/ast_serialize-0.11.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:d1ef9c478d8c8ca83499704d13e5ac32c840ed7ca7884aaaf716166aca5a2806", size = 1119522, upload-time = "2026-09-09T16:04:53.248Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c3/8c96aa5f121e9bdda0346b2df3919185eb1ec9a2cce2d20e311b4575ca0b/ast_serialize-0.11.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:27eef0739e0110f5db1ff5dfa38b3dadfcb6f0c31975a2b412f9f2bd72139cea", size = 1157260, upload-time = "2026-09-09T16:04:54.944Z" },
{ url = "https://files.pythonhosted.org/packages/10/bc/8aa663209e335eca73c9e1006197222b6b8dafa6665e513cdd975aa65496/ast_serialize-0.11.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:f9454960bbf185d33c669ccd5c9b76c96709a4542a95a65e27342c4d10dd8e20", size = 1132060, upload-time = "2026-09-09T16:04:56.501Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a7/e738887429de70350d9e25f84a95efbdd086b79579a811509dee8b02d7d5/ast_serialize-0.11.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b9a6089f1337838492b217707e9a55d0b9b407fe9169a960fa12282abf2234c", size = 1240585, upload-time = "2026-09-09T16:04:59.616Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f9/46b64fa883f3c8b8cf51629978f16d8a2da3d5c5c02f64add40864907703/ast_serialize-0.11.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:68929da7fb1c7375f69641baac9519d5c41ee441cd09a591ea5dfc83107ffe6f", size = 1228038, upload-time = "2026-09-09T16:05:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/ad/89/fe75c8d0f104a4be11cd0e23acecf129484c9ba06c7130033752d63a78c5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8945557ed3015173dbf8d44184da9621add9162720acbc0ac4832b5a091b4e8a", size = 1292388, upload-time = "2026-09-09T16:05:02.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/56/3f21c881900d8254a35195e1e962355b4d570ccd9cc193b0ba08abb81952/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9cbed53bb8992b72e587dd47d0ca71703f5f715420f48ed57587c06c2fcd213", size = 1294413, upload-time = "2026-09-09T16:05:04.212Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b2/8d77be3dad1139158c59e370391f4990ea73f7f102983392272856bd3a63/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65c836d1ab7af65e64a1dd73de82572566e838dd27529b1124eac0ac86f35e76", size = 1565921, upload-time = "2026-09-09T16:05:05.753Z" },
{ url = "https://files.pythonhosted.org/packages/94/2e/0675b5796c897f957fbdac6af0ebeeaa9a63cee866c1c54f1e5136ef5bf6/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ab6df9898600ff45149b86c12bb5aa3359cc71843ad99ad0c924d11392469ab", size = 1312160, upload-time = "2026-09-09T16:05:07.269Z" },
{ url = "https://files.pythonhosted.org/packages/93/c2/1749fa4efae0dc98aad3c3d29f0178e2609208c3ba34700b4551082e40d5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eebd3dd25a81839ac7d25b5f3fb3527f6141b35eca29f63d2613fe6254307fd", size = 1312405, upload-time = "2026-09-09T16:05:08.753Z" },
{ url = "https://files.pythonhosted.org/packages/00/14/2afc9f9d7db551f805b4d5e496946dc3dc2a703d27294ccc200b6ff07a7a/ast_serialize-0.11.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:311333c5f58f55fc04121be2c5adffa42a19e1038a43a2cef862fdf58c45bf4f", size = 1319458, upload-time = "2026-09-09T16:05:10.552Z" },
{ url = "https://files.pythonhosted.org/packages/c7/4a/8b2866d9d6d5d0b037d7bf2b74db6e75a66695c503248632116f24009a8d/ast_serialize-0.11.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa8d0a9f6d02e7626cdf540555bc2f5313eabe803dcca04854fc628ddf4b1058", size = 1365055, upload-time = "2026-09-09T16:05:12.069Z" },
{ url = "https://files.pythonhosted.org/packages/47/17/73119504574f9b46610ffb718501b254c8adb727d4e43c5babc3c62d4529/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e209a797fdf8680d58d2e969a9ddb25058ea682f6cff59dbabc98d8aef4e0db1", size = 1467771, upload-time = "2026-09-09T16:05:13.458Z" },
{ url = "https://files.pythonhosted.org/packages/56/f5/776bfb7a856ba0bb9bd373b76a90230fb77b1a10c987810b621ed244d5ba/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b391becd31e9889da438d388aac0362e3dfd222affbe45d920580c351486c787", size = 1571459, upload-time = "2026-09-09T16:05:15.15Z" },
{ url = "https://files.pythonhosted.org/packages/c7/43/86a655170ee36a8fdce65a922b7e34040bb319eb4ec251161032343c1fc3/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:021afb3482d27d1dace9c8999724a13083e96c7ece785e53e418bf5df3b50e0a", size = 1568959, upload-time = "2026-09-09T16:05:16.677Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b1/015a3a156dc9bd46e9c4e4f24a939eb0b87179b1c53c9495ac6f088f13b4/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:c1dc60251d93beff32d4147ecd4ccc518b0f022a900093f6f3021c2012416f0f", size = 1698173, upload-time = "2026-09-09T16:05:18.248Z" },
{ url = "https://files.pythonhosted.org/packages/0e/ea/a988b70980aca3a8a3fb731901dc911fe73a456cebbc225b767bbe5490ff/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b77d6a2267227d68d8221cfca177f8c6d9f68019f3250401d0355d2638db9fb9", size = 1493298, upload-time = "2026-09-09T16:05:19.806Z" },
{ url = "https://files.pythonhosted.org/packages/de/15/e049b2701ddab9087d7f62448f4c2c1b1463e676baa471345cf3e4a31e1b/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e68ac7d7fd1a5d42a3a6d726d7cc8a7e0c9987934f11c6a5162aa2cf68e81e24", size = 1510213, upload-time = "2026-09-09T16:05:21.402Z" },
{ url = "https://files.pythonhosted.org/packages/46/a2/23d555eb842d1397dce54fac31851e6f151e03a0ae74db389d9b39718c33/ast_serialize-0.11.1-cp39-abi3-win32.whl", hash = "sha256:ed449d786b9032a7b85fcd6c2f7f54c4cd0e1e1ad254d396805ff922096bf08d", size = 1125239, upload-time = "2026-09-09T16:05:22.796Z" },
{ url = "https://files.pythonhosted.org/packages/1f/4a/251f3fd1b8a5549edaedf8f3ba0b9fb5060e194d3c0ed208b593fccaeff1/ast_serialize-0.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:6b43f5a9b9a8dd20ba3124914e63aa7d7427de761ac849081cda403c2112fda0", size = 1164260, upload-time = "2026-09-09T16:05:24.714Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b5/08e2f643feb9e4d72d42a484949ad51238b18262a01c7036196c201cc330/ast_serialize-0.11.1-cp39-abi3-win_arm64.whl", hash = "sha256:a579ea6f473aab3958a64734194b22fbaec108ec45d994c28d3bd721e1e1da32", size = 1137533, upload-time = "2026-09-09T16:05:26.095Z" },
]
[[package]]
name = "billiard"
version = "4.2.4"
@@ -228,15 +165,6 @@ redis = [
{ name = "kombu", extra = ["redis"] },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
@@ -508,6 +436,7 @@ dependencies = [
{ name = "defusedxml" },
{ name = "email-validator" },
{ name = "fastapi" },
{ name = "iso18245" },
{ name = "openpyxl" },
{ name = "pdfplumber" },
{ name = "psycopg", extra = ["binary"] },
@@ -521,11 +450,11 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "httpx2" },
{ name = "hypothesis" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
@@ -538,6 +467,7 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7" },
{ name = "email-validator", specifier = ">=2.1" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "iso18245", specifier = ">=1.4.1" },
{ name = "openpyxl", specifier = ">=3.1" },
{ name = "pdfplumber", specifier = ">=0.11" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
@@ -551,11 +481,11 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "httpx2", specifier = ">=2.0" },
{ name = "hypothesis", specifier = ">=6.112" },
{ name = "mypy", specifier = ">=1.10" },
{ name = "pytest", specifier = ">=8.2" },
{ name = "ruff", specifier = ">=0.5" },
{ name = "ty", specifier = ">=0.0.18" },
]
[[package]]
@@ -591,16 +521,16 @@ wheels = [
]
[[package]]
name = "httpcore"
version = "1.0.9"
name = "httpcore2"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" },
]
[[package]]
@@ -626,18 +556,28 @@ wheels = [
]
[[package]]
name = "httpx"
version = "0.28.1"
name = "httpx2"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
{ url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
@@ -707,6 +647,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
name = "importlib-resources"
version = "5.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/f1/8711c49ffd121083007a24c1bff0d324c9ff621d4fdf8b4ffcb8d9e60330/importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528", size = 36550, upload-time = "2023-07-07T16:21:00.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/68/bd9dd6bbf06772c7accce77d0354d783333fbe712a60b08fc13540c05422/importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2", size = 32912, upload-time = "2023-07-07T16:20:58.128Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -716,6 +665,18 @@ 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 = "iso18245"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-resources" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/de/e8095ac5ac026e4ad23c07d1cb35bc1495051d2ae32c863b246d43e1d9a1/iso18245-1.4.1.tar.gz", hash = "sha256:1ee69d2b6b6334e29d9131371970a171649a969fe319ae7b09afcfc2ecb02720", size = 50749, upload-time = "2025-09-03T09:23:07.299Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/5e/ef3e709668014b9ebabb86d05f4848f96ffac406e202c7207b773b3ccc2f/iso18245-1.4.1-py3-none-any.whl", hash = "sha256:07fcac02b22044e5f188a7683684f2166900a36d39119eb6ca0f4ef5f0df5772", size = 66746, upload-time = "2025-09-03T09:23:05.833Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
@@ -745,45 +706,6 @@ redis = [
{ name = "redis" },
]
[[package]]
name = "librt"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
{ url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
{ url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
{ url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
{ url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
{ url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
{ url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
]
[[package]]
name = "mako"
version = "1.4.1"
@@ -826,44 +748,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mypy"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
@@ -885,15 +769,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pdfminer-six"
version = "20260107"
@@ -1292,6 +1167,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "ty"
version = "0.0.83"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/7d/2fd9575bce2d14e2281bec82d0713dee68329b0dc944e9c06c2e36b761fe/ty-0.0.83.tar.gz", hash = "sha256:db118de73c05ac476faceb4d42d59782feaeddfc4d721fd3ae8b807a0a2ae4e4", size = 7373076, upload-time = "2026-09-21T22:47:29.207Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/85/7259431bade2317d8b82fa27220ae4618c21396acf0ca28a7bd75e92afd7/ty-0.0.83-py3-none-linux_armv6l.whl", hash = "sha256:836bc08bad96ecc7e38baf1a6ddaec15aa8c8b46ae99a96365397b5f6481b7a5", size = 13905669, upload-time = "2026-09-21T22:46:36.677Z" },
{ url = "https://files.pythonhosted.org/packages/d2/30/9527386ade8dd71fc52f65cde3c094411af7ad0d505f9063ef0841998702/ty-0.0.83-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f1fb029fe076c7dca404d98e38fdb5fdbc2038a09407f89d43be1fdd9b2c459", size = 13499505, upload-time = "2026-09-21T22:46:40.114Z" },
{ url = "https://files.pythonhosted.org/packages/a0/54/63080d8cfd59a53810499a69a2ebc995724be27618b70d7f099e73ace317/ty-0.0.83-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3515776d01ee3b1be974dafa73d449d0bc711afbc12ba341f82f70bc641ff4ee", size = 13498980, upload-time = "2026-09-21T22:46:43.197Z" },
{ url = "https://files.pythonhosted.org/packages/49/78/e647f190bd9513d09db30d45309e9b5df070b5caa44af2de3290fab59fbe/ty-0.0.83-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5b80d66c7f9c8d60f25ccfd40c6eaebe8fd7c9005074904e0e164dd89c13d03", size = 13494705, upload-time = "2026-09-21T22:46:46.106Z" },
{ url = "https://files.pythonhosted.org/packages/2e/12/aef645b13c5bd15e3e5993537dbc23f7d65475f3cf1755d07b4d3e56850d/ty-0.0.83-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57eae4aa55dab1af5d1423227c6517ccab1e658fe265c8e5a58caa584ca2876b", size = 13661466, upload-time = "2026-09-21T22:46:49.392Z" },
{ url = "https://files.pythonhosted.org/packages/d4/c4/11872e823653c568468ef7f0c3b23aed7ad2d33652e20144d28d5bfad58a/ty-0.0.83-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dea3680911340ecf93dff5786dbeefd69c9399df685a22751e1fbd27e6007b50", size = 14479677, upload-time = "2026-09-21T22:46:52.277Z" },
{ url = "https://files.pythonhosted.org/packages/8e/db/5b0d0d24d16d24d781f29ee842ee718903e453dc4068acb02bef860d5540/ty-0.0.83-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8453786c274f48e2a89b78e8a505a4f449323f357f2ce26a9b90a4bd68176f67", size = 14972208, upload-time = "2026-09-21T22:46:55.242Z" },
{ url = "https://files.pythonhosted.org/packages/54/04/0077a53bcf6ceb41365a45d8091b52094c91a5f4b071c2a6d705ed8bb65b/ty-0.0.83-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7701bdae3b52271cf0835abe4202aa3b23b735425d8c5a3e6a8ca1c68d39ead8", size = 14660550, upload-time = "2026-09-21T22:46:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ff/b6c879f1c3121387edd19ebdb1fb02af30691f88f632278a5d467fb5f348/ty-0.0.83-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa95b4376447e5309fe3ccc98ee75916a9082714e6a029b99427ca62021dc94a", size = 14147376, upload-time = "2026-09-21T22:47:01.167Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bc/82e730247d53cb02acd5a91237c0c62f215b2e0379689a550176d307ed89/ty-0.0.83-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2a4550a08fc1899a33e4a08787a73ec9daa99d292268df4f0a5f1c24c63854c3", size = 14567502, upload-time = "2026-09-21T22:47:04.344Z" },
{ url = "https://files.pythonhosted.org/packages/67/13/bb6f57923231f51b626931625ada71e219d64541f8fadf96065bc5034358/ty-0.0.83-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:feb72080f0835fa2b27485e3b11fa50dc57c30e0ba73808a3f7d64b56db83f6f", size = 13443051, upload-time = "2026-09-21T22:47:07.316Z" },
{ url = "https://files.pythonhosted.org/packages/50/20/9862431662d4453527cd52cf0f30d5975cd1801aa39e570bf45fb299be77/ty-0.0.83-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3e5206def2d6e1df93c75ed8dc763255233ccc3153647509d125bada10ad770a", size = 13678908, upload-time = "2026-09-21T22:47:10.714Z" },
{ url = "https://files.pythonhosted.org/packages/d7/e3/94ebbe1573b76660fa09f2dbce96da6d0bb69a2d29c7b6ad538b452979d4/ty-0.0.83-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bdf5dfa157f5c127ba096fe0561b47877e6e79562c7fc2b35906ba5e9f6bb723", size = 13937248, upload-time = "2026-09-21T22:47:14.133Z" },
{ url = "https://files.pythonhosted.org/packages/2f/c0/3ea45c2ec3a82fddb7137e457ccb2a452446905430d225adaa4990cdc338/ty-0.0.83-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:626d6ad00f05a5d9b7ea80de44b12931d0c0fb50e64eab20b7fcc13e9aff2a68", size = 14290826, upload-time = "2026-09-21T22:47:16.874Z" },
{ url = "https://files.pythonhosted.org/packages/d8/b2/c941ce83a59e46ef31dd04bdf6067530e7ec77d5f0cdd1e223fbc6fc351b/ty-0.0.83-py3-none-win32.whl", hash = "sha256:0391017dac6ba3e452e0322301f50bba12fbccadac07d7fd888c46951ad37638", size = 13105291, upload-time = "2026-09-21T22:47:19.807Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8a/5eef9458505698b144643cbd2062f465c5d860d8cf86a8710d74505ad9a0/ty-0.0.83-py3-none-win_amd64.whl", hash = "sha256:1b67d86f5bbad4076ae1a2ff23a6a4493219885d72d4b148cb0193e090390060", size = 13901557, upload-time = "2026-09-21T22:47:23.429Z" },
{ url = "https://files.pythonhosted.org/packages/9f/27/a06061513f1de25569c2e8898018bd552ab7b31afa7cfe818703269b3350/ty-0.0.83-py3-none-win_arm64.whl", hash = "sha256:a916a3d7d68fe30b7aacdb9fe16d461fa944d722e59e96ea0fa9d206a1b6625f", size = 13627960, upload-time = "2026-09-21T22:47:26.305Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"