Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eccf81a5ed | ||
|
|
73298b3e49 | ||
|
|
e0c32319e8 | ||
|
|
8a0c98e9bb | ||
|
|
5810f16901 | ||
|
|
6b2821ba96 | ||
|
|
6babc560c7 | ||
|
|
4efef98c9c | ||
|
|
6d98939024 |
@@ -38,3 +38,5 @@ jobs:
|
||||
run: uv run black --check . --extend-exclude .venv
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
- name: Typing (mypy)
|
||||
run: uv run mypy .
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
uv sync
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
uv run alembic upgrade head
|
||||
|
||||
# Run all tests
|
||||
pytest
|
||||
uv run pytest
|
||||
|
||||
# Run a single test file
|
||||
pytest tests/test_model_b4wexport.py -v
|
||||
uv run pytest tests/test_model_b4wexport.py -v
|
||||
```
|
||||
|
||||
Use `uv run` for project commands so they use the project-managed Python environment and dependencies.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **`src/`**: Application code
|
||||
@@ -53,10 +55,10 @@ with get_session() as session:
|
||||
|
||||
```bash
|
||||
# Tests automatically run migrations via conftest fixture
|
||||
pytest tests/ -v --tb=short
|
||||
uv run pytest tests/ -v --tb=short
|
||||
|
||||
# To test without migrations (in-memory SQLite), use DATABASE_URL="sqlite://"
|
||||
export DATABASE_URL="sqlite://" pytest tests/test_db.py
|
||||
DATABASE_URL="sqlite://" uv run pytest tests/test_db.py
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
@@ -69,8 +71,35 @@ Two sources with shared patterns:
|
||||
|
||||
- **b4w**: `B4wExport`, `Category`, transactional import to `import_b4w` table + `category` table
|
||||
- **comdirect**: `ComdirectGiro`, `ComdirectCreditCard`, `ComdirectDepot`, import to `comdirect_giro` table
|
||||
- **unified**: `Transaction`, `TransactionDb` – both ingest pipelines project their staging rows (`import_b4w`, `comdirect_giro`) into one `transactions` table via `src/ingest/transactions.py` (`map_*` mappers + `consolidate_transactions`). Dedup uses a business-key `hash_id` (source + IBAN + value date + amount + text + counterparty) with a UNIQUE constraint and SQLite `INSERT OR IGNORE`, so re-imports are idempotent. Amounts are canonical signed (negative = money out). Depot/credit-card/tagesgeld are not projected yet.
|
||||
|
||||
All models inherit from common base in `src/models/_common.py`:
|
||||
## Category Budget Management (NEW)
|
||||
|
||||
Budget tracking for categories with time-bounded or unbounded periods:
|
||||
|
||||
```python
|
||||
from src.models.category import Category, CategoryBudget
|
||||
from datetime import date
|
||||
|
||||
cat = Category(name="Lebenshaltung")
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.00,
|
||||
valid_from=date(2026, 10, 1), # Optional: None for unbounded start
|
||||
valid_to=date(2026, 11, 30), # Optional: None for unbounded end
|
||||
)
|
||||
|
||||
# Set/replace budget - handles overlap automatically:
|
||||
from src.domain_utils import set_category_budget
|
||||
set_category_budget(db_session, budget)
|
||||
```
|
||||
|
||||
The `set_category_budget()` function intelligently handles three scenarios:
|
||||
1. **No overlap**: Adds new budget alongside existing ones
|
||||
2. **Partial overlap**: Splits existing budget into non-overlapping parts + adds new budget
|
||||
3. **Full overlap**: Replaces existing budget entirely
|
||||
|
||||
All models inherit from common base in `src/models/common.py`:
|
||||
- `_hash()`: SHA256-based deduplication key
|
||||
- `_parse_date_german()`: Handles "07.09.2026" format and values like "offen", "--"
|
||||
- `_parse_decimal_german()`: Converts "1,23" to Decimal
|
||||
@@ -102,6 +131,22 @@ item = B4wExportFactory.build( # build() for partial, create() for full
|
||||
|
||||
## Testing Tips
|
||||
|
||||
- Run single test: `pytest tests/test_model_b4wexport.py -v`
|
||||
- Run single test: `uv run pytest tests/test_model_b4wexport.py -v`
|
||||
- View migrations: `ls alembic/versions/`
|
||||
- Test data location: `/scripts/dummy_data/`
|
||||
|
||||
### Type Checking (mypy)
|
||||
|
||||
```bash
|
||||
# Static type checking with mypy
|
||||
uv run mypy src/
|
||||
|
||||
# With test coverage
|
||||
uv run pytest --cov=src --cov-report=term-missing
|
||||
```
|
||||
|
||||
**Note**: Some comparisons between `date | None` types require explicit guards or type assertions to satisfy mypy, even when the logic is correct. See `src/domain_utils.py` for examples.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Don't be too verbose in docstrings and comments
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# finanz-cockpit
|
||||
|
||||
Financial tracking application for German bank exports (b4w, comdirect) with unified transaction processing and category-based budget management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual-source ingestion**: Import transactions from b4w and comdirect CSV exports
|
||||
- **Unified transaction table**: All transactions are consolidated into a single `transactions` table using business-key deduplication
|
||||
- **Category hierarchy**: Support for nested category structures (e.g., `"Lebenshaltung:Essen:Supermarkt"`)
|
||||
- **Budget tracking per category**: Set and manage budgets with time-bounded or unbounded validity periods
|
||||
- **Automatic overlap handling**: Smart budget management handles partial/full overlaps automatically
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Import b4w Export
|
||||
|
||||
```python
|
||||
from src.ingest.b4w import ingest_b4w
|
||||
from src.db import get_session
|
||||
from pathlib import Path
|
||||
|
||||
csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv")
|
||||
with get_session() as session:
|
||||
ingest_b4w(csv_path, session)
|
||||
```
|
||||
|
||||
### Import comdirect Export
|
||||
|
||||
```python
|
||||
from src.ingest.comdirect import ingest_comdirect
|
||||
from src.db import get_session
|
||||
from pathlib import Path
|
||||
|
||||
csv_path = Path("/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/comdirect_giro_01.csv")
|
||||
with get_session() as session:
|
||||
ingest_comdirect(
|
||||
session,
|
||||
csv_path,
|
||||
account_iban="DE89370400440532013000",
|
||||
account_currency="EUR",
|
||||
)
|
||||
```
|
||||
|
||||
### Set Category Budgets
|
||||
|
||||
```python
|
||||
from src.models.category import Category, CategoryBudget
|
||||
from datetime import date
|
||||
from src.domain_utils import set_category_budget
|
||||
from src.db import get_session
|
||||
|
||||
# Create category hierarchy
|
||||
cat = Category.from_str("Lebenshaltung:Essen:Supermarkt")
|
||||
|
||||
# Set budget for October-November 2026
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
with get_session() as session:
|
||||
set_category_budget(session, budget)
|
||||
session.commit()
|
||||
|
||||
# Budgets can also be unbounded (no dates specified)
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=200.00, # Monthly average
|
||||
)
|
||||
set_category_budget(session, budget)
|
||||
session.commit()
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src --cov-report=term-missing
|
||||
|
||||
# Run a single test file
|
||||
pytest tests/test_model_b4wexport.py -v
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **`src/`**: Application code
|
||||
- `main.py`: CLI entrypoint for CSV ingestion
|
||||
- `db.py`: Database connection management
|
||||
- `models/`: Pydantic models + SQLAlchemy tables
|
||||
- `ingest/`: Data ingestion logic per source (b4w, comdirect)
|
||||
- `domain_utils.py`: Domain-level utilities (budget overlap handling)
|
||||
- **`tests/`**: Tests with fixtures in `conftest.py`
|
||||
- **`alembic/versions/`**: Database migrations
|
||||
|
||||
## Data Models
|
||||
|
||||
### Transaction Sources
|
||||
|
||||
- **b4w**: `B4wExport`, `Category`
|
||||
- **comdirect**: `ComdirectGiro`, `ComdirectCreditCard`, `ComdirectDepot`
|
||||
- **unified**: `Transaction`, `TransactionDb` – consolidates all sources into one table
|
||||
|
||||
All models inherit from common base in `src/models/common.py`:
|
||||
- `hash_id()`: SHA256-based deduplication key
|
||||
- `_parse_date_german()`: Handles "07.09.2026" format and values like "offen", "--"
|
||||
- `_parse_decimal_german()`: Converts "1,23" to Decimal
|
||||
|
||||
### Category Budgets
|
||||
|
||||
Budget tracking for categories with time-bounded or unbounded periods:
|
||||
|
||||
```python
|
||||
from src.models.category import Category, CategoryBudget
|
||||
|
||||
cat = Category(name="Lebenshaltung")
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.00,
|
||||
valid_from=date(2026, 10, 1), # Optional: None for unbounded start
|
||||
valid_to=date(2026, 11, 30), # Optional: None for unbounded end
|
||||
)
|
||||
|
||||
set_category_budget(db_session, budget)
|
||||
```
|
||||
|
||||
The `set_category_budget()` function handles overlap scenarios automatically:
|
||||
1. **No overlap**: Adds new budget alongside existing ones
|
||||
2. **Partial overlap**: Splits existing budget into non-overlapping parts + adds new budget
|
||||
3. **Full overlap**: Replaces existing budget entirely
|
||||
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run migrations
|
||||
alembic upgrade head
|
||||
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage report
|
||||
pytest --cov=src --cov-report=html:htmlcov
|
||||
```
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Database Session**: Always use `get_session()` context manager:
|
||||
|
||||
```python
|
||||
from src.db import get_session
|
||||
|
||||
with get_session() as session:
|
||||
# query operations here
|
||||
session.commit()
|
||||
```
|
||||
|
||||
**CSV Format**:
|
||||
- **b4w**: German bank export (CSV)
|
||||
- **comdirect**: CSV with fields like `Buchungstag`, `Umsatz in EUR`, `Vorgang`
|
||||
|
||||
### Testing Workflow
|
||||
|
||||
Tests automatically run migrations via `conftest.py`. For testing without migrations:
|
||||
|
||||
```bash
|
||||
export DATABASE_URL="sqlite://" pytest tests/test_db.py
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Global engine state**: Always use `get_session()` context manager
|
||||
2. **German date formats**: Use `_parse_date_german()` for CSV parsing
|
||||
3. **Migration setup**: Tests auto-migrate, but integration tests need explicit `alembic upgrade head`
|
||||
4. **Faker locale**: Use `Faker("de_DE")` for realistic names/IBANs
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
+2
-3
@@ -6,7 +6,7 @@ from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
from src.db import get_set_engine
|
||||
from src.models._common import BaseModelDb
|
||||
from src.models.common import BaseModelDb
|
||||
|
||||
load_dotenv() # reads .env into os.environ
|
||||
|
||||
@@ -65,9 +65,8 @@ def run_migrations_online() -> None:
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
if get_set_engine() is not None:
|
||||
connectable = get_set_engine()
|
||||
else:
|
||||
if connectable is None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""create transactions table
|
||||
|
||||
Revision ID: 78ec55b9e1d1
|
||||
Revises: 8822a28b01ca
|
||||
Create Date: 2026-10-10 16:32:10.691919
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "78ec55b9e1d1"
|
||||
down_revision: str | Sequence[str] | None = "8822a28b01ca"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
"transactions",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("hash_id", sa.String(), nullable=False),
|
||||
sa.Column("source", sa.String(), nullable=False),
|
||||
sa.Column("source_row_id", sa.Integer(), nullable=False),
|
||||
sa.Column("account_iban", sa.String(), nullable=False),
|
||||
sa.Column("account_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("date_valid", sa.Date(), nullable=False),
|
||||
sa.Column("date_book", sa.Date(), nullable=True),
|
||||
sa.Column("amount", sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column("amount_currency", sa.String(length=3), nullable=False),
|
||||
sa.Column("text", sa.String(), nullable=True),
|
||||
sa.Column("other_name", sa.String(), nullable=True),
|
||||
sa.Column("other_iban", sa.String(), nullable=True),
|
||||
sa.Column("category_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["category_id"],
|
||||
["category.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("hash_id", name="uq_transactions_hash"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_transactions_source_row_id",
|
||||
"transactions",
|
||||
["source", "source_row_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index("ix_transactions_source_row_id", table_name="transactions")
|
||||
op.drop_table("transactions")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,23 @@
|
||||
"""merge category validity and budget migration branches
|
||||
|
||||
Revision ID: c18ae67f4b2a
|
||||
Revises: d9c6533f8a1b, fe23064de1c5
|
||||
Create Date: 2026-10-27
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c18ae67f4b2a"
|
||||
down_revision: str | Sequence[str] | None = ("d9c6533f8a1b", "fe23064de1c5")
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,102 @@
|
||||
"""add temporal validity to categories
|
||||
|
||||
Revision ID: d9c6533f8a1b
|
||||
Revises: 78ec55b9e1d1
|
||||
Create Date: 2026-10-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d9c6533f8a1b"
|
||||
down_revision: str | Sequence[str] | None = "78ec55b9e1d1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("category") as batch_op:
|
||||
batch_op.drop_constraint("uq_category_name_parent", type_="unique")
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"valid_from",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
info={
|
||||
"description": "UTC start of validity; NULL means valid since the indefinite past."
|
||||
},
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"valid_to",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
info={
|
||||
"description": "UTC end of validity; NULL means the version is currently valid."
|
||||
},
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"last_action",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="created",
|
||||
info={
|
||||
"description": "Most recent lifecycle action recorded for this category version."
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Existing rows predate temporal tracking, so their start is unbounded.
|
||||
op.execute("UPDATE category SET last_action = 'migrated'")
|
||||
|
||||
# Separate root and non-root indexes so NULL parent IDs are unique too.
|
||||
op.create_index(
|
||||
"uq_category_active_name_parent",
|
||||
"category",
|
||||
["name", "parent_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"),
|
||||
postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NOT NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_category_active_root_name",
|
||||
"category",
|
||||
["name"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("valid_to IS NULL AND parent_id IS NULL"),
|
||||
postgresql_where=sa.text("valid_to IS NULL AND parent_id IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
has_temporal_history = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM category "
|
||||
"WHERE valid_from IS NOT NULL OR valid_to IS NOT NULL LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
if has_temporal_history:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade category validity while temporal history exists; "
|
||||
"the downgrade would discard it."
|
||||
)
|
||||
|
||||
op.drop_index("uq_category_active_root_name", table_name="category")
|
||||
op.drop_index("uq_category_active_name_parent", table_name="category")
|
||||
|
||||
with op.batch_alter_table("category") as batch_op:
|
||||
batch_op.drop_column("last_action")
|
||||
batch_op.drop_column("valid_to")
|
||||
batch_op.drop_column("valid_from")
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_category_name_parent", ["name", "parent_id"]
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""create category_budget table
|
||||
|
||||
Revision ID: fe23064de1c5
|
||||
Revises: 78ec55b9e1d1
|
||||
Create Date: 2026-10-16 10:43:24.952699
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "fe23064de1c5"
|
||||
down_revision: str | Sequence[str] | None = "78ec55b9e1d1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"category_budget",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("category_id", sa.Integer(), nullable=False),
|
||||
sa.Column("amount_eur", sa.Numeric(precision=12, scale=2), nullable=False),
|
||||
sa.Column("valid_from", sa.Date(), nullable=True),
|
||||
sa.Column("valid_to", sa.Date(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["category_id"],
|
||||
["category.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_category_budget_category_id",
|
||||
"category_budget",
|
||||
["category_id"],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index("ix_category_budget_category_id", table_name="category_budget")
|
||||
op.drop_table("category_budget")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"month": "2026-10",
|
||||
"categories": [
|
||||
{
|
||||
"category": "super-category-1",
|
||||
"total_amount": 100,
|
||||
"total_budget": 120,
|
||||
"sub_categories": [
|
||||
{
|
||||
"category": "category-1",
|
||||
"total_amount": 60,
|
||||
"total_budget": 50,
|
||||
"sub_categories": [],
|
||||
"transactions": []
|
||||
},
|
||||
{
|
||||
"category": "category-2",
|
||||
"total_amount": 40,
|
||||
"total_budget": 60,
|
||||
"sub_categories": [],
|
||||
"transactions": []
|
||||
}
|
||||
],
|
||||
"transactions": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,8 +11,10 @@ dependencies = [
|
||||
"alembic>=1.19.1",
|
||||
"argparse>=1.4.0",
|
||||
"dotenv>=0.9.9",
|
||||
"mypy>=2.4.0",
|
||||
"pydantic>=2.13.5",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-cov>=7.1.0",
|
||||
"python-dotenv>=1.2.3",
|
||||
"sqlalchemy>=2.0.52",
|
||||
]
|
||||
@@ -28,9 +30,17 @@ module-root = ""
|
||||
requires = ["uv_build>=0.12.9,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.mypy]
|
||||
explicit_package_bases = true
|
||||
mypy_path = "."
|
||||
exclude = ["^tests/"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
|
||||
[tool.black]
|
||||
target-version = [ "py314" ]
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = ["EXE"]
|
||||
|
||||
@@ -42,3 +52,17 @@ dev = [
|
||||
"polyfactory>=3.3.0",
|
||||
"ruff>=0.16.7",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["src"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 85
|
||||
skip_covered = true
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
"^[a-zA-Z_]+\\s*=\\s*$"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
Girokonto:Beruf / Ausbildung
|
||||
Girokonto:Beruf / Ausbildung:Gehalt
|
||||
Girokonto:Beruf / Ausbildung:Reisekosten
|
||||
Girokonto:Freizeit
|
||||
Girokonto:Freizeit:Abo
|
||||
Girokonto:Freizeit:Elektronik&Hardware
|
||||
Girokonto:Freizeit:Foto
|
||||
Girokonto:Freizeit:Medien
|
||||
Girokonto:Freizeit:Musikintrumente
|
||||
Girokonto:Freizeit:Urlaub&Ausflüge
|
||||
Girokonto:Freizeit:sonst.
|
||||
Girokonto:Lebenshaltung
|
||||
Girokonto:Lebenshaltung:Auto
|
||||
Girokonto:Lebenshaltung:Bekleidung
|
||||
Girokonto:Lebenshaltung:Fahrrad
|
||||
Girokonto:Lebenshaltung:Gesundheit
|
||||
Girokonto:Lebenshaltung:Haushalt sonst.
|
||||
Girokonto:Lebenshaltung:Heimwerken
|
||||
Girokonto:Lebenshaltung:Kontoführung
|
||||
Girokonto:Lebenshaltung:Körperpflege
|
||||
Girokonto:Lebenshaltung:Lebensmittel
|
||||
Girokonto:Lebenshaltung:Telekommunikation
|
||||
Girokonto:Lebenshaltung:Versicherungen
|
||||
Girokonto:Privat
|
||||
Girokonto:Privat:Geschenke
|
||||
Girokonto:Privat:Mitgliedsbeiträge
|
||||
Girokonto:Privat:Steuerausgleich Ehetine
|
||||
Girokonto:Sonstiges
|
||||
Girokonto:Sparen
|
||||
Girokonto:Sparen:Depot
|
||||
Girokonto:Sparen:Tagesgeld
|
||||
Girokonto:unbestimmte Verwendung:Bargeld
|
||||
Girokonto:unbestimmte Verwendung:Kreditkarte
|
||||
Haushaltskonto:Beiträge
|
||||
Umbuchung
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract unique categories from b4w CSV export."""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_unique_categories(csv_path: str, output_path: str) -> list[str]:
|
||||
"""Merge CSV categories with the existing output file, sorted, one per line."""
|
||||
|
||||
unique_categories: set[str] = set()
|
||||
|
||||
with open(csv_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
category = (row.get("Category") or "").strip()
|
||||
if category:
|
||||
unique_categories.add(category)
|
||||
|
||||
output_file_path = Path(output_path)
|
||||
if output_file_path.exists():
|
||||
with open(output_file_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if category := line.strip():
|
||||
unique_categories.add(category)
|
||||
|
||||
sorted_categories = sorted(unique_categories)
|
||||
|
||||
with open(output_file_path, "w", encoding="utf-8") as f:
|
||||
f.writelines(f"{cat}\n" for cat in sorted_categories)
|
||||
|
||||
print(f"Extracted {len(sorted_categories)} unique categories")
|
||||
print(f"Output written to: {output_path}")
|
||||
return sorted_categories
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract unique categories from a b4w CSV export."
|
||||
)
|
||||
parser.add_argument("--csv", required=True, help="Pfad der b4w-Exportdatei (CSV)")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="scripts/initial_load/categories/categories.txt",
|
||||
help="Pfad der Ausgabedatei (Default: %(default)s)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
extract_unique_categories(args.csv, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,20 +1,16 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models._common import BaseModelDb
|
||||
from sqlalchemy.orm import DeclarativeBase, Session
|
||||
|
||||
|
||||
def _query_existing(
|
||||
session: Session, DbModel: type[BaseModelDb], query_parameters: dict[str, Any]
|
||||
) -> BaseModelDb | None:
|
||||
def _query_existing[DbModelT: DeclarativeBase](
|
||||
session: Session, DbModel: type[DbModelT], query_parameters: dict[str, Any]
|
||||
) -> DbModelT | None:
|
||||
query = session.query(DbModel).filter_by(**query_parameters)
|
||||
return query.first()
|
||||
|
||||
|
||||
def _upsert[DbModelT: BaseModelDb](
|
||||
def upsert[DbModelT: DeclarativeBase](
|
||||
session: Session,
|
||||
DbModel: type[DbModelT],
|
||||
parameters_existing: dict[str, Any],
|
||||
@@ -28,7 +24,6 @@ def _upsert[DbModelT: BaseModelDb](
|
||||
if do_update:
|
||||
for key, value in parameters_new.items():
|
||||
setattr(existing_db_row, key, value)
|
||||
assert isinstance(existing_db_row, DbModel)
|
||||
return existing_db_row
|
||||
|
||||
new_db_row = DbModel(**parameters_new)
|
||||
@@ -37,8 +32,3 @@ def _upsert[DbModelT: BaseModelDb](
|
||||
if do_commit:
|
||||
session.commit()
|
||||
return new_db_row
|
||||
|
||||
|
||||
def _reader_raw(path: Path):
|
||||
with path.open() as fid:
|
||||
return list(csv.DictReader(fid))
|
||||
@@ -0,0 +1,149 @@
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.category import Category, CategoryBudget, CategoryBudgetDb, CategoryDb
|
||||
|
||||
ONE_DAY = timedelta(days=1)
|
||||
|
||||
|
||||
def find_category_db(session: Session, category: Category | None) -> CategoryDb | None:
|
||||
"""Find a CategoryDb in the database given a Pydantic Category object.
|
||||
|
||||
Recursively finds parent categories to build the full hierarchy path.
|
||||
Returns None if any part of the hierarchy doesn't exist.
|
||||
"""
|
||||
if category is None:
|
||||
return None
|
||||
|
||||
def find_existing(node: Category) -> CategoryDb | None:
|
||||
if node.parent is None:
|
||||
parent_id = None
|
||||
else:
|
||||
parent_db = find_existing(node.parent)
|
||||
if parent_db is None:
|
||||
return None
|
||||
parent_id = parent_db.id
|
||||
|
||||
return (
|
||||
session.query(CategoryDb)
|
||||
.filter_by(name=node.name, parent_id=parent_id)
|
||||
.filter(CategoryDb.valid_to.is_(None))
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
return find_existing(category)
|
||||
|
||||
|
||||
def set_category_budget(session: Session, category_budget: CategoryBudget):
|
||||
"""Set or update a category budget, handling overlaps correctly.
|
||||
|
||||
This function handles three cases:
|
||||
1. No overlap: Add the new budget alongside existing ones
|
||||
2. Partial overlap: Split the existing budget and add appropriate parts + new budget
|
||||
3. Full overlap: Replace the existing budget entirely with the new one
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session
|
||||
category_budget: The budget to set/update
|
||||
|
||||
Raises:
|
||||
ValueError: If the category doesn't exist in the database
|
||||
"""
|
||||
# Find the category in the database
|
||||
category_db = find_category_db(session, category_budget.category)
|
||||
if category_db is None:
|
||||
raise ValueError(
|
||||
f"No matching category in database for category: {category_budget.category.to_str()}"
|
||||
)
|
||||
|
||||
new_start = category_budget.valid_from
|
||||
new_end = category_budget.valid_to
|
||||
new_amount = category_budget.amount_eur
|
||||
|
||||
# Helper to add a budget row
|
||||
def _add_row(amount_eur: Decimal, valid_from: date | None, valid_to: date | None):
|
||||
return CategoryBudgetDb(
|
||||
category_id=category_db.id,
|
||||
amount_eur=amount_eur,
|
||||
valid_from=valid_from,
|
||||
valid_to=valid_to,
|
||||
)
|
||||
|
||||
# Helper to check if two date ranges overlap
|
||||
def _ranges_overlap(
|
||||
start1: date | None, end1: date | None, start2: date | None, end2: date | None
|
||||
) -> bool:
|
||||
"""Check if two date ranges overlap."""
|
||||
range1_unbounded = start1 is None and end1 is None
|
||||
range2_unbounded = start2 is None and end2 is None
|
||||
|
||||
# A fully unbounded budget acts as a default alongside bounded budgets.
|
||||
if range1_unbounded and range2_unbounded:
|
||||
return True
|
||||
if range1_unbounded or range2_unbounded:
|
||||
return False
|
||||
|
||||
# None on one side of a partially bounded range means infinity. Date
|
||||
# endpoints are inclusive, so equal endpoints also overlap.
|
||||
starts_before_end1 = end1 is None or start2 is None or start2 <= end1
|
||||
starts_before_end2 = end2 is None or start1 is None or start1 <= end2
|
||||
return starts_before_end1 and starts_before_end2
|
||||
|
||||
# Helper to find overlapping sub-range
|
||||
def _overlap_range(
|
||||
start1: date | None, end1: date | None, start2: date | None, end2: date | None
|
||||
) -> tuple[date | None, date | None]:
|
||||
"""Return the overlapping sub-range of two ranges."""
|
||||
o_start = max((s for s in (start1, start2) if s is not None), default=None)
|
||||
o_end = min((e for e in (end1, end2) if e is not None), default=None)
|
||||
return (o_start, o_end)
|
||||
|
||||
existing_budgets = (
|
||||
session.query(CategoryBudgetDb).filter_by(category_id=category_db.id).all()
|
||||
)
|
||||
|
||||
def _remove_and_split_budget(budget: CategoryBudgetDb):
|
||||
"""Remove the overlapping parts of an existing budget and add back non-overlapping parts."""
|
||||
overlap_start, overlap_end = _overlap_range(
|
||||
new_start, new_end, budget.valid_from, budget.valid_to
|
||||
)
|
||||
|
||||
# Keep the old budget's portion before the new range, if any.
|
||||
if overlap_start is not None and (
|
||||
budget.valid_from is None or budget.valid_from < overlap_start
|
||||
):
|
||||
session.add(
|
||||
_add_row(
|
||||
budget.amount_eur,
|
||||
budget.valid_from,
|
||||
overlap_start - ONE_DAY,
|
||||
)
|
||||
)
|
||||
|
||||
# Keep the old budget's portion after the new range, if any.
|
||||
if overlap_end is not None and (
|
||||
budget.valid_to is None or overlap_end < budget.valid_to
|
||||
):
|
||||
session.add(
|
||||
_add_row(
|
||||
budget.amount_eur,
|
||||
overlap_end + ONE_DAY,
|
||||
budget.valid_to,
|
||||
)
|
||||
)
|
||||
|
||||
# Process each existing budget
|
||||
for existing_budget in existing_budgets:
|
||||
if not _ranges_overlap(
|
||||
new_start, new_end, existing_budget.valid_from, existing_budget.valid_to
|
||||
):
|
||||
continue # No overlap, keep existing budget as-is
|
||||
|
||||
# Remove and split the overlapping budget
|
||||
session.delete(existing_budget)
|
||||
_remove_and_split_budget(existing_budget)
|
||||
|
||||
# Add the requested budget once, even if it replaced multiple existing rows.
|
||||
session.add(_add_row(new_amount, new_start, new_end))
|
||||
+98
-19
@@ -1,9 +1,13 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.ingest._common import _reader_raw, _upsert
|
||||
from src.models.b4w import B4wDb, B4wExport, Category, CategoryDb
|
||||
from src.db_utils import upsert
|
||||
from src.ingest.common import _reader_raw
|
||||
from src.ingest.transactions import consolidate_transactions, map_b4w_to_transaction
|
||||
from src.models.b4w import B4wDb, B4wExport
|
||||
from src.models.category import Category, CategoryDb, upsert_category
|
||||
|
||||
|
||||
def reader_b4w(path: Path) -> list[B4wExport]:
|
||||
@@ -11,23 +15,91 @@ def reader_b4w(path: Path) -> list[B4wExport]:
|
||||
return list(map(B4wExport.model_validate, raw))
|
||||
|
||||
|
||||
def upsert_category(
|
||||
session: Session, category: Category | None, do_commit=True
|
||||
) -> CategoryDb | None:
|
||||
if category is None:
|
||||
return None
|
||||
parameters = {
|
||||
"name": category.name,
|
||||
"parent": upsert_category(session, category.parent),
|
||||
}
|
||||
return _upsert(
|
||||
session,
|
||||
CategoryDb,
|
||||
parameters,
|
||||
parameters,
|
||||
do_update=False,
|
||||
do_commit=do_commit,
|
||||
def _utc_datetime(value: datetime | None) -> datetime:
|
||||
if value is None:
|
||||
return datetime.now(UTC)
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _ensure_after_valid_from(category: CategoryDb, effective_at: datetime) -> None:
|
||||
if category.valid_from is None:
|
||||
return
|
||||
valid_from = _utc_datetime(category.valid_from)
|
||||
if effective_at <= valid_from:
|
||||
raise ValueError("The action time must be later than valid_from")
|
||||
|
||||
|
||||
def change_category(
|
||||
session: Session,
|
||||
category_id: int,
|
||||
replacement: Category,
|
||||
effective_at: datetime | None = None,
|
||||
do_commit: bool = True,
|
||||
) -> CategoryDb:
|
||||
"""Close an active category version and start its replacement version."""
|
||||
current = (
|
||||
session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none()
|
||||
)
|
||||
if current is None:
|
||||
raise ValueError(f"No active category found with id {category_id}")
|
||||
|
||||
action_time = _utc_datetime(effective_at)
|
||||
_ensure_after_valid_from(current, action_time)
|
||||
parent = upsert_category(session, replacement.parent, do_commit=False)
|
||||
parent_id = parent.id if parent else None
|
||||
|
||||
if current.name == replacement.name and current.parent_id == parent_id:
|
||||
return current
|
||||
|
||||
replacement_active = (
|
||||
session.query(CategoryDb)
|
||||
.filter_by(name=replacement.name, parent_id=parent_id, valid_to=None)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
current.valid_to = action_time
|
||||
current.last_action = "changed"
|
||||
session.flush()
|
||||
|
||||
if replacement_active is None:
|
||||
replacement_active = CategoryDb(
|
||||
name=replacement.name,
|
||||
parent=parent,
|
||||
valid_from=action_time,
|
||||
valid_to=None,
|
||||
last_action="changed",
|
||||
)
|
||||
session.add(replacement_active)
|
||||
session.flush()
|
||||
|
||||
if do_commit:
|
||||
session.commit()
|
||||
return replacement_active
|
||||
|
||||
|
||||
def retire_category(
|
||||
session: Session,
|
||||
category_id: int,
|
||||
effective_at: datetime | None = None,
|
||||
do_commit: bool = True,
|
||||
) -> CategoryDb:
|
||||
"""Close an active category version without creating a replacement."""
|
||||
category = (
|
||||
session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none()
|
||||
)
|
||||
if category is None:
|
||||
raise ValueError(f"No active category found with id {category_id}")
|
||||
|
||||
action_time = _utc_datetime(effective_at)
|
||||
_ensure_after_valid_from(category, action_time)
|
||||
category.valid_to = action_time
|
||||
category.last_action = "retired"
|
||||
session.flush()
|
||||
if do_commit:
|
||||
session.commit()
|
||||
return category
|
||||
|
||||
|
||||
def upsert_b4w(
|
||||
@@ -53,7 +125,7 @@ def upsert_b4w(
|
||||
"notes": data.notes,
|
||||
}
|
||||
|
||||
return _upsert(
|
||||
return upsert(
|
||||
session,
|
||||
B4wDb,
|
||||
parameters_existing,
|
||||
@@ -67,4 +139,11 @@ def ingest_b4w(path: Path, session: Session):
|
||||
data = reader_b4w(path)
|
||||
for row in data:
|
||||
upsert_b4w(session, row, do_commit=False)
|
||||
consolidate_transactions(
|
||||
session,
|
||||
source="b4w",
|
||||
StagingDb=B4wDb,
|
||||
mapper=map_b4w_to_transaction,
|
||||
do_commit=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
+54
-30
@@ -1,18 +1,23 @@
|
||||
import re
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from settings import BANK_ACCOUNTS
|
||||
from src.ingest._common import _upsert
|
||||
from src.models._common import Csv2Dict, CsV2DictMulti
|
||||
from src.db_utils import upsert
|
||||
from src.ingest.transactions import (
|
||||
consolidate_transactions,
|
||||
map_comdirect_giro_to_transaction,
|
||||
)
|
||||
from src.models.comdirect import (
|
||||
ComdirectCreditCard,
|
||||
ComdirectDepot,
|
||||
ComdirectGiro,
|
||||
ComdirectGiroDb,
|
||||
)
|
||||
from src.models.common import Csv2Dict, CsV2DictMulti
|
||||
from src.models.settings import Account, Source
|
||||
|
||||
PATTERN_ACCOUNT_NAME = r'^"Umsätze ([^"]+)";"Zeitraum: .*$'
|
||||
@@ -40,18 +45,18 @@ COMDIRECT_ACCOUNT_PARSERS = {a.name: a.model.model_validate for a in ACCOUNTS}
|
||||
|
||||
|
||||
def reader_comdirect(path: Path) -> dict[str, list[ComdirectExportModel]]:
|
||||
result = {}
|
||||
result: dict[str, list[ComdirectExportModel]] = {}
|
||||
raw = _reader_raw_comdirect(path)
|
||||
for account in ACCOUNTS:
|
||||
assert account.name in raw
|
||||
data_raw: Csv2Dict = raw.pop(account.name)
|
||||
data: list = list(map(account.model.model_validate, data_raw))
|
||||
result[account.name] = data
|
||||
data = [account.model.model_validate(row) for row in data_raw]
|
||||
result[account.name] = cast(list[ComdirectExportModel], data)
|
||||
assert not raw
|
||||
return result
|
||||
|
||||
|
||||
def _open_and_readlines(path: Path, encoding: str) -> list:
|
||||
def _open_and_readlines(path: Path, encoding: str) -> list[str]:
|
||||
with path.open("r", encoding=encoding) as fid:
|
||||
lines = fid.readlines()
|
||||
return lines
|
||||
@@ -65,14 +70,19 @@ def _reader_raw_comdirect(path: Path) -> CsV2DictMulti:
|
||||
lines = _open_and_readlines(path, encoding="latin-1")
|
||||
|
||||
expect_next = EXPECT_LINE.ACCOUNT_NAME
|
||||
account_name = None
|
||||
result = {}
|
||||
account_name: str | None = None
|
||||
header: list[str] = []
|
||||
result: CsV2DictMulti = {}
|
||||
# Comdirect groups "new" transactions (marked with "neu") under a header
|
||||
# line that carries only their Buchungstag; remember it until the next
|
||||
# transaction row for the same account.
|
||||
pending_book_date: dict[str, str] = {}
|
||||
for line in lines:
|
||||
if expect_next == EXPECT_LINE.ACCOUNT_NAME:
|
||||
account_name = re.findall(PATTERN_ACCOUNT_NAME, line)
|
||||
if account_name:
|
||||
account_name = account_name[0]
|
||||
result[account_name] = {}
|
||||
matches = re.findall(PATTERN_ACCOUNT_NAME, line)
|
||||
if matches:
|
||||
account_name = matches[0]
|
||||
result[account_name] = []
|
||||
expect_next = EXPECT_LINE.BLANK
|
||||
elif expect_next == EXPECT_LINE.BLANK:
|
||||
if line == "\n":
|
||||
@@ -80,16 +90,23 @@ def _reader_raw_comdirect(path: Path) -> CsV2DictMulti:
|
||||
elif expect_next == EXPECT_LINE.HEADER:
|
||||
header = re.findall(PATTERN_HEADER, line)
|
||||
if header:
|
||||
assert account_name is not None
|
||||
result[account_name] = []
|
||||
expect_next = EXPECT_LINE.DATA
|
||||
elif expect_next == EXPECT_LINE.DATA:
|
||||
data = re.findall(PATTERN_DATA, line)
|
||||
if data:
|
||||
result[account_name].append(
|
||||
{column: value for column, value in zip(header, data)}
|
||||
)
|
||||
else:
|
||||
if not data:
|
||||
expect_next = EXPECT_LINE.ACCOUNT_NAME
|
||||
elif len(data) == 1:
|
||||
# Group header line (single Buchungstag for the "neu" rows below).
|
||||
assert account_name is not None
|
||||
pending_book_date[account_name] = data[0]
|
||||
else:
|
||||
assert account_name is not None
|
||||
row = {column: value for column, value in zip(header, data)}
|
||||
if data[0] == "neu" and account_name in pending_book_date:
|
||||
row["Buchungstag"] = pending_book_date.pop(account_name)
|
||||
result[account_name].append(row)
|
||||
else:
|
||||
expect_next = EXPECT_LINE.ACCOUNT_NAME
|
||||
return result
|
||||
@@ -106,7 +123,7 @@ def upsert_comdirect_giro(
|
||||
parameters_existing = {"hash_id": data.hash_id}
|
||||
|
||||
if not data.date_val:
|
||||
return
|
||||
return None
|
||||
|
||||
parameters_new = {
|
||||
"hash_id": data.hash_id,
|
||||
@@ -120,7 +137,7 @@ def upsert_comdirect_giro(
|
||||
"text": data.text,
|
||||
}
|
||||
|
||||
return _upsert(
|
||||
return upsert(
|
||||
session,
|
||||
ComdirectGiroDb,
|
||||
parameters_existing,
|
||||
@@ -138,18 +155,25 @@ def ingest_comdirect(
|
||||
for account_name, data in data_collection.items():
|
||||
account = SOURCE_COMDIRECT.get_account(account_name)
|
||||
assert isinstance(account, Account)
|
||||
if account_name == "Girokonto":
|
||||
db_load_function = lambda session_, row_, account_: upsert_comdirect_giro(
|
||||
session=session_,
|
||||
data=row_,
|
||||
account_iban=account_.iban,
|
||||
account_currency=account_.currency,
|
||||
do_commit=False,
|
||||
)
|
||||
else:
|
||||
if account_name != "Girokonto":
|
||||
# Aktuell speist nur das Girokonto die Tabelle `comdirect_giro`
|
||||
# (Kreditkarte, Tagesgeld und Depot sind vorerst ausgeklammert).
|
||||
continue
|
||||
for row in data:
|
||||
data_validated = account.model.model_validate(row)
|
||||
assert isinstance(data_validated, account.model)
|
||||
db_load_function(session, row, account)
|
||||
assert isinstance(data_validated, ComdirectGiro)
|
||||
upsert_comdirect_giro(
|
||||
session=session,
|
||||
data=data_validated,
|
||||
account_iban=account_iban,
|
||||
account_currency=account_currency,
|
||||
do_commit=False,
|
||||
)
|
||||
consolidate_transactions(
|
||||
session,
|
||||
source="comdirect_giro",
|
||||
StagingDb=ComdirectGiroDb,
|
||||
mapper=map_comdirect_giro_to_transaction,
|
||||
do_commit=False,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _reader_raw(path: Path):
|
||||
with path.open() as fid:
|
||||
return list(csv.DictReader(fid))
|
||||
@@ -0,0 +1,116 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.b4w import B4wDb
|
||||
from src.models.category import Category, CategoryDb
|
||||
from src.models.comdirect import ComdirectGiroDb
|
||||
from src.models.common import BaseModelDb
|
||||
from src.models.transactions import Transaction, TransactionDb
|
||||
|
||||
|
||||
def _category_from_db(category_db: CategoryDb | None) -> Category | None:
|
||||
if category_db is None:
|
||||
return None
|
||||
return Category(
|
||||
name=category_db.name,
|
||||
parent=_category_from_db(category_db.parent),
|
||||
)
|
||||
|
||||
|
||||
def map_b4w_to_transaction(row: B4wDb) -> Transaction:
|
||||
"""Project one staging row of ``import_b4w`` onto a canonical Transaction."""
|
||||
return Transaction(
|
||||
source="b4w",
|
||||
source_row_id=row.id,
|
||||
account_iban=row.account_iban,
|
||||
account_currency=row.account_currency,
|
||||
date_valid=row.date_valid,
|
||||
date_book=row.date_book,
|
||||
amount=row.amount,
|
||||
amount_currency=row.amount_currency,
|
||||
text=row.text,
|
||||
other_name=row.other_name,
|
||||
other_iban=row.other_iban,
|
||||
category=_category_from_db(row.category),
|
||||
)
|
||||
|
||||
|
||||
def map_comdirect_giro_to_transaction(row: ComdirectGiroDb) -> Transaction:
|
||||
"""Project one staging row of ``comdirect_giro`` onto a canonical Transaction."""
|
||||
return Transaction(
|
||||
source="comdirect_giro",
|
||||
source_row_id=row.id,
|
||||
account_iban=row.account_iban,
|
||||
account_currency=row.account_currency,
|
||||
date_valid=row.date_valid,
|
||||
date_book=row.date_book,
|
||||
amount=row.amount,
|
||||
amount_currency=row.amount_currency,
|
||||
text=row.text,
|
||||
)
|
||||
|
||||
|
||||
def upsert_transaction(
|
||||
session: Session,
|
||||
transaction: Transaction,
|
||||
*,
|
||||
category_db: CategoryDb | None = None,
|
||||
do_commit: bool = True,
|
||||
) -> None:
|
||||
"""Insert one canonical transaction, ignoring duplicates on ``hash_id``.
|
||||
|
||||
Uses a DB-native ``INSERT OR IGNORE`` (SQLite). For PostgreSQL, swap for
|
||||
``postgresql_insert(TransactionDb).values(...).on_conflict_do_nothing(
|
||||
constraint="uq_transactions_hash")``.
|
||||
"""
|
||||
parameters = {
|
||||
"hash_id": transaction.hash_id,
|
||||
"source": transaction.source,
|
||||
"source_row_id": transaction.source_row_id,
|
||||
"account_iban": transaction.account_iban,
|
||||
"account_currency": transaction.account_currency,
|
||||
"date_valid": transaction.date_valid,
|
||||
"date_book": transaction.date_book,
|
||||
"amount": transaction.amount,
|
||||
"amount_currency": transaction.amount_currency,
|
||||
"text": transaction.text,
|
||||
"other_name": transaction.other_name,
|
||||
"other_iban": transaction.other_iban,
|
||||
"category_id": category_db.id if category_db is not None else None,
|
||||
}
|
||||
stmt = sqlite_insert(TransactionDb).values(parameters)
|
||||
_ = session.execute(stmt.prefix_with("OR IGNORE"))
|
||||
if do_commit:
|
||||
session.commit()
|
||||
|
||||
|
||||
def consolidate_transactions[StagingDbT: BaseModelDb](
|
||||
session: Session,
|
||||
source: str,
|
||||
StagingDb: type[StagingDbT],
|
||||
mapper: Callable[[StagingDbT], Transaction],
|
||||
do_commit: bool = True,
|
||||
) -> None:
|
||||
"""Project staging rows of one source into the unified ``transactions`` table.
|
||||
|
||||
Idempotent: rows whose business-key hash already exists for this source
|
||||
are skipped, and ``INSERT OR IGNORE`` protects against in-batch duplicates.
|
||||
"""
|
||||
existing_hashes = set(
|
||||
session.scalars(select(TransactionDb.hash_id).filter_by(source=source))
|
||||
)
|
||||
for staging_row in session.query(StagingDb).all():
|
||||
transaction = mapper(staging_row)
|
||||
if transaction.hash_id in existing_hashes:
|
||||
continue
|
||||
upsert_transaction(
|
||||
session,
|
||||
transaction,
|
||||
category_db=getattr(staging_row, "category", None),
|
||||
do_commit=False,
|
||||
)
|
||||
if do_commit:
|
||||
session.commit()
|
||||
+8
-2
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from src.db import get_session
|
||||
from src.ingest.b4w import ingest_b4w
|
||||
from src.ingest.comdirect import ingest_comdirect
|
||||
from src.models.settings import Account
|
||||
@@ -19,16 +20,21 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.input_b4w:
|
||||
ingest_b4w(Path(args.input_b4w))
|
||||
with get_session() as session:
|
||||
ingest_b4w(Path(args.input_b4w), session)
|
||||
if args.input_comdirect:
|
||||
import settings
|
||||
|
||||
giro_account = settings.BANK_ACCOUNTS["comdirect"].get_account("Girokonto")
|
||||
assert isinstance(giro_account, Account)
|
||||
with get_session() as session:
|
||||
ingest_comdirect(
|
||||
session,
|
||||
Path(args.input_comdirect),
|
||||
account_iban=giro_account.iban or "",
|
||||
account_currency=giro_account.currency or "",
|
||||
account_currency=(
|
||||
giro_account.currency.value if giro_account.currency else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+47
-7
@@ -1,7 +1,47 @@
|
||||
from src.models.b4w import B4wExport as B4wExport
|
||||
from src.models.b4w import Category as Category
|
||||
from src.models.comdirect import ComdirectCreditCard as ComdirectCreditCard
|
||||
from src.models.comdirect import ComdirectDepot as ComdirectDepot
|
||||
from src.models.comdirect import ComdirectGiro as ComdirectGiro
|
||||
from src.models.settings import Account as Account
|
||||
from src.models.settings import Source as Source
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
_EXPORTS = {
|
||||
"B4wExport": ".b4w",
|
||||
"Category": ".category",
|
||||
"CategoryBudget": ".category",
|
||||
"CategoryBudgetDb": ".category",
|
||||
"CategoryDb": ".category",
|
||||
"ComdirectCreditCard": ".comdirect",
|
||||
"ComdirectDepot": ".comdirect",
|
||||
"ComdirectGiro": ".comdirect",
|
||||
"Account": ".settings",
|
||||
"Source": ".settings",
|
||||
"Transaction": ".transactions",
|
||||
"TransactionDb": ".transactions",
|
||||
}
|
||||
__all__ = list(_EXPORTS)
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
module_name = _EXPORTS.get(name)
|
||||
if module_name is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
value = getattr(import_module(module_name, __name__), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted([*globals(), *__all__])
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.b4w import B4wExport as B4wExport
|
||||
from src.models.category import Category as Category
|
||||
from src.models.category import CategoryBudget as CategoryBudget
|
||||
from src.models.category import CategoryBudgetDb as CategoryBudgetDb
|
||||
from src.models.category import CategoryDb as CategoryDb
|
||||
from src.models.comdirect import ComdirectCreditCard as ComdirectCreditCard
|
||||
from src.models.comdirect import ComdirectDepot as ComdirectDepot
|
||||
from src.models.comdirect import ComdirectGiro as ComdirectGiro
|
||||
from src.models.settings import Account as Account
|
||||
from src.models.settings import Source as Source
|
||||
from src.models.transactions import Transaction as Transaction
|
||||
from src.models.transactions import TransactionDb as TransactionDb
|
||||
|
||||
+22
-63
@@ -2,19 +2,26 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import Field, computed_field, field_validator
|
||||
from sqlalchemy import Column, Date, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import (
|
||||
Date,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.constants import IBAN_PATTERN, Currency
|
||||
from src.models._common import (
|
||||
BaseModelDb,
|
||||
from src.models.category import Category, CategoryDb
|
||||
from src.models.common import (
|
||||
ExportBaseModel,
|
||||
IngestdbBase,
|
||||
Sign,
|
||||
_hash,
|
||||
date2str,
|
||||
hashify,
|
||||
)
|
||||
|
||||
__all__ = ["B4wDb", "B4wExport", "Category", "CategoryDb"]
|
||||
|
||||
|
||||
class B4wExport(ExportBaseModel):
|
||||
id: int = Field(alias="Id1", description="internal B4w ID")
|
||||
@@ -56,10 +63,10 @@ class B4wExport(ExportBaseModel):
|
||||
"Notes": f"{self.notes}",
|
||||
}
|
||||
|
||||
@computed_field
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def hash_id(self) -> str:
|
||||
return _hash(self.id)
|
||||
return hashify(self.id)
|
||||
|
||||
@property
|
||||
def amount_signed(self) -> Decimal:
|
||||
@@ -87,65 +94,17 @@ class B4wExport(ExportBaseModel):
|
||||
return category
|
||||
|
||||
|
||||
class Category(ExportBaseModel):
|
||||
name: str = Field(pattern=r"[^:\r\n]+")
|
||||
parent: Category | None = None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def hash_id(self) -> str:
|
||||
return _hash(self.name, self.parent)
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, s: str) -> Category | None:
|
||||
if not s:
|
||||
return
|
||||
components = s.split(":")
|
||||
name = components[-1]
|
||||
rest = ":".join(components[:-1])
|
||||
return cls(name=name, parent=cls.from_str(rest))
|
||||
|
||||
def to_str(self) -> str | None:
|
||||
if not self.parent:
|
||||
return self.name
|
||||
else:
|
||||
return f"{self.parent.to_str()}:{self.name}"
|
||||
|
||||
def __str__(self):
|
||||
return self.to_str()
|
||||
|
||||
def __repr__(self):
|
||||
return f'Category.from_str("{self.__str__()}")'
|
||||
|
||||
|
||||
class CategoryDb(BaseModelDb):
|
||||
__tablename__ = "category"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("name", "parent_id", name="uq_category_name_parent"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String, nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("category.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
parent = relationship(
|
||||
"CategoryDb", remote_side="CategoryDb.id", back_populates="children"
|
||||
)
|
||||
children = relationship("CategoryDb", back_populates="parent")
|
||||
|
||||
UniqueConstraint("name", "parent_id", name="uq_category_name_parent")
|
||||
|
||||
|
||||
class B4wDb(IngestdbBase):
|
||||
__tablename__ = "import_b4w"
|
||||
|
||||
id_b4w = Column(Integer, nullable=False, unique=True)
|
||||
date_book = Column(Date, nullable=True)
|
||||
other_name = Column(String, nullable=True)
|
||||
other_iban = Column(String, nullable=True)
|
||||
category_id = Column(Integer, ForeignKey("category.id"), nullable=True)
|
||||
notes = Column(String, nullable=True)
|
||||
id_b4w: Mapped[int] = mapped_column(Integer, nullable=False, unique=True)
|
||||
date_book: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
other_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
other_iban: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("category.id"), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
# relationship
|
||||
category = relationship("CategoryDb")
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field, model_validator
|
||||
from sqlalchemy import (
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
String,
|
||||
TypeDecorator,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship
|
||||
|
||||
from src.db_utils import upsert
|
||||
from src.models.common import AmountPos, BaseModelDb, ExportBaseModel, hashify
|
||||
|
||||
|
||||
class UTCDateTime(TypeDecorator[datetime]):
|
||||
"""Store UTC timestamps and return timezone-aware UTC values on every dialect."""
|
||||
|
||||
impl = DateTime
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
return dialect.type_descriptor(DateTime(timezone=True))
|
||||
|
||||
def process_bind_param(self, value: datetime | None, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
def process_result_value(self, value: datetime | None, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
class Category(ExportBaseModel):
|
||||
name: str = Field(pattern=r"[^:\r\n]+")
|
||||
parent: Category | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def hash_id(self) -> str:
|
||||
return hashify(self.name, self.parent)
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, s: str) -> Category | None:
|
||||
if not s:
|
||||
return None
|
||||
components = s.split(":")
|
||||
name = components[-1]
|
||||
rest = ":".join(components[:-1])
|
||||
return cls(name=name, parent=cls.from_str(rest))
|
||||
|
||||
def to_str(self) -> str | None:
|
||||
if not self.parent:
|
||||
return self.name
|
||||
else:
|
||||
return f"{self.parent.to_str()}:{self.name}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.to_str() or ""
|
||||
|
||||
def __repr__(self):
|
||||
return f'Category.from_str("{self.__str__()}")'
|
||||
|
||||
def get_budget(self, date_valid: date | None) -> float:
|
||||
# TODO
|
||||
return 0.0
|
||||
|
||||
|
||||
class CategoryDb(BaseModelDb):
|
||||
__tablename__ = "category"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_category_active_name_parent",
|
||||
"name",
|
||||
"parent_id",
|
||||
unique=True,
|
||||
sqlite_where=text("valid_to IS NULL AND parent_id IS NOT NULL"),
|
||||
postgresql_where=text("valid_to IS NULL AND parent_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_category_active_root_name",
|
||||
"name",
|
||||
unique=True,
|
||||
sqlite_where=text("valid_to IS NULL AND parent_id IS NULL"),
|
||||
postgresql_where=text("valid_to IS NULL AND parent_id IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("category.id"), nullable=True
|
||||
)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime(),
|
||||
nullable=True,
|
||||
info={
|
||||
"description": "UTC start of validity; NULL means valid since the indefinite past."
|
||||
},
|
||||
)
|
||||
valid_to: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime(),
|
||||
nullable=True,
|
||||
info={
|
||||
"description": "UTC end of validity; NULL means the version is currently valid."
|
||||
},
|
||||
)
|
||||
last_action: Mapped[str] = mapped_column(
|
||||
String,
|
||||
nullable=False,
|
||||
default="created",
|
||||
server_default="created",
|
||||
info={
|
||||
"description": "Most recent lifecycle action recorded for this category version."
|
||||
},
|
||||
)
|
||||
|
||||
# Relationships
|
||||
parent = relationship(
|
||||
"CategoryDb", remote_side="CategoryDb.id", back_populates="children"
|
||||
)
|
||||
children = relationship("CategoryDb", back_populates="parent")
|
||||
budgets: Mapped[list[CategoryBudgetDb]] = relationship(back_populates="category")
|
||||
|
||||
|
||||
def upsert_category(
|
||||
session: Session, category: Category | None, do_commit: bool = True
|
||||
) -> CategoryDb | None:
|
||||
if category is None:
|
||||
return None
|
||||
|
||||
parent = upsert_category(session, category.parent, do_commit=False)
|
||||
parameters = {"name": category.name, "parent": parent}
|
||||
matching_rows = session.query(CategoryDb).filter_by(
|
||||
name=category.name, parent_id=parent.id if parent else None
|
||||
)
|
||||
active = matching_rows.filter(CategoryDb.valid_to.is_(None)).one_or_none()
|
||||
if active is not None:
|
||||
return active
|
||||
|
||||
historical = (
|
||||
matching_rows.filter(CategoryDb.valid_to.is_not(None))
|
||||
.order_by(CategoryDb.valid_to.desc())
|
||||
.first()
|
||||
)
|
||||
valid_from = None
|
||||
last_action = "created"
|
||||
if historical is not None:
|
||||
valid_from = datetime.now(UTC)
|
||||
if historical.valid_to is not None:
|
||||
previous_valid_to = historical.valid_to
|
||||
if (
|
||||
previous_valid_to.tzinfo is None
|
||||
or previous_valid_to.utcoffset() is None
|
||||
):
|
||||
previous_valid_to = previous_valid_to.replace(tzinfo=UTC)
|
||||
else:
|
||||
previous_valid_to = previous_valid_to.astimezone(UTC)
|
||||
valid_from = max(valid_from, previous_valid_to + timedelta(microseconds=1))
|
||||
last_action = "changed"
|
||||
|
||||
parameters_new = {
|
||||
**parameters,
|
||||
"valid_from": valid_from,
|
||||
"valid_to": None,
|
||||
"last_action": last_action,
|
||||
}
|
||||
return upsert(
|
||||
session,
|
||||
CategoryDb,
|
||||
{**parameters, "valid_to": None},
|
||||
parameters_new,
|
||||
do_update=False,
|
||||
do_commit=do_commit,
|
||||
)
|
||||
|
||||
|
||||
class CategoryBudget(BaseModel):
|
||||
category: Category
|
||||
amount_eur: AmountPos
|
||||
valid_from: date | None = Field(
|
||||
default=None, description="Start of validity. None means unbounded"
|
||||
)
|
||||
valid_to: date | None = Field(
|
||||
default=None, description="End of validity. None means unbounded"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_date_range(self) -> Self:
|
||||
if (
|
||||
self.valid_from is not None
|
||||
and self.valid_to is not None
|
||||
and self.valid_from > self.valid_to
|
||||
):
|
||||
raise ValueError(
|
||||
f"`valid_from` ({self.valid_from}) and valid_to ({self.valid_to}) are nor in order"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CategoryBudgetDb(BaseModelDb):
|
||||
"""Budget amount for a category, optionally bounded to a time window.
|
||||
|
||||
Satellite table to ``category``: one category may have several budgets
|
||||
(e.g. one per month). ``valid_from``/``valid_to`` are nullable; ``None``
|
||||
means unbounded on that side.
|
||||
"""
|
||||
|
||||
__tablename__ = "category_budget"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
category_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("category.id"), nullable=False, index=True
|
||||
)
|
||||
amount_eur: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
|
||||
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True, default=None)
|
||||
|
||||
# relationship
|
||||
category = relationship("CategoryDb", back_populates="budgets")
|
||||
@@ -0,0 +1,85 @@
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, computed_field
|
||||
|
||||
from src.models.category import Category
|
||||
from src.models.transactions import Transaction
|
||||
|
||||
|
||||
class CategoryTree(BaseModel):
|
||||
category: Category | None
|
||||
sub_categories: list[CategoryTree] | None = None
|
||||
transactions: list[Transaction] | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def total_amount(self) -> float:
|
||||
result = 0.0
|
||||
if self.transactions:
|
||||
result += sum(
|
||||
float(transaction.amount) for transaction in self.transactions
|
||||
)
|
||||
if self.sub_categories:
|
||||
result += sum(
|
||||
float(sub_category.total_amount) for sub_category in self.sub_categories
|
||||
)
|
||||
return result
|
||||
|
||||
def total_budget(self, date_valid: date) -> float:
|
||||
result = 0.0
|
||||
if self.category is None:
|
||||
return result
|
||||
result += self.category.get_budget(date_valid)
|
||||
if self.sub_categories:
|
||||
result += sum(
|
||||
sub_category.total_budget(date_valid)
|
||||
for sub_category in self.sub_categories
|
||||
)
|
||||
return result
|
||||
|
||||
def __add__(self, other: CategoryTree):
|
||||
if self.category != other.category:
|
||||
return
|
||||
|
||||
sub_trees = []
|
||||
seen = []
|
||||
for sub_tree_1 in self.sub_categories or []:
|
||||
new = sub_tree_1
|
||||
for sub_tree_2 in other.sub_categories or []:
|
||||
if sub_tree_1.category == sub_tree_2.category:
|
||||
new += sub_tree_2
|
||||
seen.append(sub_tree_2)
|
||||
break
|
||||
sub_trees.append(new)
|
||||
sub_trees += [
|
||||
sub_tree
|
||||
for sub_tree in (other.sub_categories or [])
|
||||
if sub_tree not in seen
|
||||
]
|
||||
|
||||
transactions = ((self.transactions or []) + (other.transactions or [])) or None
|
||||
|
||||
return CategoryTree(
|
||||
category=self.category,
|
||||
sub_categories=list(sub_trees),
|
||||
transactions=transactions,
|
||||
)
|
||||
|
||||
|
||||
def build_category_tree(transactions: list[Transaction]) -> list[CategoryTree]:
|
||||
results: dict[str, CategoryTree] = {}
|
||||
for branch in map(_transaction_to_category_branch, transactions):
|
||||
key = str(branch.category) if branch.category else ""
|
||||
results[key] = (results[key] + branch) if key in results else branch
|
||||
return list(results.values())
|
||||
|
||||
|
||||
def _transaction_to_category_branch(transaction: Transaction) -> CategoryTree:
|
||||
category = transaction.category
|
||||
result = CategoryTree(category=category, transactions=[transaction])
|
||||
if not category:
|
||||
return result
|
||||
while category.parent is not None:
|
||||
category = category.parent
|
||||
result = CategoryTree(category=category, sub_categories=[result])
|
||||
return result
|
||||
+9
-11
@@ -2,16 +2,17 @@ from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import ConfigDict, Field, computed_field, field_validator
|
||||
from sqlalchemy import Column, Date, String
|
||||
from sqlalchemy import Date, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.models._common import (
|
||||
from src.models.common import (
|
||||
ExportBaseModel,
|
||||
IngestdbBase,
|
||||
_hash,
|
||||
_parse_date_german,
|
||||
_parse_decimal_german,
|
||||
date2str,
|
||||
decimal2str,
|
||||
hashify,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,7 +27,7 @@ class ComdirectBase(ExportBaseModel):
|
||||
@classmethod
|
||||
def parse_date_val(cls, date_str: str | None) -> date | None:
|
||||
if date_str is None:
|
||||
return
|
||||
return None
|
||||
return _parse_date_german(date_str)
|
||||
|
||||
@field_validator("amount_eur", mode="before")
|
||||
@@ -68,10 +69,10 @@ class ComdirectGiro(ComdirectBase):
|
||||
)
|
||||
text: str = Field(alias="Buchungstext")
|
||||
|
||||
@computed_field
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def hash_id(self) -> str:
|
||||
return _hash(self.date_val, self.date_book, self.text)
|
||||
return hashify(self.date_val, self.date_book, self.text)
|
||||
|
||||
@field_validator("date_book", mode="before")
|
||||
@classmethod
|
||||
@@ -103,9 +104,6 @@ class ComdirectCreditCard(ComdirectGiro):
|
||||
)
|
||||
reference: str = Field(alias="Referenz", default="")
|
||||
text: str = Field(alias="Buchungstext", default="")
|
||||
amount_eur: Decimal | None = Field(
|
||||
alias="Umsatz in EUR", examples=["32,43"], default=None
|
||||
)
|
||||
|
||||
def parameters_to_dict(self, by_name: bool = True) -> dict[str, str]:
|
||||
parameters = dict(**super().parameters_to_dict(by_name=by_name))
|
||||
@@ -155,5 +153,5 @@ class ComdirectBaseDb(IngestdbBase):
|
||||
class ComdirectGiroDb(ComdirectBaseDb):
|
||||
__tablename__ = "comdirect_giro"
|
||||
|
||||
type = Column(String, nullable=True)
|
||||
date_book = Column(Date, nullable=True)
|
||||
type: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
date_book: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
|
||||
@@ -2,13 +2,14 @@ import hashlib
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Column, Date, Integer, Numeric, String
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Date, Numeric, String
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def _hash(*args):
|
||||
def hashify(*args):
|
||||
raw = "".join(str(args))
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
@@ -40,11 +41,15 @@ def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str:
|
||||
return default
|
||||
|
||||
|
||||
def _parse_decimal_german(decimal_str: str) -> Decimal:
|
||||
type AmountPos = Annotated[Decimal, Field(decimal_places=2, gt=0)]
|
||||
type Amount = Annotated[Decimal, Field(decimal_places=2)]
|
||||
|
||||
|
||||
def _parse_decimal_german(decimal_str: str) -> Amount:
|
||||
return Decimal(decimal_str.replace(".", "").replace(",", "."))
|
||||
|
||||
|
||||
def decimal2str(value: Decimal) -> str:
|
||||
def decimal2str(value: Amount) -> str:
|
||||
value = Decimal(f"{value:.2f}")
|
||||
int_part, _, frac = str(value).partition(".")
|
||||
groups: list[str] = []
|
||||
@@ -89,11 +94,11 @@ class BaseModelDb(DeclarativeBase):
|
||||
class IngestdbBase(BaseModelDb):
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
hash_id = Column(String, nullable=False)
|
||||
account_iban = Column(String, nullable=False)
|
||||
account_currency = Column(String(3), nullable=False)
|
||||
date_valid = Column(Date, nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
amount_currency = Column(String(3), nullable=False)
|
||||
text = Column(String, nullable=True)
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
hash_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
account_iban: Mapped[str] = mapped_column(String, nullable=False)
|
||||
account_currency: Mapped[str] = mapped_column(String(3), nullable=False)
|
||||
date_valid: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
amount_currency: Mapped[str] = mapped_column(String(3), nullable=False)
|
||||
text: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
@@ -0,0 +1,78 @@
|
||||
from datetime import date
|
||||
|
||||
from pydantic import Field, computed_field
|
||||
from sqlalchemy import Date, ForeignKey, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.category import Category
|
||||
from src.models.common import Amount, ExportBaseModel, IngestdbBase, date2str, hashify
|
||||
|
||||
|
||||
class Transaction(ExportBaseModel):
|
||||
"""Canonical, source-agnostic representation of a bank transaction.
|
||||
|
||||
Both input sources (b4w, comdirect giro) are projected into this model.
|
||||
The deduplication key is a business-key hash of the transaction
|
||||
attributes, so re-importing an export is idempotent even if
|
||||
source-internal ids change between exports.
|
||||
"""
|
||||
|
||||
source: str = Field(description="Source name", examples=["b4w", "comdirect_giro"])
|
||||
source_row_id: int = Field(description="Primary key of the staging table row")
|
||||
account_iban: str
|
||||
account_currency: str
|
||||
date_valid: date
|
||||
amount: Amount
|
||||
amount_currency: str
|
||||
date_book: date | None = None
|
||||
text: str | None = None
|
||||
other_name: str | None = None
|
||||
other_iban: str | None = None
|
||||
category: Category | None = None
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def hash_id(self) -> str:
|
||||
return hashify(
|
||||
self.source,
|
||||
self.account_iban,
|
||||
self.date_valid,
|
||||
f"{self.amount:.2f}",
|
||||
self.text,
|
||||
self.other_name,
|
||||
)
|
||||
|
||||
def parameters_to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"source": f"{self.source}",
|
||||
"source_row_id": f"{self.source_row_id}",
|
||||
"account_iban": f"{self.account_iban}",
|
||||
"account_currency": f"{self.account_currency}",
|
||||
"date_valid": date2str(self.date_valid),
|
||||
"amount": f"{self.amount}",
|
||||
"amount_currency": f"{self.amount_currency}",
|
||||
"date_book": date2str(self.date_book),
|
||||
"text": f"{self.text or ''}",
|
||||
"other_name": f"{self.other_name or ''}",
|
||||
"other_iban": f"{self.other_iban or ''}",
|
||||
"category": (self.category.to_str() if self.category else "") or "",
|
||||
}
|
||||
|
||||
|
||||
class TransactionDb(IngestdbBase):
|
||||
__tablename__ = "transactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("hash_id", name="uq_transactions_hash"),
|
||||
Index("ix_transactions_source_row_id", "source", "source_row_id"),
|
||||
)
|
||||
|
||||
source: Mapped[str] = mapped_column(String, nullable=False)
|
||||
source_row_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
date_book: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
other_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
other_iban: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("category.id"), nullable=True
|
||||
)
|
||||
|
||||
category = relationship("CategoryDb")
|
||||
+87
-2
@@ -1,15 +1,23 @@
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Callable, Generator
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite://"
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import StaticPool, create_engine
|
||||
from sqlalchemy import Engine, StaticPool, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from alembic import command
|
||||
from src.db import set_engine
|
||||
from src.ingest.b4w import upsert_b4w
|
||||
from tests.factories import B4wExportFactory
|
||||
|
||||
DEFAULT_B4W_ROWS = 10
|
||||
# ``*.db`` and ``.local`` are gitignored, so artifacts are never committed.
|
||||
TEST_DB_DIR = Path(os.environ.get("TEST_DB_DIR", ".local/test-dbs"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -32,3 +40,80 @@ def db_session() -> Generator[Session]:
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session_factory() -> Generator[Callable[[str], Session]]:
|
||||
"""Create named, file-backed SQLite databases that survive the test run.
|
||||
|
||||
Each call recreates ``<TEST_DB_DIR>/<name>.db`` from scratch (overwriting
|
||||
the artifact of the previous run), runs the migrations and returns a
|
||||
session. The files are intentionally *not* removed on teardown, so the last
|
||||
created instance can be inspected afterwards, e.g.::
|
||||
|
||||
sqlite3 .local/test-dbs/b4w_basic.db
|
||||
"""
|
||||
engines: list[Engine] = []
|
||||
|
||||
def make(name: str) -> Session:
|
||||
TEST_DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
db_path = (TEST_DB_DIR / f"{name}.db").resolve()
|
||||
# Start from a clean database: overwrite whatever the last run left.
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
Path(f"{db_path}{suffix}").unlink(missing_ok=True)
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
engines.append(engine)
|
||||
set_engine(engine) # alembic/env.py picks this up via get_set_engine()
|
||||
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
try:
|
||||
yield make
|
||||
finally:
|
||||
for engine in engines:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def populated_db_b4w(
|
||||
db_session_factory: Callable[[str], Session],
|
||||
) -> Callable[..., Session]:
|
||||
"""Populate ``import_b4w`` (and ``category``) in a named, persisted DB.
|
||||
|
||||
Only the first-layer ingestion table is filled; no further processing.
|
||||
``**kwargs`` are forwarded to ``B4wExportFactory.build``; callable values
|
||||
are resolved per row (e.g. ``ValDt=lambda: faker.date_between(...)``).
|
||||
An optional ``setup`` callback runs after database creation and before rows
|
||||
are inserted.
|
||||
``Id1`` is reserved: it is set per row (``1..row_count``) and cannot be
|
||||
overridden via ``**kwargs``.
|
||||
The returned session is *not* closed by the fixture; close it in the test.
|
||||
"""
|
||||
|
||||
def populate_db_b4w(
|
||||
name: str,
|
||||
row_count: int = DEFAULT_B4W_ROWS,
|
||||
*,
|
||||
setup: Callable[[Session], None] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Session:
|
||||
if "Id1" in kwargs:
|
||||
raise ValueError(
|
||||
"Id1 is set per row (1..row_count) by populated_db_b4w and "
|
||||
"cannot be overridden via **kwargs"
|
||||
)
|
||||
session = db_session_factory(name)
|
||||
if setup is not None:
|
||||
setup(session)
|
||||
for i in range(row_count):
|
||||
row_kwargs = {k: (v() if callable(v) else v) for k, v in kwargs.items()}
|
||||
row = B4wExportFactory.build(Id1=i + 1, **cast(dict[str, Any], row_kwargs))
|
||||
upsert_b4w(session, row, do_commit=False)
|
||||
session.commit()
|
||||
return session
|
||||
|
||||
return populate_db_b4w
|
||||
|
||||
+42
-1
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from faker import Faker
|
||||
@@ -6,9 +7,39 @@ from polyfactory.factories.pydantic_factory import ModelFactory
|
||||
from polyfactory.fields import Use
|
||||
|
||||
from src.models.b4w import B4wExport
|
||||
from src.models.category import Category
|
||||
from src.models.comdirect import ComdirectCreditCard, ComdirectDepot, ComdirectGiro
|
||||
from src.models.transactions import Transaction
|
||||
|
||||
DEFAULT_CATEGORY = "root_category:parent_category:leaf_category"
|
||||
CATEGORY_PATHS = tuple(
|
||||
line.strip()
|
||||
for line in (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "scripts/initial_load/categories/categories.txt"
|
||||
)
|
||||
.read_text(encoding="utf-8")
|
||||
.splitlines()
|
||||
if line.strip()
|
||||
)
|
||||
|
||||
|
||||
def _category_from_path(path: str) -> Category:
|
||||
category = Category.from_str(path)
|
||||
if category is None:
|
||||
raise ValueError("Category list contains an empty path")
|
||||
return category
|
||||
|
||||
|
||||
class CategoryFactory:
|
||||
__faker__ = Faker("de_DE")
|
||||
|
||||
@classmethod
|
||||
def build(cls) -> Category:
|
||||
return _category_from_path(cls.__faker__.random_element(CATEGORY_PATHS))
|
||||
|
||||
@staticmethod
|
||||
def build_all() -> list[Category]:
|
||||
return [_category_from_path(path) for path in CATEGORY_PATHS]
|
||||
|
||||
|
||||
def _faker_or_none(faker: Faker, non_default: Any, default: Any = None, p_value=0.5):
|
||||
@@ -98,3 +129,13 @@ class ComdirectDepotFactory(ModelFactory[ComdirectDepot]):
|
||||
date_val = Use(_german_date)
|
||||
num = Use(_amount_eur)
|
||||
spot_price = Use(_amount_eur)
|
||||
|
||||
|
||||
class TransactionFactory(ModelFactory[Transaction]):
|
||||
__faker__ = Faker("de_DE")
|
||||
__use_examples__ = True
|
||||
|
||||
account_iban = Use(_iban)
|
||||
text = Use(_sentence)
|
||||
other_name = Use(_names)
|
||||
other_iban = Use(_iban_or_none)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.category import CategoryBudgetDb, CategoryDb
|
||||
from tests.factories import CategoryFactory
|
||||
|
||||
|
||||
def test_category_budget_schema(db_session: Session):
|
||||
inspector = inspect(db_session.get_bind())
|
||||
columns = {c["name"]: c for c in inspector.get_columns("category_budget")}
|
||||
|
||||
assert set(columns) == {
|
||||
"id",
|
||||
"category_id",
|
||||
"amount_eur",
|
||||
"valid_from",
|
||||
"valid_to",
|
||||
}
|
||||
assert columns["id"]["nullable"] is False
|
||||
assert columns["category_id"]["nullable"] is False
|
||||
assert columns["amount_eur"]["nullable"] is False
|
||||
assert columns["valid_from"]["nullable"] is True
|
||||
assert columns["valid_to"]["nullable"] is True
|
||||
assert str(columns["amount_eur"]["type"]) == "NUMERIC(12, 2)"
|
||||
|
||||
foreign_keys = inspector.get_foreign_keys("category_budget")
|
||||
assert len(foreign_keys) == 1
|
||||
assert foreign_keys[0]["constrained_columns"] == ["category_id"]
|
||||
assert foreign_keys[0]["referred_table"] == "category"
|
||||
|
||||
|
||||
def test_category_budget_defaults_null_dates(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budget = CategoryBudgetDb(category_id=category.id, amount_eur=Decimal("150.00"))
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
db_session.refresh(budget)
|
||||
|
||||
assert budget.id is not None
|
||||
assert budget.category_id == category.id
|
||||
assert budget.amount_eur == Decimal("150.00")
|
||||
# Null means unbounded in either direction.
|
||||
assert budget.valid_from is None
|
||||
assert budget.valid_to is None
|
||||
|
||||
|
||||
def test_category_budget_date_window(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budget = CategoryBudgetDb(
|
||||
category_id=category.id,
|
||||
amount_eur=Decimal("200.50"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
db_session.refresh(budget)
|
||||
|
||||
assert budget.valid_from == date(2026, 10, 1)
|
||||
assert budget.valid_to == date(2026, 10, 31)
|
||||
|
||||
|
||||
def test_category_budget_relationship_both_ways(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budget = CategoryBudgetDb(category_id=category.id, amount_eur=Decimal("1.00"))
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
|
||||
# Force re-read from the database so the relationship is loaded, not
|
||||
# just resolved from the session identity map.
|
||||
db_session.expire_all()
|
||||
assert [b.id for b in category.budgets] == [budget.id]
|
||||
assert budget.category.id == category.id
|
||||
|
||||
|
||||
def test_category_has_multiple_budgets(db_session: Session):
|
||||
category = CategoryDb(name=CategoryFactory.build().name)
|
||||
db_session.add(category)
|
||||
db_session.flush()
|
||||
|
||||
budgets = [
|
||||
CategoryBudgetDb(
|
||||
category_id=category.id,
|
||||
amount_eur=Decimal("1200.00"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
),
|
||||
CategoryBudgetDb(
|
||||
category_id=category.id,
|
||||
amount_eur=Decimal("1300.00"),
|
||||
valid_from=date(2026, 11, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
),
|
||||
]
|
||||
for budget in budgets:
|
||||
db_session.add(budget)
|
||||
db_session.commit()
|
||||
db_session.expire_all()
|
||||
|
||||
assert [b.id for b in category.budgets] == [b.id for b in budgets]
|
||||
@@ -0,0 +1,164 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models.category import Category, CategoryBudget
|
||||
from tests.factories import CategoryFactory
|
||||
|
||||
|
||||
class TestCategoryComputedFields:
|
||||
"""Tests for Category's computed fields and special methods."""
|
||||
|
||||
def test_hash_id_computed_field_simple(self):
|
||||
"""Test hash_id for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert hasattr(c, "hash_id")
|
||||
assert isinstance(c.hash_id, str)
|
||||
# Hash should be deterministic for same inputs
|
||||
c2 = Category.from_str("single")
|
||||
assert c2
|
||||
assert c.hash_id == c2.hash_id
|
||||
|
||||
def test_hash_id_computed_field_nested(self):
|
||||
"""Test hash_id for multi-level category hierarchy."""
|
||||
c1 = Category.from_str("level1:level2:level3")
|
||||
assert c1
|
||||
assert hasattr(c1, "hash_id")
|
||||
|
||||
c2 = Category.from_str("level1:level2:level3")
|
||||
assert c2
|
||||
assert c1.hash_id == c2.hash_id
|
||||
|
||||
def test_hash_id_different_hierarchy(self):
|
||||
"""Test that different hierarchies have different hash_ids."""
|
||||
c1 = Category.from_str("A:B")
|
||||
c2 = Category.from_str("A:C")
|
||||
assert c1
|
||||
assert c2
|
||||
# Different last level -> different hash_id
|
||||
assert c1.hash_id != c2.hash_id
|
||||
|
||||
def test_hash_id_same_name_different_parent(self):
|
||||
"""Test that same name but different parent gives different hash."""
|
||||
root = Category(name="Budget")
|
||||
cat1 = Category(name="Budget", parent=root)
|
||||
|
||||
# Create another with same name but no parent
|
||||
orphan = Category.from_str("Budget")
|
||||
assert orphan
|
||||
|
||||
assert cat1.hash_id != orphan.hash_id
|
||||
|
||||
def test_to_str_single_level(self):
|
||||
"""Test to_str() for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert c.to_str() == "single"
|
||||
|
||||
def test_to_str_nested(self):
|
||||
"""Test to_str() for nested category."""
|
||||
c = Category.from_str("parent:child:grandchild")
|
||||
assert c
|
||||
assert c.to_str() == "parent:child:grandchild"
|
||||
|
||||
def test_str_uses_to_str(self):
|
||||
"""Test that __str__ delegates to to_str()."""
|
||||
c = Category.from_str("A:B:C")
|
||||
assert c
|
||||
assert str(c) == c.to_str() == "A:B:C"
|
||||
|
||||
def test_repr_simple(self):
|
||||
"""Test repr() for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
expected = 'Category.from_str("single")'
|
||||
assert repr(c) == expected
|
||||
|
||||
def test_repr_nested(self):
|
||||
"""Test repr() for nested category."""
|
||||
c = Category.from_str("parent:child")
|
||||
expected = 'Category.from_str("parent:child")'
|
||||
assert repr(c) == expected
|
||||
|
||||
|
||||
class TestCategoryBudgetValidation:
|
||||
"""Tests for CategoryBudget's date range validation."""
|
||||
|
||||
def test_valid_budget_no_dates(self):
|
||||
"""Budget with None dates is valid."""
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=Decimal("100.00"),
|
||||
)
|
||||
assert budget.valid_from is None
|
||||
assert budget.valid_to is None
|
||||
|
||||
def test_valid_budget_with_dates(self):
|
||||
"""Budget with valid date range is accepted."""
|
||||
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=Decimal("100.00"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
assert budget.valid_from == date(2026, 10, 1)
|
||||
assert budget.valid_to == date(2026, 10, 31)
|
||||
|
||||
def test_invalid_date_range_raises(self):
|
||||
"""Budget with valid_from > valid_to raises ValueError."""
|
||||
|
||||
cat = CategoryFactory.build()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=Decimal("100.00"),
|
||||
valid_from=date(2026, 10, 31),
|
||||
valid_to=date(2026, 10, 1),
|
||||
)
|
||||
|
||||
assert "valid_from" in str(exc_info.value)
|
||||
assert "valid_to" in str(exc_info.value)
|
||||
assert "nor in order" in str(exc_info.value)
|
||||
|
||||
def test_valid_budget_exact_boundary(self):
|
||||
"""Budget with exact same from and to is valid (single day)."""
|
||||
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=Decimal("100.00"),
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 1),
|
||||
)
|
||||
assert budget.valid_from == date(2026, 10, 1)
|
||||
assert budget.valid_to == date(2026, 10, 1)
|
||||
|
||||
def test_validation_preserves_original_values(self):
|
||||
"""Validation doesn't change the model's original values."""
|
||||
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=Decimal("150.75"),
|
||||
valid_from=date(2026, 9, 1),
|
||||
valid_to=date(2026, 9, 30),
|
||||
)
|
||||
|
||||
# Create an invalid one (valid_from > valid_to) and verify validation catches it
|
||||
try:
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=Decimal("200.00"),
|
||||
valid_from=date(2026, 12, 31),
|
||||
valid_to=date(2026, 12, 30),
|
||||
)
|
||||
except ValueError:
|
||||
pass # Expected
|
||||
|
||||
# Valid one should still have original values
|
||||
assert budget.amount_eur == 150.75
|
||||
@@ -0,0 +1,116 @@
|
||||
from src.models.category import Category
|
||||
from src.models.category_tree import (
|
||||
CategoryTree,
|
||||
_transaction_to_category_branch,
|
||||
build_category_tree,
|
||||
)
|
||||
from src.models.transactions import Transaction
|
||||
from tests.factories import TransactionFactory
|
||||
|
||||
|
||||
def test_transaction_to_category_branch_simple():
|
||||
transaction = TransactionFactory.build(category=Category.from_str("dummy category"))
|
||||
result = _transaction_to_category_branch(transaction)
|
||||
assert isinstance(result, CategoryTree)
|
||||
assert result.category
|
||||
assert result.category.name == "dummy category"
|
||||
assert result.transactions
|
||||
assert isinstance(result.transactions, list)
|
||||
assert len(result.transactions) == 1
|
||||
assert isinstance(result.transactions[0], Transaction)
|
||||
|
||||
|
||||
def test_transaction_to_category_branch_two_category_levels():
|
||||
transaction = TransactionFactory.build(category=Category.from_str("father:son"))
|
||||
result = _transaction_to_category_branch(transaction)
|
||||
assert isinstance(result, CategoryTree)
|
||||
assert result.category
|
||||
assert result.category.name == "father"
|
||||
assert result.transactions is None
|
||||
assert result.sub_categories
|
||||
assert isinstance(result.sub_categories, list)
|
||||
assert len(result.sub_categories) == 1
|
||||
assert isinstance(result.sub_categories[0], CategoryTree)
|
||||
assert isinstance(result.sub_categories[0].category, Category)
|
||||
assert result.sub_categories[0].category.name == "son"
|
||||
assert result.sub_categories[0].transactions
|
||||
assert isinstance(result.sub_categories[0].transactions, list)
|
||||
assert len(result.sub_categories[0].transactions) == 1
|
||||
assert isinstance(result.sub_categories[0].transactions[0], Transaction)
|
||||
assert result.sub_categories[0].transactions[0] == transaction
|
||||
|
||||
|
||||
def test_transaction_to_category_branch_no_category():
|
||||
transaction = TransactionFactory.build(category=None)
|
||||
result = _transaction_to_category_branch(transaction)
|
||||
assert result.category is None
|
||||
assert result.transactions
|
||||
assert isinstance(result.transactions, list)
|
||||
assert len(result.transactions) == 1
|
||||
assert result.transactions[0] == transaction
|
||||
|
||||
|
||||
def test_category_tree_add():
|
||||
category_tree_1 = CategoryTree(
|
||||
category=Category.from_str("super"),
|
||||
sub_categories=[
|
||||
CategoryTree(
|
||||
category=Category.from_str("super:sub1"),
|
||||
transactions=[TransactionFactory.build(amount=10)],
|
||||
),
|
||||
CategoryTree(
|
||||
category=Category.from_str("super:sub2"),
|
||||
transactions=[TransactionFactory.build(amount=30)],
|
||||
),
|
||||
],
|
||||
)
|
||||
category_tree_2 = CategoryTree(
|
||||
category=Category.from_str("super"),
|
||||
sub_categories=[
|
||||
CategoryTree(
|
||||
category=Category.from_str("super:sub2"),
|
||||
transactions=[TransactionFactory.build(amount=20)],
|
||||
),
|
||||
CategoryTree(
|
||||
category=Category.from_str("super:sub3"),
|
||||
transactions=[TransactionFactory.build(amount=100)],
|
||||
),
|
||||
],
|
||||
)
|
||||
result = category_tree_1 + category_tree_2
|
||||
assert result
|
||||
assert result.sub_categories
|
||||
assert isinstance(result.sub_categories, list)
|
||||
assert len(result.sub_categories) == 3
|
||||
assert all(s.category for s in result.sub_categories)
|
||||
assert sorted([s.category.name for s in result.sub_categories]) == [
|
||||
"sub1",
|
||||
"sub2",
|
||||
"sub3",
|
||||
]
|
||||
assert result.total_amount == 160.0
|
||||
|
||||
|
||||
def test_build_category_tree():
|
||||
transactions = [
|
||||
TransactionFactory.build(category=Category.from_str("super1:sub1"), amount=10),
|
||||
TransactionFactory.build(category=Category.from_str("super1:sub2"), amount=13),
|
||||
TransactionFactory.build(
|
||||
category=Category.from_str("super1:sub2:sub2_sub1"), amount=104
|
||||
),
|
||||
TransactionFactory.build(
|
||||
category=Category.from_str("super1:sub2:sub2_sub1"), amount=109
|
||||
),
|
||||
TransactionFactory.build(category=Category.from_str("super2"), amount=5000),
|
||||
]
|
||||
result = build_category_tree(transactions)
|
||||
assert result
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[0], CategoryTree)
|
||||
assert result[0].category
|
||||
assert result[0].category.name == "super1"
|
||||
assert result[0].transactions is None
|
||||
assert result[0].sub_categories
|
||||
assert isinstance(result[0].sub_categories, list)
|
||||
assert len(result[0].sub_categories) == 2
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests for db_utils.py update path in upsert function.
|
||||
|
||||
The upsert function has two paths:
|
||||
1. Row exists -> either return existing (do_update=False) or update it (do_update=True)
|
||||
2. Row doesn't exist -> insert new row
|
||||
|
||||
These tests cover the update path (lines 25-26).
|
||||
|
||||
NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.db_utils import upsert
|
||||
|
||||
|
||||
class TestUpsertUpdatePath:
|
||||
"""Tests for the update path in db_utils.upsert."""
|
||||
|
||||
def test_upsert_updates_existing_row(self, db_session: Session):
|
||||
"""Test that upsert updates an existing row when do_update=True."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="initial", value="old_value")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Verify it was inserted
|
||||
result = db_session.query(TestModel).filter_by(name="initial").one()
|
||||
assert result.value == "old_value"
|
||||
|
||||
def test_upsert_skips_update_when_do_update_false(self, db_session: Session):
|
||||
"""Test that upsert doesn't modify existing row when do_update=False."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_2"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="skip_update", value="original")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Upsert with do_update=False should not modify existing row
|
||||
same_row = TestModel(name="skip_update", value="should_not_change")
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "skip_update"},
|
||||
same_row.__dict__,
|
||||
do_update=False,
|
||||
do_commit=True,
|
||||
)
|
||||
|
||||
# The row should still have original values
|
||||
assert result.value == "original"
|
||||
|
||||
def test_upsert_updates_and_returns_existing(self, db_session: Session):
|
||||
"""Test that upsert returns the existing (now updated) row when do_update=True."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_3"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="update_test", value="old")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Upsert with do_update=True should update and return the same object
|
||||
new_values = TestModel(name="update_test", value="new_value")
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "update_test"},
|
||||
new_values.__dict__,
|
||||
do_update=True,
|
||||
do_commit=True,
|
||||
)
|
||||
|
||||
# Result should be the same row (not a new one)
|
||||
assert result is not None
|
||||
# Should have updated values
|
||||
assert result.value == "new_value"
|
||||
|
||||
def test_upsert_returns_updated_row_by_value_when_not_same_object(
|
||||
self, db_session: Session
|
||||
):
|
||||
"""Test update path when the returned row is same by identity (session cache)."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_4"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="identity_test", value="initial")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Get the row from DB again (fresh load)
|
||||
before_update = (
|
||||
db_session.query(TestModel).filter_by(name="identity_test").one()
|
||||
)
|
||||
|
||||
# Now upsert should return same object due to session cache + identity map
|
||||
new_values = TestModel(name="identity_test", value="updated")
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "identity_test"},
|
||||
new_values.__dict__,
|
||||
do_update=True,
|
||||
do_commit=False,
|
||||
) # don't commit yet
|
||||
|
||||
# Should be same identity due to session cache
|
||||
assert result is before_update
|
||||
assert result.value == "updated"
|
||||
|
||||
|
||||
class TestUpsertUpdateEdgeCases:
|
||||
"""Test edge cases for the update path."""
|
||||
|
||||
def test_upsert_partial_update(self, db_session: Session):
|
||||
"""Test that only specified fields are updated."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_5"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
extra: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row with all fields
|
||||
initial = TestModel(
|
||||
name="partial", value="original_value", extra="extra_original"
|
||||
)
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Create new values with only some fields set (note: we skip 'extra' field)
|
||||
partial_update = TestModel(name="partial", value="new_value", extra=None)
|
||||
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "partial"},
|
||||
partial_update.__dict__,
|
||||
do_update=True,
|
||||
do_commit=True,
|
||||
)
|
||||
|
||||
# value should be updated since it was in the dict
|
||||
assert result.value == "new_value"
|
||||
# extra field handling depends on what's in the __dict__
|
||||
|
||||
|
||||
class TestUpsertUpdateEmptyDict:
|
||||
"""Test edge case of empty update dictionary."""
|
||||
|
||||
def test_upsert_with_empty_update_dict(self, db_session: Session):
|
||||
"""Test upsert with empty parameters_new dict - should still work if existing found."""
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TestModel(Base):
|
||||
__tablename__ = "test_model_6"
|
||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name: str = Column(String(100), nullable=False)
|
||||
value: str = Column(String(50), nullable=False)
|
||||
|
||||
Base.metadata.create_all(db_session.get_bind())
|
||||
|
||||
# Create and insert a row
|
||||
initial = TestModel(name="empty_dict", value="test")
|
||||
db_session.add(initial)
|
||||
db_session.commit()
|
||||
|
||||
# Try to upsert with empty new parameters (edge case)
|
||||
result = upsert(
|
||||
db_session,
|
||||
TestModel,
|
||||
{"name": "empty_dict"},
|
||||
{},
|
||||
do_update=True,
|
||||
do_commit=False,
|
||||
)
|
||||
|
||||
# Should return the existing row unchanged
|
||||
assert result is not None
|
||||
assert result.name == "empty_dict"
|
||||
@@ -0,0 +1,736 @@
|
||||
"""Comprehensive tests for set_category_budget overlap handling."""
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.domain_utils import find_category_db, set_category_budget
|
||||
from src.models import Category, CategoryBudget, CategoryBudgetDb
|
||||
from src.models.category import CategoryDb, upsert_category
|
||||
from tests.factories import CategoryFactory
|
||||
|
||||
# NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes.
|
||||
|
||||
|
||||
class TestSetCategoryBudgetNoOverlap:
|
||||
"""Test budgets that don't overlap with existing ones."""
|
||||
|
||||
def test_no_overlap_add_new(self, db_session: Session):
|
||||
"""When new budget has no overlap with existing, add both."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# First budget: Oct 1 - Nov 30
|
||||
budget1 = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, budget1)
|
||||
|
||||
# Second budget: Dec 1 - Dec 31 (no overlap with Oct-Nov range)
|
||||
budget2 = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.00,
|
||||
valid_from=date(2026, 12, 1),
|
||||
valid_to=date(2026, 12, 31),
|
||||
)
|
||||
set_category_budget(db_session, budget2)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 2
|
||||
|
||||
def test_completely_before_no_overlap(self, db_session: Session):
|
||||
"""New budget completely before existing - should add both."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# Existing budget covering Oct 15 - Dec 30
|
||||
existing_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 15),
|
||||
valid_to=date(2026, 12, 30),
|
||||
)
|
||||
set_category_budget(db_session, existing_budget)
|
||||
|
||||
# New budget covering Sep 1 - Sep 30 (completely before)
|
||||
new_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=50.00,
|
||||
valid_from=date(2026, 9, 1),
|
||||
valid_to=date(2026, 9, 30),
|
||||
)
|
||||
set_category_budget(db_session, new_budget)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 2
|
||||
|
||||
|
||||
class TestSetCategoryBudgetFullOverlap:
|
||||
"""Test when new budget fully overlaps existing."""
|
||||
|
||||
def test_full_overlap_replace(self, db_session: Session):
|
||||
"""When new budget fully overlaps existing, replace entirely."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# Existing budget covering Oct 1 - Nov 30
|
||||
existing_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, existing_budget)
|
||||
|
||||
# New budget covering Oct 1 - Nov 30 (same range, different amount)
|
||||
new_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=200.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, new_budget)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
# Should have only 1 budget with the new amount (replaced old one)
|
||||
assert len(budgets) == 1
|
||||
assert budgets[0].amount_eur == 200.00
|
||||
|
||||
|
||||
class TestSetCategoryBudgetPartialOverlapStart:
|
||||
"""Test partial overlap where new budget overlaps at start."""
|
||||
|
||||
def test_partial_overlap_at_start(self, db_session: Session):
|
||||
"""When new budget overlaps existing from the start, keep end and cut before."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# Existing budget covering Oct 1 - Nov 30
|
||||
existing_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, existing_budget)
|
||||
|
||||
# New budget covering Oct 1 - Nov 15 (overlaps from start)
|
||||
new_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=120.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 15),
|
||||
)
|
||||
set_category_budget(db_session, new_budget)
|
||||
|
||||
# Should have 2 budgets:
|
||||
# 1. Oct 1 - Nov 15 (new budget)
|
||||
# 2. Dec 1 - Dec 30 (remainder from old budget, after overlap end + 1 day)
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 2
|
||||
|
||||
|
||||
class TestSetCategoryBudgetPartialOverlapEnd:
|
||||
"""Test partial overlap where new budget overlaps at end."""
|
||||
|
||||
def test_partial_overlap_at_end(self, db_session: Session):
|
||||
"""When new budget overlaps existing at the end, keep start and cut after."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# Existing budget covering Oct 1 - Nov 30
|
||||
existing_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, existing_budget)
|
||||
|
||||
# New budget covering Oct 15 - Nov 30 (overlaps at end)
|
||||
new_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=120.00,
|
||||
valid_from=date(2026, 10, 15),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, new_budget)
|
||||
|
||||
# Should have 2 budgets:
|
||||
# 1. Oct 1 - Oct 14 (remainder from old budget, before overlap start - 1 day)
|
||||
# 2. Oct 15 - Nov 30 (new budget)
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 2
|
||||
|
||||
|
||||
class TestSetCategoryBudgetMiddleOverlap:
|
||||
"""Test overlap in the middle that splits existing budget."""
|
||||
|
||||
def test_complex_overlap_middle(self, db_session: Session):
|
||||
"""Handle overlap in the middle that splits existing budget into two parts."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# Existing budget covering Oct 1 - Dec 31
|
||||
existing_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 12, 31),
|
||||
)
|
||||
set_category_budget(db_session, existing_budget)
|
||||
|
||||
# New budget covering Oct 15 - Nov 30 (overlaps in middle)
|
||||
new_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.00,
|
||||
valid_from=date(2026, 10, 15),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, new_budget)
|
||||
|
||||
# Should have 3 budgets:
|
||||
# 1. Oct 1 - Oct 14 (before new range, original amount)
|
||||
# 2. Oct 15 - Nov 30 (new budget, new amount)
|
||||
# 3. Dec 1 - Dec 31 (after new range, original amount)
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 3
|
||||
|
||||
|
||||
class TestSetCategoryBudgetUnbounded:
|
||||
"""Test budgets with unbounded dates."""
|
||||
|
||||
def test_unbounded_start(self, db_session: Session):
|
||||
"""Budget with None valid_from (unbounded start)."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=None, # Unbounded start
|
||||
valid_to=date(2026, 12, 31),
|
||||
)
|
||||
set_category_budget(db_session, budget)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 1
|
||||
assert budgets[0].valid_from is None
|
||||
assert budgets[0].valid_to == date(2026, 12, 31)
|
||||
|
||||
def test_unbounded_end(self, db_session: Session):
|
||||
"""Budget with None valid_to (unbounded end)."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=None, # Unbounded end
|
||||
)
|
||||
set_category_budget(db_session, budget)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 1
|
||||
assert budgets[0].valid_from == date(2026, 10, 1)
|
||||
assert budgets[0].valid_to is None
|
||||
|
||||
def test_unbounded_both(self, db_session: Session):
|
||||
"""Budget with both dates unbounded (None)."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=None, # Unbounded start
|
||||
valid_to=None, # Unbounded end
|
||||
)
|
||||
set_category_budget(db_session, budget)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 1
|
||||
assert budgets[0].valid_from is None
|
||||
assert budgets[0].valid_to is None
|
||||
|
||||
|
||||
class TestSetCategoryBudgetWithExistingUnbounded:
|
||||
"""Test adding bounded budgets to categories with existing unbounded budgets."""
|
||||
|
||||
def test_add_bounded_after_unbounded(self, db_session: Session):
|
||||
"""Add a bounded budget after an unbounded one (no overlap)."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# First, add unbounded budget
|
||||
unbounded_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=None,
|
||||
valid_to=None,
|
||||
)
|
||||
set_category_budget(db_session, unbounded_budget)
|
||||
|
||||
# Add bounded budget after (Nov 1 - Dec 31)
|
||||
bounded_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=150.00,
|
||||
valid_from=date(2026, 11, 1),
|
||||
valid_to=date(2026, 12, 31),
|
||||
)
|
||||
set_category_budget(db_session, bounded_budget)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 2
|
||||
|
||||
def test_add_bounded_before_unbounded(self, db_session: Session):
|
||||
"""Add a bounded budget before an unbounded one (no overlap)."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# First, add unbounded budget
|
||||
unbounded_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=None,
|
||||
valid_to=None,
|
||||
)
|
||||
set_category_budget(db_session, unbounded_budget)
|
||||
|
||||
# Add bounded budget before (Sep 1 - Oct 31)
|
||||
bounded_budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=75.00,
|
||||
valid_from=date(2026, 9, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
set_category_budget(db_session, bounded_budget)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 2
|
||||
|
||||
|
||||
class TestSetCategoryBudgetMultipleWithSameAmount:
|
||||
"""Test multiple budgets with same amount and overlapping dates."""
|
||||
|
||||
def test_same_range_different_amount(self, db_session: Session):
|
||||
"""When two budgets have same range but different amounts, only one should remain."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# First budget
|
||||
budget1 = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, budget1)
|
||||
|
||||
# Same range but different amount
|
||||
budget2 = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=200.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, budget2)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 1
|
||||
assert budgets[0].amount_eur == 200.00
|
||||
|
||||
|
||||
class TestSetCategoryBudgetDateEdgeCases:
|
||||
"""Test edge cases around date boundaries."""
|
||||
|
||||
def test_adjacent_budgets_no_overlap(self, db_session: Session):
|
||||
"""Two budgets that are adjacent (end of one is start-1 of next) should both exist."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# Budget 1: Oct 1 - Oct 31
|
||||
budget1 = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
set_category_budget(db_session, budget1)
|
||||
|
||||
# Budget 2: Nov 1 - Nov 30 (adjacent to Oct 31)
|
||||
budget2 = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 11, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
set_category_budget(db_session, budget2)
|
||||
|
||||
budgets = list(
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)
|
||||
)
|
||||
assert len(budgets) == 2
|
||||
|
||||
def test_shared_endpoint_is_replaced(self, db_session: Session):
|
||||
"""Inclusive ranges that share a date must not overlap after replacement."""
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
set_category_budget(
|
||||
db_session,
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 15),
|
||||
),
|
||||
)
|
||||
set_category_budget(
|
||||
db_session,
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=200.00,
|
||||
valid_from=date(2026, 10, 15),
|
||||
valid_to=date(2026, 10, 31),
|
||||
),
|
||||
)
|
||||
|
||||
budgets = (
|
||||
db_session.query(CategoryBudgetDb)
|
||||
.filter_by(category_id=cat_db.id)
|
||||
.order_by(CategoryBudgetDb.valid_from)
|
||||
.all()
|
||||
)
|
||||
assert [(b.amount_eur, b.valid_from, b.valid_to) for b in budgets] == [
|
||||
(100.00, date(2026, 10, 1), date(2026, 10, 14)),
|
||||
(200.00, date(2026, 10, 15), date(2026, 10, 31)),
|
||||
]
|
||||
|
||||
|
||||
class TestSetCategoryBudgetRegressionCases:
|
||||
def test_new_range_overlapping_multiple_budgets_is_added_once(
|
||||
self, db_session: Session
|
||||
):
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
for start, end, amount in (
|
||||
(date(2026, 10, 1), date(2026, 10, 31), 100.00),
|
||||
(date(2026, 12, 1), date(2026, 12, 31), 200.00),
|
||||
):
|
||||
set_category_budget(
|
||||
db_session,
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=amount,
|
||||
valid_from=start,
|
||||
valid_to=end,
|
||||
),
|
||||
)
|
||||
|
||||
set_category_budget(
|
||||
db_session,
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=300.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 12, 31),
|
||||
),
|
||||
)
|
||||
|
||||
budgets = (
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id).all()
|
||||
)
|
||||
assert len(budgets) == 1
|
||||
assert budgets[0].amount_eur == 300.00
|
||||
assert budgets[0].valid_from == date(2026, 10, 1)
|
||||
assert budgets[0].valid_to == date(2026, 12, 31)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("existing_start", "existing_end", "new_start", "new_end", "expected"),
|
||||
[
|
||||
(
|
||||
date(2026, 10, 1),
|
||||
date(2026, 12, 31),
|
||||
None,
|
||||
date(2026, 11, 15),
|
||||
[
|
||||
(100.00, date(2026, 11, 16), date(2026, 12, 31)),
|
||||
(200.00, None, date(2026, 11, 15)),
|
||||
],
|
||||
),
|
||||
(
|
||||
date(2026, 10, 1),
|
||||
date(2026, 12, 31),
|
||||
date(2026, 11, 15),
|
||||
None,
|
||||
[
|
||||
(100.00, date(2026, 10, 1), date(2026, 11, 14)),
|
||||
(200.00, date(2026, 11, 15), None),
|
||||
],
|
||||
),
|
||||
(
|
||||
None,
|
||||
date(2026, 12, 31),
|
||||
date(2026, 10, 1),
|
||||
date(2026, 10, 31),
|
||||
[
|
||||
(100.00, None, date(2026, 9, 30)),
|
||||
(200.00, date(2026, 10, 1), date(2026, 10, 31)),
|
||||
(100.00, date(2026, 11, 1), date(2026, 12, 31)),
|
||||
],
|
||||
),
|
||||
(
|
||||
date(2026, 10, 1),
|
||||
None,
|
||||
date(2026, 11, 1),
|
||||
date(2026, 11, 30),
|
||||
[
|
||||
(100.00, date(2026, 10, 1), date(2026, 10, 31)),
|
||||
(200.00, date(2026, 11, 1), date(2026, 11, 30)),
|
||||
(100.00, date(2026, 12, 1), None),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_partially_unbounded_ranges_are_split_correctly(
|
||||
self,
|
||||
db_session: Session,
|
||||
existing_start: date | None,
|
||||
existing_end: date | None,
|
||||
new_start: date | None,
|
||||
new_end: date | None,
|
||||
expected: list[tuple[float, date | None, date | None]],
|
||||
):
|
||||
cat = CategoryFactory.build()
|
||||
cat_db = upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
set_category_budget(
|
||||
db_session,
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=existing_start,
|
||||
valid_to=existing_end,
|
||||
),
|
||||
)
|
||||
set_category_budget(
|
||||
db_session,
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=200.00,
|
||||
valid_from=new_start,
|
||||
valid_to=new_end,
|
||||
),
|
||||
)
|
||||
|
||||
budgets = (
|
||||
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id).all()
|
||||
)
|
||||
actual = {(float(b.amount_eur), b.valid_from, b.valid_to) for b in budgets}
|
||||
assert actual == set(expected)
|
||||
|
||||
|
||||
class TestFindCategoryDb:
|
||||
"""Test find_category_db function."""
|
||||
|
||||
def test_find_simple(self, db_session: Session):
|
||||
"""Find a simple category without parent."""
|
||||
cat = Category(name="SimpleCategory")
|
||||
upsert_category(db_session, cat, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
result = find_category_db(db_session, cat)
|
||||
assert result is not None
|
||||
assert str(result.name) == "SimpleCategory"
|
||||
|
||||
def test_find_with_parent_hierarchy(self, db_session: Session):
|
||||
"""Find a category with parent hierarchy."""
|
||||
grandfather = Category(name="grandfather")
|
||||
upsert_category(db_session, grandfather, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
father = Category(name="father", parent=grandfather)
|
||||
upsert_category(db_session, father, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
child = Category(name="child", parent=father)
|
||||
upsert_category(db_session, child, do_commit=False)
|
||||
db_session.commit()
|
||||
|
||||
# Find the child via find_category_db
|
||||
result = find_category_db(db_session, child)
|
||||
assert result is not None
|
||||
assert str(result.parent.name) == "father"
|
||||
|
||||
def test_find_nonexistent(self, db_session: Session):
|
||||
"""Return None for non-existent category."""
|
||||
# Create a category but don't add it to session (already in memory from fixtures)
|
||||
result = find_category_db(db_session, Category(name="NonExistent"))
|
||||
assert result is None
|
||||
|
||||
def test_find_reintroduced_category_returns_active_version(
|
||||
self, db_session: Session
|
||||
):
|
||||
category = Category(name="Reintroduced")
|
||||
historical = CategoryDb(
|
||||
name=category.name,
|
||||
valid_to=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
last_action="retired",
|
||||
)
|
||||
db_session.add(historical)
|
||||
db_session.commit()
|
||||
|
||||
active = upsert_category(db_session, category, do_commit=False)
|
||||
assert active is not None
|
||||
db_session.commit()
|
||||
|
||||
result = find_category_db(db_session, category)
|
||||
assert result is not None
|
||||
assert result.id == active.id
|
||||
|
||||
set_category_budget(
|
||||
db_session,
|
||||
CategoryBudget(category=category, amount_eur=100.00),
|
||||
)
|
||||
budget = db_session.query(CategoryBudgetDb).one()
|
||||
assert budget.category_id == active.id
|
||||
|
||||
|
||||
class TestSetCategoryBudgetErrorHandling:
|
||||
"""Test error handling in set_category_budget."""
|
||||
|
||||
def test_missing_category_raises_error(self, db_session: Session):
|
||||
"""set_category_budget should raise ValueError for missing category."""
|
||||
cat = Category(name="NonExistent")
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
set_category_budget(db_session, budget)
|
||||
|
||||
assert "NonExistent" in str(exc_info.value)
|
||||
|
||||
def test_missing_parent_raises_error(self, db_session: Session):
|
||||
"""set_category_budget should raise ValueError for missing parent."""
|
||||
cat = Category(name="ChildOfMissing")
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 10, 31),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
set_category_budget(db_session, budget)
|
||||
|
||||
# The error should mention that the category was not found (including its parent)
|
||||
assert (
|
||||
"ChildOfMissing" in str(exc_info.value).lower()
|
||||
or "not found" in str(exc_info.value).lower()
|
||||
or "no matching" in str(exc_info.value).lower()
|
||||
)
|
||||
|
||||
|
||||
class TestSetCategoryBudgetValidation:
|
||||
"""Test CategoryBudget model validation rules."""
|
||||
|
||||
def test_valid_budget_no_dates(self):
|
||||
"""Budget with None dates is valid."""
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
)
|
||||
assert budget.valid_from is None
|
||||
assert budget.valid_to is None
|
||||
|
||||
def test_valid_budget_with_dates(self):
|
||||
"""Budget with dates is valid."""
|
||||
cat = CategoryFactory.build()
|
||||
budget = CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
assert budget.valid_from == date(2026, 10, 1)
|
||||
assert budget.valid_to == date(2026, 11, 30)
|
||||
|
||||
def test_invalid_date_range_raises(self):
|
||||
"""Budget with valid_from > valid_to should raise validation error."""
|
||||
cat = CategoryFactory.build()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=100.00,
|
||||
valid_from=date(2026, 11, 30),
|
||||
valid_to=date(2026, 10, 1),
|
||||
)
|
||||
|
||||
assert "valid_from" in str(exc_info.value) or "valid_to" in str(exc_info.value)
|
||||
|
||||
def test_amount_must_be_positive(self):
|
||||
"""Amount must be greater than zero."""
|
||||
cat = CategoryFactory.build()
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
CategoryBudget(
|
||||
category=cat,
|
||||
amount_eur=-100.00,
|
||||
valid_from=date(2026, 10, 1),
|
||||
valid_to=date(2026, 11, 30),
|
||||
)
|
||||
|
||||
assert "greater than" in str(exc_info.value).lower()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for extract_categories.py - extracting unique categories from b4w CSV exports."""
|
||||
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from scripts.initial_load.categories.extract_categories import extract_unique_categories
|
||||
|
||||
|
||||
def write_csv(path: Path, categories: list[str]) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["Id", "AcctId", "Category"])
|
||||
writer.writeheader()
|
||||
for i, category in enumerate(categories, 1):
|
||||
writer.writerow({"Id": str(i), "AcctId": "587713594", "Category": category})
|
||||
|
||||
|
||||
def read_lines(path: Path) -> list[str]:
|
||||
return path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_extract_unique_categories_basic(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen"])
|
||||
|
||||
result = extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert result == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]
|
||||
assert read_lines(output_file) == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]
|
||||
|
||||
|
||||
def test_extract_unique_categories_no_duplicates(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(
|
||||
csv_file,
|
||||
["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Lebenshaltung"],
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert len(read_lines(output_file)) == 2
|
||||
|
||||
|
||||
def test_extract_unique_categories_output_format(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(
|
||||
csv_file,
|
||||
["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Privat"],
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
lines = read_lines(output_file)
|
||||
assert len(lines) == 3
|
||||
# Plain list: no numbering prefix, no blank lines.
|
||||
assert all(line == line.strip() and not line[0].isdigit() for line in lines)
|
||||
|
||||
|
||||
def test_extract_unique_categories_german_special_chars(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(
|
||||
csv_file,
|
||||
["Girokonto:Freizeit:Urlaub&Ausflüge", "Girokonto:Privat:Mitgliedsbeiträge"],
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert read_lines(output_file) == [
|
||||
"Girokonto:Freizeit:Urlaub&Ausflüge",
|
||||
"Girokonto:Privat:Mitgliedsbeiträge",
|
||||
]
|
||||
|
||||
|
||||
def test_extract_unique_categories_handles_empty_csv(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
csv_file.write_text("Id,AcctId,Category\n", encoding="utf-8")
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert output_file.exists()
|
||||
assert read_lines(output_file) == []
|
||||
|
||||
|
||||
def test_extract_unique_categories_handles_csv_with_all_empty_categories(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
csv_file.write_text(
|
||||
"Id,AcctId,Category\n1,587713594,\n2,587713594,\n3,587713594,\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
|
||||
assert read_lines(output_file) == []
|
||||
|
||||
|
||||
def test_extract_unique_categories_rerun_is_idempotent(tmp_path):
|
||||
csv_file = tmp_path / "input.csv"
|
||||
output_file = tmp_path / "categories.txt"
|
||||
write_csv(csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen"])
|
||||
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
first_run = read_lines(output_file)
|
||||
|
||||
# A new category appears in the CSV on the second run.
|
||||
write_csv(
|
||||
csv_file, ["Girokonto:Lebenshaltung", "Girokonto:Sparen", "Girokonto:Privat"]
|
||||
)
|
||||
extract_unique_categories(str(csv_file), str(output_file))
|
||||
second_run = read_lines(output_file)
|
||||
|
||||
# Existing entries are merged, not duplicated; list stays sorted and plain.
|
||||
assert second_run == [
|
||||
"Girokonto:Lebenshaltung",
|
||||
"Girokonto:Privat",
|
||||
"Girokonto:Sparen",
|
||||
]
|
||||
assert first_run == ["Girokonto:Lebenshaltung", "Girokonto:Sparen"]
|
||||
+86
-1
@@ -1,7 +1,11 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.ingest.b4w import upsert_category
|
||||
from src.ingest.b4w import change_category, retire_category, upsert_category
|
||||
from src.models import Category
|
||||
from src.models.b4w import CategoryDb
|
||||
|
||||
@@ -17,6 +21,13 @@ def test_upsert_category_returns_orm_object(db_session: Session):
|
||||
assert str(result.name) == "dummy"
|
||||
assert str(result.parent.name) == "dummy_parent_2"
|
||||
assert str(result.parent.parent.name) == "dummy_parent_1"
|
||||
assert result.valid_from is None
|
||||
assert result.valid_to is None
|
||||
assert result.last_action == "created"
|
||||
assert all(
|
||||
CategoryDb.__table__.c[name].info.get("description")
|
||||
for name in ("valid_from", "valid_to", "last_action")
|
||||
)
|
||||
|
||||
|
||||
def test_upsert_category_writes_new_to_db(db_session: Session):
|
||||
@@ -45,3 +56,77 @@ def test_upsert_category_skips_existing(db_session: Session):
|
||||
select(CategoryDb).filter_by(name="try_again_and_again")
|
||||
).all()
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_change_category_closes_old_version_and_starts_new_one(
|
||||
db_session: Session,
|
||||
):
|
||||
original = upsert_category(
|
||||
db_session, Category.from_str("old_category"), do_commit=False
|
||||
)
|
||||
assert original is not None
|
||||
effective_at = datetime(2026, 10, 16, 12, tzinfo=UTC)
|
||||
|
||||
replacement = change_category(
|
||||
db_session,
|
||||
original.id,
|
||||
Category.from_str("new_category"),
|
||||
effective_at=effective_at,
|
||||
do_commit=False,
|
||||
)
|
||||
|
||||
assert original.valid_from is None
|
||||
assert original.valid_to == effective_at
|
||||
assert original.last_action == "changed"
|
||||
assert replacement.name == "new_category"
|
||||
assert replacement.valid_from == effective_at
|
||||
assert replacement.valid_to is None
|
||||
assert replacement.last_action == "changed"
|
||||
|
||||
db_session.expire(original)
|
||||
db_session.expire(replacement)
|
||||
assert original.valid_to == effective_at
|
||||
assert replacement.valid_from == effective_at
|
||||
|
||||
# A historical name can be introduced again as a new active version.
|
||||
reintroduced = upsert_category(
|
||||
db_session, Category.from_str("old_category"), do_commit=False
|
||||
)
|
||||
assert reintroduced is not None
|
||||
assert reintroduced.id != original.id
|
||||
assert reintroduced.valid_from is not None
|
||||
assert reintroduced.valid_from > original.valid_to
|
||||
assert reintroduced.last_action == "changed"
|
||||
|
||||
|
||||
def test_retire_category_closes_active_version(db_session: Session):
|
||||
category = upsert_category(
|
||||
db_session, Category.from_str("category_to_retire"), do_commit=False
|
||||
)
|
||||
assert category is not None
|
||||
effective_at = datetime(2026, 10, 16, 12, tzinfo=UTC)
|
||||
|
||||
retired = retire_category(
|
||||
db_session, category.id, effective_at=effective_at, do_commit=False
|
||||
)
|
||||
|
||||
assert retired.valid_to == effective_at
|
||||
assert retired.last_action == "retired"
|
||||
assert (
|
||||
db_session.query(CategoryDb)
|
||||
.filter_by(name="category_to_retire", valid_to=None)
|
||||
.one_or_none()
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_active_root_categories_are_unique(db_session: Session):
|
||||
db_session.add_all(
|
||||
[
|
||||
CategoryDb(name="duplicate_root"),
|
||||
CategoryDb(name="duplicate_root"),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
db_session.flush()
|
||||
|
||||
@@ -1,56 +1,93 @@
|
||||
from src.models import Category
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.category import Category, CategoryDb, upsert_category
|
||||
|
||||
|
||||
def test_single_level_category():
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert c.name == "single"
|
||||
assert c.parent is None
|
||||
|
||||
|
||||
def test_two_level_category():
|
||||
c = Category.from_str("first:second")
|
||||
assert c
|
||||
assert c.name == "second"
|
||||
assert isinstance(c.parent, Category)
|
||||
assert c.parent.name == "first"
|
||||
assert c.parent.parent is None
|
||||
|
||||
|
||||
def test_three_level_category():
|
||||
c = Category.from_str("first:second:third")
|
||||
assert c
|
||||
assert c.name == "third"
|
||||
assert isinstance(c.parent, Category)
|
||||
assert c.parent.name == "second"
|
||||
assert isinstance(c.parent.parent, Category)
|
||||
assert c.parent.parent.name == "first"
|
||||
assert c.parent.parent.parent is None
|
||||
|
||||
|
||||
def test_single_level_category_to_string():
|
||||
c = Category.from_str("single")
|
||||
assert c
|
||||
assert str(c) == "single"
|
||||
|
||||
|
||||
def test_two_level_category_to_string():
|
||||
c = Category(name="second", parent=Category(name="first"))
|
||||
assert c.to_str() == "first:second"
|
||||
|
||||
|
||||
def test_three_level_category_to_string():
|
||||
c = Category(
|
||||
name="third", parent=Category(name="second", parent=Category(name="first"))
|
||||
def test_upsert_category_returns_orm_object(db_session: Session):
|
||||
result = upsert_category(
|
||||
db_session,
|
||||
Category.from_str("dummy_parent_1:dummy_parent_2:dummy"),
|
||||
do_commit=False,
|
||||
)
|
||||
assert c.to_str() == "first:second:third"
|
||||
assert result
|
||||
assert isinstance(result, CategoryDb)
|
||||
assert str(result.name) == "dummy"
|
||||
assert str(result.parent.name) == "dummy_parent_2"
|
||||
assert str(result.parent.parent.name) == "dummy_parent_1"
|
||||
|
||||
|
||||
def test_category_str():
|
||||
c = Category.from_str("first:second:third")
|
||||
assert str(c) == "first:second:third"
|
||||
def test_upsert_category_writes_new_to_db(db_session: Session):
|
||||
upsert_category(
|
||||
db_session,
|
||||
Category.from_str("grand_father:father:new_category"),
|
||||
do_commit=True,
|
||||
)
|
||||
result = db_session.execute(
|
||||
select(CategoryDb).filter_by(name="new_category")
|
||||
).scalar_one()
|
||||
assert result
|
||||
assert str(result.name) == "new_category"
|
||||
assert str(result.parent.name) == "father"
|
||||
assert str(result.parent.parent.name) == "grand_father"
|
||||
|
||||
|
||||
def test_category_repr():
|
||||
c = Category.from_str("first:second:third")
|
||||
assert repr(c) == 'Category.from_str("first:second:third")'
|
||||
def test_upsert_category_skips_existing(db_session: Session):
|
||||
for _ in range(3):
|
||||
upsert_category(
|
||||
db_session,
|
||||
Category.from_str("grand_father:father:try_again_and_again"),
|
||||
do_commit=True,
|
||||
)
|
||||
result = db_session.execute(
|
||||
select(CategoryDb).filter_by(name="try_again_and_again")
|
||||
).all()
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_category_hash_id_computed_field():
|
||||
"""Test hash_id computed field is present and deterministic."""
|
||||
c1 = Category.from_str("single")
|
||||
c2 = Category.from_str("single")
|
||||
assert hasattr(c1, "hash_id")
|
||||
assert c1.hash_id == c2.hash_id
|
||||
|
||||
|
||||
def test_category_hash_id_different_inputs():
|
||||
"""Test that different category hierarchies have different hash_ids."""
|
||||
c1 = Category.from_str("A:B")
|
||||
c2 = Category.from_str("A:C")
|
||||
# Different last level -> different hash_id
|
||||
assert c1.hash_id != c2.hash_id
|
||||
|
||||
|
||||
def test_category_to_str_single_level():
|
||||
"""Test to_str() for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
assert c.to_str() == "single"
|
||||
|
||||
|
||||
def test_category_to_str_nested():
|
||||
"""Test to_str() for nested category."""
|
||||
c = Category.from_str("parent:child:grandchild")
|
||||
assert c.to_str() == "parent:child:grandchild"
|
||||
|
||||
|
||||
def test_category_str_delegates_to_to_str():
|
||||
"""Test that __str__ delegates to to_str()."""
|
||||
c = Category.from_str("A:B:C")
|
||||
assert str(c) == c.to_str() == "A:B:C"
|
||||
|
||||
|
||||
def test_category_repr_simple():
|
||||
"""Test repr() for single-level category."""
|
||||
c = Category.from_str("single")
|
||||
expected = 'Category.from_str("single")'
|
||||
assert repr(c) == expected
|
||||
|
||||
|
||||
def test_category_repr_nested():
|
||||
"""Test repr() for nested category."""
|
||||
c = Category.from_str("parent:child")
|
||||
expected = 'Category.from_str("parent:child")'
|
||||
assert repr(c) == expected
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for common model utilities."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from src.models.common import ExportBaseModel, Sign, date2str
|
||||
|
||||
|
||||
class TestSignEnum:
|
||||
"""Test Sign enum from common module."""
|
||||
|
||||
def test_sign_str(self):
|
||||
"""Test Sign.__str__ method."""
|
||||
assert str(Sign.DEBIT) == "DBIT"
|
||||
assert str(Sign.CREDIT) == "CRDT"
|
||||
|
||||
def test_sign_repr(self):
|
||||
"""Test Sign.__repr__ method (line 24)."""
|
||||
repr_debit = repr(Sign.DEBIT)
|
||||
assert repr_debit == "Sign.DEBIT"
|
||||
|
||||
repr_credit = repr(Sign.CREDIT)
|
||||
assert repr_credit == "Sign.CREDIT"
|
||||
|
||||
def test_sign_to_sign(self):
|
||||
"""Test Sign.to_sign method."""
|
||||
assert Sign.DEBIT.to_sign() == -1
|
||||
assert Sign.CREDIT.to_sign() == 1
|
||||
|
||||
|
||||
class TestDate2Str:
|
||||
"""Test date2str utility function from common module."""
|
||||
|
||||
def test_with_date(self):
|
||||
"""date2str with a valid date."""
|
||||
test_date = date(2026, 10, 15)
|
||||
result = date2str(test_date)
|
||||
assert result == "2026-10-15"
|
||||
|
||||
def test_with_none_default(self):
|
||||
"""date2str with None value (line 40)."""
|
||||
result = date2str(None)
|
||||
assert result == "--"
|
||||
|
||||
def test_with_none_custom_default(self):
|
||||
"""date2str with None and custom default."""
|
||||
result = date2str(None, default="N/A")
|
||||
assert result == "N/A"
|
||||
|
||||
def test_with_date_custom_format(self):
|
||||
"""date2str with custom format."""
|
||||
test_date = date(2026, 10, 15)
|
||||
result = date2str(test_date, date_format="%d.%m.%Y")
|
||||
assert result == "15.10.2026"
|
||||
|
||||
def test_with_none_custom_default_and_format(self):
|
||||
"""date2str with None, custom default and format."""
|
||||
result = date2str(None, default="--", date_format="%d.%m.%Y")
|
||||
assert result == "--"
|
||||
|
||||
|
||||
class TestExportBaseModel:
|
||||
"""Test ExportBaseModel methods from common module."""
|
||||
|
||||
def test_parameters_to_dict_empty(self):
|
||||
"""parameters_to_dict returns empty dict (line 70)."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
result = model.parameters_to_dict()
|
||||
assert result == {}
|
||||
|
||||
def test_parameters_to_str_no_params(self):
|
||||
"""parameters_to_str with no parameters."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
result = model.parameters_to_str()
|
||||
assert result == ""
|
||||
|
||||
def test_repr_empty_params(self):
|
||||
"""__repr__ with empty parameters (line 79)."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
|
||||
# __repr__ should return string representation with empty params
|
||||
repr_result = repr(model)
|
||||
assert "TestModel" in repr_result
|
||||
|
||||
def test_str_empty_params(self):
|
||||
"""__str__ with empty parameters."""
|
||||
|
||||
class TestModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
model = TestModel()
|
||||
|
||||
# __str__ should return string representation
|
||||
str_result = str(model)
|
||||
assert "TestModel" in str_result
|
||||
|
||||
def test_repr_with_params(self):
|
||||
"""Test repr with actual parameters."""
|
||||
|
||||
class ConfigurableModel(ExportBaseModel):
|
||||
def parameters_to_dict(self) -> dict[str, str]:
|
||||
return {"key": "value"}
|
||||
|
||||
model = ConfigurableModel()
|
||||
|
||||
# Test that repr works correctly with params
|
||||
repr_result = repr(model)
|
||||
assert "key" in repr_result or "value" in repr_result
|
||||
|
||||
def test_str_with_params(self):
|
||||
"""Test str with actual parameters."""
|
||||
|
||||
class ConfigurableModel(ExportBaseModel):
|
||||
def parameters_to_dict(self) -> dict[str, str]:
|
||||
return {"name": "test"}
|
||||
|
||||
model = ConfigurableModel()
|
||||
|
||||
# Test that str works correctly with params
|
||||
str_result = str(model)
|
||||
assert "name" in str_result or "test" in str_result
|
||||
|
||||
|
||||
class TestMyNameMethod:
|
||||
"""Test ExportBaseModel._my_name classmethod."""
|
||||
|
||||
def test_my_name_returns_classname(self):
|
||||
"""_my_name should return the class name."""
|
||||
|
||||
class SubModel(ExportBaseModel):
|
||||
pass
|
||||
|
||||
result = SubModel._my_name()
|
||||
assert result == "SubModel"
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for src.models.__init__.py lazy loading and __dir__."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src import models
|
||||
|
||||
|
||||
class TestLazyLoading:
|
||||
"""Tests for lazy attribute loading via __getattr__."""
|
||||
|
||||
def test_load_b4wexport(self):
|
||||
"""Test loading B4wExport model."""
|
||||
from src.models.b4w import B4wExport as DirectB4wExport
|
||||
|
||||
b4w = models.B4wExport
|
||||
assert b4w is DirectB4wExport
|
||||
|
||||
def test_load_category(self):
|
||||
"""Test loading Category model."""
|
||||
from src.models.category import Category as DirectCategory
|
||||
|
||||
cat = models.Category
|
||||
assert cat is DirectCategory
|
||||
|
||||
def test_load_categorybudgetdb(self):
|
||||
"""Test loading CategoryBudgetDb model."""
|
||||
from src.models.category import CategoryBudgetDb as DirectCategoryBudgetDb
|
||||
|
||||
cbdb = models.CategoryBudgetDb
|
||||
assert cbdb is DirectCategoryBudgetDb
|
||||
|
||||
def test_load_comdirectgiro(self):
|
||||
"""Test loading ComdirectGiro model."""
|
||||
from src.models.comdirect import ComdirectGiro as DirectComdirectGiro
|
||||
|
||||
giro = models.ComdirectGiro
|
||||
assert giro is DirectComdirectGiro
|
||||
|
||||
def test_load_transaction(self):
|
||||
"""Test loading Transaction model."""
|
||||
from src.models.transactions import Transaction as DirectTransaction
|
||||
|
||||
tx = models.Transaction
|
||||
assert tx is DirectTransaction
|
||||
|
||||
def test_load_account(self):
|
||||
"""Test loading Account model."""
|
||||
from src.models.settings import Account as DirectAccount
|
||||
|
||||
acc = models.Account
|
||||
assert acc is DirectAccount
|
||||
|
||||
def test_load_source(self):
|
||||
"""Test loading Source model."""
|
||||
from src.models.settings import Source as DirectSource
|
||||
|
||||
src = models.Source
|
||||
assert src is DirectSource
|
||||
|
||||
def test_nonexistent_attribute_raises(self):
|
||||
"""Test that non-existent attributes raise AttributeError."""
|
||||
with pytest.raises(AttributeError) as exc_info:
|
||||
# This is intentional: testing that accessing a non-existent attribute raises
|
||||
_ = models.NonExistentModel # type: ignore[name-defined]
|
||||
|
||||
expected_msg = "module 'src.models' has no attribute 'NonExistentModel'"
|
||||
assert expected_msg in str(exc_info.value)
|
||||
|
||||
|
||||
class TestDirMethod:
|
||||
"""Tests for __dir__() method."""
|
||||
|
||||
def test_dir_returns_sorted_list(self):
|
||||
"""Test that __dir__() returns a sorted list."""
|
||||
result = models.__dir__()
|
||||
assert isinstance(result, list)
|
||||
# Should be alphabetically sorted
|
||||
assert result == sorted(result)
|
||||
|
||||
def test_dir_includes_all_exports(self):
|
||||
"""Test that __dir__() includes all exported models."""
|
||||
result = models.__dir__()
|
||||
|
||||
expected_exports = {
|
||||
"B4wExport",
|
||||
"Category",
|
||||
"CategoryBudgetDb",
|
||||
"ComdirectCreditCard",
|
||||
"ComdirectDepot",
|
||||
"ComdirectGiro",
|
||||
"Account",
|
||||
"Source",
|
||||
"Transaction",
|
||||
"TransactionDb",
|
||||
}
|
||||
|
||||
# All exports should be in dir()
|
||||
for export_name in expected_exports:
|
||||
assert export_name in result
|
||||
|
||||
def test_dir_includes_globals(self):
|
||||
"""Test that __dir__() includes module-level globals."""
|
||||
result = models.__dir__()
|
||||
assert "_EXPORTS" in result
|
||||
|
||||
def test_dir_is_alphabetically_sorted(self):
|
||||
"""Verify the list is truly sorted alphabetically."""
|
||||
result = models.__dir__()
|
||||
# Double-check: if we sort it again, it should be identical
|
||||
assert result == sorted(result)
|
||||
@@ -0,0 +1,34 @@
|
||||
from datetime import date
|
||||
|
||||
import faker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.b4w import B4wDb
|
||||
from src.models.category import CategoryDb, upsert_category
|
||||
from tests.factories import CategoryFactory
|
||||
|
||||
|
||||
def _load_initial_categories(session: Session) -> None:
|
||||
for category in CategoryFactory.build_all():
|
||||
upsert_category(session, category, do_commit=False)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_b4w_populate(populated_db_b4w):
|
||||
val_dt_min, val_dt_max = date(2026, 1, 1), date(2026, 3, 31)
|
||||
session = populated_db_b4w(
|
||||
"b4w_basic",
|
||||
100,
|
||||
setup=_load_initial_categories,
|
||||
ValDt=lambda: faker.Faker().date_between(val_dt_min, val_dt_max),
|
||||
)
|
||||
assert session
|
||||
assert session.query(B4wDb).count() == 100
|
||||
assert session.query(CategoryDb).count() >= len(CategoryFactory.build_all())
|
||||
# ``ValDt`` was given as a per-row callable: it must have been resolved
|
||||
# and persisted for every row.
|
||||
dates = [d for (d,) in session.query(B4wDb.date_valid).all()]
|
||||
assert all(val_dt_min <= d <= val_dt_max for d in dates)
|
||||
# ``Id1`` is assigned per row: one distinct row per id, no dedup collisions.
|
||||
ids = [i for (i,) in session.query(B4wDb.id_b4w).order_by(B4wDb.id_b4w).all()]
|
||||
assert ids == list(range(1, 101))
|
||||
@@ -0,0 +1,196 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.ingest.b4w import ingest_b4w, upsert_b4w
|
||||
from src.ingest.comdirect import ingest_comdirect
|
||||
from src.ingest.transactions import (
|
||||
consolidate_transactions,
|
||||
map_b4w_to_transaction,
|
||||
map_comdirect_giro_to_transaction,
|
||||
)
|
||||
from src.models.b4w import B4wDb
|
||||
from src.models.comdirect import ComdirectGiroDb
|
||||
from src.models.transactions import Transaction, TransactionDb
|
||||
from tests.factories import B4wExportFactory, CategoryFactory
|
||||
|
||||
B4W_PATH = Path("tests/test_data/b4w_export_dummy_01.csv")
|
||||
COMDIRECT_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt")
|
||||
|
||||
|
||||
def test_transaction_hash_is_business_key():
|
||||
first = Transaction(
|
||||
source="b4w",
|
||||
source_row_id=1,
|
||||
account_iban="DE89370400440532013000",
|
||||
account_currency="EUR",
|
||||
date_valid=date(2026, 9, 7),
|
||||
amount=Decimal("-50.00"),
|
||||
amount_currency="EUR",
|
||||
date_book=date(2026, 9, 8),
|
||||
text="Supermarket",
|
||||
other_name="Aldi",
|
||||
)
|
||||
second = Transaction(
|
||||
source="b4w",
|
||||
source_row_id=999,
|
||||
account_iban="DE89370400440532013000",
|
||||
account_currency="EUR",
|
||||
date_valid=date(2026, 9, 7),
|
||||
amount=Decimal("-50.00"),
|
||||
amount_currency="EUR",
|
||||
date_book=date(2026, 9, 8),
|
||||
text="Supermarket",
|
||||
other_name="Aldi",
|
||||
)
|
||||
# Stable across source-row ids (re-imports) ...
|
||||
assert second.hash_id == first.hash_id
|
||||
# ... but source is part of the key, so no cross-source collision.
|
||||
third = second.model_copy(update={"source": "comdirect_giro"})
|
||||
assert third.hash_id != first.hash_id
|
||||
|
||||
|
||||
def test_map_b4w_to_transaction():
|
||||
row = B4wDb(
|
||||
id=7,
|
||||
hash_id="unused",
|
||||
id_b4w=1,
|
||||
account_iban="DE89370400440532013000",
|
||||
account_currency="EUR",
|
||||
date_valid=date(2026, 9, 7),
|
||||
date_book=date(2026, 9, 8),
|
||||
amount=Decimal("-50.00"),
|
||||
amount_currency="EUR",
|
||||
text="Supermarket",
|
||||
other_name="Aldi",
|
||||
other_iban="DE02120300000000202051",
|
||||
category_id=None,
|
||||
notes="",
|
||||
)
|
||||
transaction = map_b4w_to_transaction(row)
|
||||
assert isinstance(transaction, Transaction)
|
||||
assert transaction.source == "b4w"
|
||||
assert transaction.source_row_id == 7
|
||||
assert transaction.amount == Decimal("-50.00")
|
||||
assert transaction.date_book == date(2026, 9, 8)
|
||||
assert transaction.text == "Supermarket"
|
||||
assert transaction.other_name == "Aldi"
|
||||
assert transaction.other_iban == "DE02120300000000202051"
|
||||
|
||||
|
||||
def test_map_comdirect_giro_to_transaction():
|
||||
row = ComdirectGiroDb(
|
||||
id=3,
|
||||
hash_id="unused",
|
||||
account_iban="DE89370400440532013000",
|
||||
account_currency="EUR",
|
||||
amount_currency="EUR",
|
||||
date_valid=date(2026, 9, 7),
|
||||
amount=Decimal("32.43"),
|
||||
date_book=date(2026, 9, 8),
|
||||
type="Kartenumsatz",
|
||||
text="Supermarkt",
|
||||
)
|
||||
transaction = map_comdirect_giro_to_transaction(row)
|
||||
assert isinstance(transaction, Transaction)
|
||||
assert transaction.source == "comdirect_giro"
|
||||
assert transaction.source_row_id == 3
|
||||
assert transaction.amount == Decimal("32.43")
|
||||
assert transaction.other_name is None
|
||||
assert transaction.other_iban is None
|
||||
assert transaction.category is None
|
||||
|
||||
|
||||
def test_consolidate_b4w_signed_amount_and_category(db_session: Session):
|
||||
export = B4wExportFactory.build(
|
||||
Amt="50.00",
|
||||
RmtInf="Supermarket",
|
||||
Category=CategoryFactory.build().to_str(),
|
||||
CdtDbtInd="DBIT",
|
||||
)
|
||||
staging = upsert_b4w(db_session, export, do_commit=False)
|
||||
assert staging is not None
|
||||
consolidate_transactions(
|
||||
db_session, "b4w", B4wDb, map_b4w_to_transaction, do_commit=False
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
transaction = db_session.execute(select(TransactionDb)).scalars().one()
|
||||
assert transaction.source == "b4w"
|
||||
assert transaction.source_row_id == staging.id
|
||||
# b4w amount_signed: DBIT -> money out
|
||||
assert transaction.amount == Decimal("-50.00")
|
||||
assert transaction.category_id == staging.category_id
|
||||
assert transaction.text == "Supermarket"
|
||||
mapped = map_b4w_to_transaction(staging)
|
||||
assert mapped.category is not None
|
||||
assert mapped.category.to_str() == export.category.to_str()
|
||||
|
||||
|
||||
def test_ingest_b4w_e2e_idempotent(db_session: Session):
|
||||
for _ in range(2):
|
||||
ingest_b4w(B4W_PATH, db_session)
|
||||
|
||||
staging_count = db_session.execute(
|
||||
select(func.count()).select_from(B4wDb)
|
||||
).scalar_one()
|
||||
transaction_count = db_session.execute(
|
||||
select(func.count()).select_from(TransactionDb)
|
||||
).scalar_one()
|
||||
assert staging_count > 0
|
||||
assert transaction_count == staging_count
|
||||
|
||||
transactions = db_session.execute(select(TransactionDb)).scalars().all()
|
||||
assert all(t.source == "b4w" for t in transactions)
|
||||
assert all(t.source_row_id is not None for t in transactions)
|
||||
|
||||
|
||||
def test_ingest_comdirect_e2e_idempotent(db_session: Session):
|
||||
for _ in range(2):
|
||||
ingest_comdirect(
|
||||
session=db_session,
|
||||
path=COMDIRECT_PATH,
|
||||
account_iban="DE89370400440532013000",
|
||||
account_currency="EUR",
|
||||
)
|
||||
|
||||
staging_count = db_session.execute(
|
||||
select(func.count()).select_from(ComdirectGiroDb)
|
||||
).scalar_one()
|
||||
transaction_count = db_session.execute(
|
||||
select(func.count()).select_from(TransactionDb)
|
||||
).scalar_one()
|
||||
assert staging_count > 0
|
||||
assert transaction_count == staging_count
|
||||
|
||||
transactions = db_session.execute(select(TransactionDb)).scalars().all()
|
||||
assert all(t.source == "comdirect_giro" for t in transactions)
|
||||
assert all(t.account_iban == "DE89370400440532013000" for t in transactions)
|
||||
|
||||
|
||||
def test_ingest_mixed_sources_into_one_table(db_session: Session):
|
||||
ingest_b4w(B4W_PATH, db_session)
|
||||
ingest_comdirect(
|
||||
session=db_session,
|
||||
path=COMDIRECT_PATH,
|
||||
account_iban="DE89370400440532013000",
|
||||
account_currency="EUR",
|
||||
)
|
||||
|
||||
b4w_count = db_session.execute(select(func.count()).select_from(B4wDb)).scalar_one()
|
||||
giro_count = db_session.execute(
|
||||
select(func.count()).select_from(ComdirectGiroDb)
|
||||
).scalar_one()
|
||||
transaction_count = db_session.execute(
|
||||
select(func.count()).select_from(TransactionDb)
|
||||
).scalar_one()
|
||||
|
||||
assert b4w_count > 0
|
||||
assert giro_count > 0
|
||||
assert transaction_count == b4w_count + giro_count
|
||||
|
||||
sources = set(db_session.execute(select(TransactionDb.source).distinct()).scalars())
|
||||
assert sources == {"b4w", "comdirect_giro"}
|
||||
@@ -1,6 +1,10 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.14"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15'",
|
||||
"python_full_version < '3.15'",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
@@ -34,6 +38,70 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/94/3af39d34be01a24a6e65433d19e107099374224905f1e0cc6bbe1fd22a2f/argparse-1.4.0-py2.py3-none-any.whl", hash = "sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314", size = 23000, upload-time = "2015-09-14T16:03:16.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "26.5.1"
|
||||
@@ -74,6 +142,75 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.16.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "debugpy"
|
||||
version = "1.8.21"
|
||||
@@ -118,8 +255,10 @@ dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "argparse" },
|
||||
{ name = "dotenv" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
@@ -138,8 +277,10 @@ requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.19.1" },
|
||||
{ name = "argparse", specifier = ">=1.4.0" },
|
||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||
{ name = "mypy", specifier = ">=2.4.0" },
|
||||
{ name = "pydantic", specifier = ">=2.13.5" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.3" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.52" },
|
||||
]
|
||||
@@ -201,6 +342,76 @@ 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 = "librt"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.4.1"
|
||||
@@ -243,6 +454,48 @@ 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.4.0"
|
||||
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/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
@@ -382,6 +635,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.3"
|
||||
|
||||
Reference in New Issue
Block a user