Compare commits

..
Author SHA1 Message Date
Mathias Schneider eccf81a5ed more tests and bugfixes
Tests / test (push) Successful in 10s
Tests / lint (push) Successful in 11s
2026-10-29 14:43:18 +01:00
mathias2784 73298b3e49 finalized helper functions for category trees; started writing tests
Tests / test (push) Successful in 11s
Tests / lint (push) Successful in 10s
2026-10-28 21:17:54 +01:00
mathias2784 e0c32319e8 draft: category tree building
Tests / test (push) Failing after 12s
Tests / lint (push) Failing after 11s
2026-10-28 07:12:31 +01:00
mathias2784 8a0c98e9bb todo comment (some more)
Tests / test (push) Successful in 11s
Tests / lint (push) Failing after 7s
2026-10-27 20:48:56 +01:00
mathias2784 5810f16901 todo comment
Tests / test (push) Successful in 11s
Tests / lint (push) Failing after 7s
2026-10-27 20:38:28 +01:00
mathias2784 6b2821ba96 first sketch; incomplete; WiP
Tests / test (push) Successful in 11s
Tests / lint (push) Failing after 8s
2026-10-27 20:35:49 +01:00
mathias2784 6babc560c7 Category overhaul (#20)
Tests / test (push) Successful in 16s
Tests / lint (push) Successful in 16s
Reviewed-on: #20
Co-authored-by: Mathias <mathias2784@web.de>
2026-10-27 18:08:10 +00:00
mathias2784 4efef98c9c Test db ppopulate (#19)
Tests / test (push) Successful in 10s
Tests / lint (push) Successful in 10s
Reviewed-on: #19
Co-authored-by: Mathias <mathias2784@web.de>
2026-10-16 06:51:57 +00:00
mathias2784 6d98939024 Transactions table (#7)
Tests / test (push) Successful in 10s
Tests / lint (push) Successful in 10s
Reviewed-on: #7
Co-authored-by: Mathias <mathias2784@web.de>
2026-10-10 20:47:51 +00:00
mathias2784 fdc66037ce Merge pull request 'Ingest comdirect' (#5) from ingest-comdirect into master
Tests / test (push) Successful in 8s
Tests / lint (push) Successful in 7s
Reviewed-on: #5
2026-09-13 20:27:15 +00:00
mathias2784 61cb7cb591 pipeline: some more checks
Tests / test (push) Successful in 9s
Tests / lint (push) Successful in 6s
2026-09-13 22:20:20 +02:00
mathias2784 3245b25e9d pipeline: copy default settings
Tests / test (push) Successful in 9s
2026-09-13 22:11:36 +02:00
mathias2784 a482f38cb6 black
Tests / test (push) Failing after 7s
2026-09-13 22:06:28 +02:00
mathias2784 551d8ffbde flexible encoding when reading comdirect data 2026-09-13 22:06:20 +02:00
mathias2784 4046c00b1b pipeline fix dependencies
Tests / test (push) Failing after 16s
2026-09-13 21:56:05 +02:00
mathias2784 c4bc8d427f pipeline
Tests / test (push) Failing after 37s
2026-09-13 21:18:46 +02:00
mathias2784 75a787aece comdirect ingest tests (tbc) 2026-09-13 20:46:40 +02:00
mathias2784 c11c168134 pipeline 2026-09-13 13:16:28 +02:00
mathias2784 8440dd2ea7 b4w ingest e2e test 2026-09-13 12:57:05 +02:00
mathias2784 ef07fa5a2e comdirect complete dummy generation 2026-09-13 12:11:41 +02:00
mathias2784 61d7eb54a3 scripts/dummy_data/generate_dummies_b4w.py 2026-09-13 10:03:28 +02:00
mathias2784 afe2478cc7 comdirect stringification 2026-09-13 09:45:16 +02:00
mathias2784 46bdd5e68c ingestion models: less code repetition, more inheritance 2026-09-13 07:55:24 +02:00
mathias2784 58dc585e7e b4w faker: occasionally empty other_iban 2026-09-13 07:31:20 +02:00
mathias2784 7ab7276db5 AGENTS.md 2026-09-12 22:58:22 +02:00
mathias2784 500b76b8a8 fix b4w real-world import 2026-09-12 21:00:26 +02:00
mathias2784 e9dcb88da5 fix settings template 2026-09-12 21:00:00 +02:00
mathias2784 75fd76b0d9 comdirect faker 2026-09-12 20:07:08 +02:00
mathias2784 821fa1bd97 b4w dummy data improved 2026-09-12 18:10:34 +02:00
53 changed files with 4955 additions and 381 deletions
BIN
View File
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
name: Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync --frozen
- name: Copy default settings
run: cp settings.template.py settings.py
- name: Run pytest
run: uv run pytest
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
- name: Install uv
run: pip install uv
- name: Install dependencies
run: uv sync --frozen
- name: Copy default settings
run: cp settings.template.py settings.py
- name: Check formatting (black)
run: uv run black --check . --extend-exclude .venv
- name: Lint (ruff)
run: uv run ruff check .
- name: Typing (mypy)
run: uv run mypy .
+152
View File
@@ -0,0 +1,152 @@
# finanz-cockpit Agent Instructions
## Setup Commands
```bash
# Install dependencies
uv sync
# Run migrations
uv run alembic upgrade head
# Run all tests
uv run pytest
# Run a single test file
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
- `main.py`: CLI entrypoint for CSV ingestion
- `db.py`: Database connection management (uses global `_engine` pattern)
- `models/`: Pydantic models + SQLAlchemy tables for b4w and comdirect imports
- `ingest/`: Data ingestion logic per source
- **`tests/`**: Tests with fixtures in `conftest.py`
- **`alembic/versions/`**: Database migrations
## Key Patterns & Gotchas
### Database Configuration
```python
from src.db import get_engine, get_session, set_engine
# Development: use sqlite://
os.environ["DATABASE_URL"] = "sqlite://"
# Production/default: sqlite:///finanz.db
engine = get_engine() # reads DATABASE_URL or uses default
with get_session() as session:
session.query(...).all()
```
**Critical**: Tests override engine via `conftest.py`. Never assume global state - always use `get_session()` context manager.
### CSV Format Expectations
- **b4w**: German bank export (CSV) from `/mnt/anbau/Software/Python/finanz-cockpit/scripts/dummy_data/b4w_export_dummy_01.csv`
- **comdirect**: German bank export (CSV) with fields like `Buchungstag`, `Umsatz in EUR`, `Vorgang`
### Testing Workflow
```bash
# Tests automatically run migrations via conftest fixture
uv run pytest tests/ -v --tb=short
# To test without migrations (in-memory SQLite), use DATABASE_URL="sqlite://"
DATABASE_URL="sqlite://" uv run pytest tests/test_db.py
```
### Environment Variables
- `DATABASE_URL`: Database connection string (default: `sqlite:///finanz.db`)
## Data Models
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.
## 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
## Factory Pattern for Testing
Import from `tests/factories.py`:
```python
from tests.factories import B4wExportFactory, ComdirectGiroFactory
from src.constants import Currency
B4wExportFactory.__faker__ = Faker("de_DE")
ComdirectGiroFactory.__faker__ = Faker("de_DE")
# Use in tests:
item = B4wExportFactory.build( # build() for partial, create() for full
Amt="50.00",
RmtInf="Supermarket",
)
```
## Common Mistakes to Avoid
1. **Assuming global engine state**: Always use `get_session()` context manager
2. **Ignoring German date formats**: Use `_parse_date_german()` for CSV parsing
3. **Forgetting migration setup in tests**: Tests auto-migrate, but integration tests with real db need explicit upgrade
4. **Wrong Faker locale**: Use `Faker("de_DE")` for realistic names/IBANs
## Testing Tips
- 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
+193
View File
@@ -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
+3 -4
View File
@@ -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:
connectable = get_set_engine()
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 ###
+27
View File
@@ -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": []
}
]
}
+31
View File
@@ -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,10 +30,39 @@ 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"]
[dependency-groups]
dev = [
"black>=26.5.1",
"debugpy>=1.8.21",
"faker>=40.38.0",
"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,25 @@
;
"Umsätze Girokonto";"Zeitraum: 180 Tage";
"Neuer Kontostand";"2.363,30 EUR";
{table_giro}
"Alter Kontostand";"1.632,48 EUR";
"Umsätze Visa-Karte (Kreditkarte)";"Zeitraum: 180 Tage";
"Neuer Kontostand";"0,00 EUR";
{table_credit_card}
"Alter Kontostand";"0,00 EUR";
"Umsätze Tagesgeld PLUS-Konto";"Zeitraum: 180 Tage";
"Neuer Kontostand";"1.673,90 EUR";
{table_tagesgeld}
"Alter Kontostand";"6.686,20 EUR";
"Umsätze Depot";"Zeitraum: 180 Tage";
{table_depot}
+2 -2
View File
@@ -4,10 +4,10 @@ from pathlib import Path
from tests.factories import B4wExportFactory
NUMBER_OF_ROWS = 50
PATH_DEFAULT = Path("tests/test_data/b4w_export_dummy_01.csv")
DEFAULT_PATH = Path("tests/test_data/b4w_export_dummy_01.csv")
def save_csv(path: Path = PATH_DEFAULT):
def save_csv(path: Path = DEFAULT_PATH):
data = B4wExportFactory.batch(NUMBER_OF_ROWS)
row_fields = list(data[0].parameters_to_dict())
path.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,61 @@
from pathlib import Path
from tests.factories import (
ComdirectCreditCardFactory,
ComdirectDepotFactory,
ComdirectGiroFactory,
)
DEFAULT_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt")
TEMPLATE_PATH = Path("scripts/dummy_data/comdirect_export_template.txt")
NUMBER_OF_ROWS_GIRO = 50
NUMBER_OF_ROWS_CREDIT = 20
NUMBER_OF_ROWS_TAGESGELD = 10
NUMBER_OF_ROWS_DEPOT = 10
def save(path: Path = DEFAULT_PATH):
with TEMPLATE_PATH.open("r", encoding="latin-1") as fid:
txt_temp = fid.read()
assert isinstance(txt_temp, str)
batches = {
"giro": {
"data": ComdirectGiroFactory.batch(NUMBER_OF_ROWS_GIRO),
"table_txt": "",
},
"credit": {
"data": ComdirectCreditCardFactory.batch(NUMBER_OF_ROWS_CREDIT),
"table_txt": "",
},
"tagesgled": {
"data": ComdirectGiroFactory.batch(NUMBER_OF_ROWS_TAGESGELD),
"table_txt": "",
},
"depot": {
"data": ComdirectDepotFactory.batch(NUMBER_OF_ROWS_DEPOT),
"table_txt": "",
},
}
for batch in batches.values():
data = batch.get("data")
assert isinstance(data, list)
lines = [data[0].get_orig_headers()]
lines = lines + [row.get_orig_row() for row in data]
batch["table_txt"] = "\n".join(lines)
txt = txt_temp.format(
table_giro=batches["giro"]["table_txt"],
table_credit_card=batches["credit"]["table_txt"],
table_tagesgeld=batches["tagesgled"]["table_txt"],
table_depot=batches["depot"]["table_txt"],
)
with path.open("w", newline="") as fid:
fid.write(txt)
if __name__ == "__main__":
save()
@@ -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()
+6 -9
View File
@@ -3,13 +3,12 @@ from pathlib import Path
from faker import Faker
from src.constants import Currency
from src.ingest.b4w import ingest_b4w, reader_b4w
from src.ingest.comdirect import reader_comdirect
from src.models import (
ComdirectCreditCard,
ComdirectDepot,
ComdirectGiro,
)
from src.models.b4w import B4wExport
from src.models.settings import Account, Source
fake = Faker()
@@ -18,11 +17,10 @@ BANK_ACCOUNTS = {
"b4w": Source(
name="b4w",
source_path=Path(""),
reader_function=reader_b4w,
accounts=[
Account(
name="b4w",
ingestion_function=ingest_b4w,
model=B4wExport,
iban=fake.iban(),
currency=Currency.EUR,
)
@@ -31,29 +29,28 @@ BANK_ACCOUNTS = {
"comdirect": Source(
name="comdirect",
source_path=Path(""),
reader_function=reader_comdirect,
accounts=[
Account(
name="Girokonto",
ingestion_function=lambda x: ComdirectGiro.model_validate(x),
model=ComdirectGiro,
iban=fake.iban(),
currency=Currency.EUR,
),
Account(
name="Visa-Karte (Kreditkarte)",
ingestion_function=lambda x: ComdirectCreditCard.model_validate(x),
model=ComdirectCreditCard,
iban=fake.iban(),
currency=Currency.EUR,
),
Account(
name="Tagesgeld PLUS-Konto",
ingestion_function=lambda x: ComdirectGiro.model_validate(x),
model=ComdirectGiro,
iban=fake.iban(),
currency=Currency.EUR,
),
Account(
name="Depot",
ingestion_function=lambda x: ComdirectDepot.model_validate(x),
model=ComdirectDepot,
iban=fake.iban(),
currency=Currency.EUR,
),
+5 -15
View File
@@ -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))
+149
View File
@@ -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))
+102 -25
View File
@@ -1,10 +1,13 @@
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy.orm import Session
from src.db import get_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]:
@@ -12,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(
@@ -54,7 +125,7 @@ def upsert_b4w(
"notes": data.notes,
}
return _upsert(
return upsert(
session,
B4wDb,
parameters_existing,
@@ -64,9 +135,15 @@ def upsert_b4w(
)
def ingest_b4w(path: Path):
def ingest_b4w(path: Path, session: Session):
data = reader_b4w(path)
with get_session() as session:
for row in data:
upsert_b4w(session, row, do_commit=False)
session.commit()
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()
+69 -36
View File
@@ -1,19 +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.db import get_session
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: .*$'
@@ -41,30 +45,44 @@ 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[str]:
with path.open("r", encoding=encoding) as fid:
lines = fid.readlines()
return lines
def _reader_raw_comdirect(path: Path) -> CsV2DictMulti:
with path.open(encoding="latin1") as fid:
lines = fid.readlines()
try:
lines = _open_and_readlines(path, encoding="utf-8")
except UnicodeDecodeError:
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":
@@ -72,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
@@ -98,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,
@@ -112,7 +137,7 @@ def upsert_comdirect_giro(
"text": data.text,
}
return _upsert(
return upsert(
session,
ComdirectGiroDb,
parameters_existing,
@@ -122,25 +147,33 @@ def upsert_comdirect_giro(
)
def ingest_comdirect(path: Path, account_iban: str, account_currency: str):
def ingest_comdirect(
session: Session, path: Path, account_iban: str, account_currency: str
):
assert SOURCE_COMDIRECT
data_collection = reader_comdirect(path)
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,
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, ComdirectGiro)
upsert_comdirect_giro(
session=session,
data=data_validated,
account_iban=account_iban,
account_currency=account_currency,
do_commit=False,
)
else:
continue
with get_session() as session:
for row in data:
data_validated = account.model.model_validate(row)
assert isinstance(data_validated, account.model)
db_load_function(session, row, account)
session.commit()
consolidate_transactions(
session,
source="comdirect_giro",
StagingDb=ComdirectGiroDb,
mapper=map_comdirect_giro_to_transaction,
do_commit=False,
)
session.commit()
+7
View File
@@ -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))
+116
View File
@@ -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()
+12 -6
View File
@@ -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,17 +20,22 @@ 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)
ingest_comdirect(
Path(args.input_comdirect),
account_iban=giro_account.iban,
account_currency=giro_account.currency,
)
with get_session() as session:
ingest_comdirect(
session,
Path(args.input_comdirect),
account_iban=giro_account.iban or "",
account_currency=(
giro_account.currency.value if giro_account.currency else ""
),
)
if __name__ == "__main__":
+47 -7
View File
@@ -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
-81
View File
@@ -1,81 +0,0 @@
import hashlib
from datetime import date
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel
from sqlalchemy import Column, Date, Integer, Numeric, String
from sqlalchemy.orm import DeclarativeBase
def _hash(*args):
raw = "".join(str(args))
return hashlib.sha256(raw.encode()).hexdigest()
class Sign(str, Enum):
DEBIT = "DBIT"
CREDIT = "CRDT"
def __str__(self):
return self.value
def __repr__(self):
return f"Sign.{self.name}"
def to_sign(self) -> int:
return -1 if self is Sign.DEBIT else 1
def _parse_date_german(date_str: str) -> date | None:
if date_str in ["offen", "neu", "--"]:
return None
day, month, year = list(map(int, date_str.split(".")))
return date(day=day, month=month, year=year)
def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str:
if value:
return value.strftime(date_format)
return default
def _parse_decimal_german(decimal_str: str) -> Decimal:
return Decimal(decimal_str.replace(".", "").replace(",", "."))
def decimal2str(value: Decimal) -> str:
value = Decimal(f"{value:.2f}")
int_part, _, frac = str(value).partition(".")
groups: list[str] = []
while len(int_part) > 3:
groups.insert(0, int_part[-3:])
int_part = int_part[:-3]
groups.insert(0, int_part or "0")
return f"{'.'.join(groups)},{frac or '00'}"
type Csv2DictRow = dict[str, str]
type Csv2Dict = list[Csv2DictRow]
type CsV2DictMulti = dict[str, Csv2Dict]
class ExportBaseModel(BaseModel):
pass
class BaseModelDb(DeclarativeBase):
pass
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)
+32 -78
View File
@@ -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")
@@ -22,12 +29,12 @@ class B4wExport(ExportBaseModel):
account_currency: Currency = Field(alias="OwnrAcctCcy")
date_valid: date = Field(alias="ValDt")
date_book: date = Field(alias="BookgDt")
amount: Decimal = Field(alias="Amt")
amount: Decimal = Field(alias="Amt", gt=0, decimal_places=2)
amount_currency: Currency = Field(alias="AmtCcy")
sign: Sign = Field(alias="CdtDbtInd")
reference: str = Field(alias="RmtInf")
other_name: str = Field(alias="RmtdNm")
other_iban: str = Field(alias="RmtdAcctIBAN")
other_iban: str | None = Field(alias="RmtdAcctIBAN", pattern=IBAN_PATTERN)
category: Category | None = Field(
alias="Category",
examples=[
@@ -51,32 +58,27 @@ class B4wExport(ExportBaseModel):
"CdtDbtInd": f"{self.sign!s}",
"RmtInf": f"{self.reference}",
"RmtdNm": f"{self.other_name}",
"RmtdAcctIBAN": f"{self.other_iban}",
"RmtdAcctIBAN": f"{self.other_iban or ''}",
"Category": f"{self.category.to_str() if self.category else ''}",
"Notes": f"{self.notes}",
}
def parameters_to_str(self):
parameters = ", ".join(
f"{key}='{value}'" for key, value in self.parameters_to_dict().items()
)
return parameters
def __repr__(self):
return f"B4wExport({self.parameters_to_str()})"
def __str__(self):
return f"B4wExport({self.parameters_to_str()})"
@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:
return self.amount * self.sign.to_sign()
@field_validator("other_iban", mode="before")
@classmethod
def other_iban_blank_is_none(cls, iban_in: str | None) -> str | None:
if iban_in is None or (isinstance(iban_in, str) and not iban_in.strip()):
return None
return iban_in
@field_validator("category", mode="before")
@classmethod
def category_str_is_parsable(
@@ -92,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")
+234
View File
@@ -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")
+85
View File
@@ -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
+81 -13
View File
@@ -1,19 +1,23 @@
from datetime import date
from decimal import Decimal
from pydantic import Field, computed_field, field_validator
from sqlalchemy import Column, Date, String
from pydantic import ConfigDict, Field, computed_field, field_validator
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,
)
class ComdirectBase(ExportBaseModel):
model_config = ConfigDict(populate_by_name=True)
date_val: date | None = Field(
alias="Wertstellung (Valuta)", examples=["07.09.2026"]
)
@@ -21,7 +25,9 @@ class ComdirectBase(ExportBaseModel):
@field_validator("date_val", mode="before")
@classmethod
def parse_date_val(cls, date_str: str) -> date | None:
def parse_date_val(cls, date_str: str | None) -> date | None:
if date_str is None:
return None
return _parse_date_german(date_str)
@field_validator("amount_eur", mode="before")
@@ -29,27 +35,66 @@ class ComdirectBase(ExportBaseModel):
def parse_amount(cls, decimal_str: str) -> Decimal:
return _parse_decimal_german(decimal_str)
@classmethod
def _date2str(cls, date_value: date | None) -> str:
return date2str(date_value, default="--", date_format="%d.%m.%Y")
def parameters_to_dict(self, by_name=True) -> dict[str, str]:
return {
"date_val" if by_name else "Wertstellung (Valuta)": self._date2str(
self.date_val
),
"amount_eur" if by_name else "Umsatz in EUR": decimal2str(self.amount_eur),
}
def get_orig_headers(self, sep: str = ";", bracket: str = '"'):
return sep.join(
f"{bracket}{txt}{bracket}" for txt in self.parameters_to_dict(by_name=False)
)
def get_orig_row(self, sep: str = ";", bracket: str = '"'):
return sep.join(
f"{bracket}{txt}{bracket}"
for txt in self.parameters_to_dict(by_name=False).values()
)
class ComdirectGiro(ComdirectBase):
# # Fields from parents:
# date_val
# amount_eur
date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"])
type: str = Field(
alias="Vorgang", examples=["Lastschrift / Belastung", "Kartenumsatz"]
)
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
def parse_date_book(cls, date_str: str) -> date | None:
return _parse_date_german(date_str)
def parameters_to_dict(self, by_name: bool = True) -> dict[str, str]:
parameters = dict(**super().parameters_to_dict(by_name=by_name))
parameters.update(
{
"date_book" if by_name else "Buchungstag": self._date2str(
self.date_book
),
"type" if by_name else "Vorgang": self.type,
"text" if by_name else "Buchungstext": self.text,
}
)
return parameters
class ComdirectCreditCard(ComdirectGiro):
# "Buchungstag";"Umsatztag";"Vorgang";"Referenz";"Buchungstext";"Umsatz in EUR";
# # Fields from parents:
date_book: date | None = Field(alias="Buchungstag", examples=["07.09.2026"])
date_val: date | None = Field(alias="Umsatztag", default=None)
type: str = Field(
@@ -59,12 +104,21 @@ 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))
parameters.update(
{
"Umsatztag": self._date2str(self.date_val),
"Referenz": self.reference,
}
)
return parameters
class ComdirectDepot(ComdirectBase):
# # Fields from parents:
# amount_eur
date_val: date | None = Field(alias="Geschäftstag")
num: Decimal = Field(alias="Stück / Nom.")
name: str = Field(alias="Bezeichnung")
@@ -77,6 +131,20 @@ class ComdirectDepot(ComdirectBase):
def parse_decimals(cls, decimal_in: str) -> Decimal:
return _parse_decimal_german(decimal_in)
def parameters_to_dict(self, by_name: bool = True) -> dict[str, str]:
parameters = dict(**super().parameters_to_dict(by_name=by_name))
parameters.update(
{
"Geschäftstag": self._date2str(self.date_val),
"Stück / Nom.": decimal2str(self.num),
"Bezeichnung": self.name,
"WKN": self.wkn,
"Währung": self.currency,
"Ausführungskurs": decimal2str(self.spot_price),
}
)
return parameters
class ComdirectBaseDb(IngestdbBase):
__abstract__ = True
@@ -85,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)
+104
View File
@@ -0,0 +1,104 @@
import hashlib
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Annotated
from pydantic import BaseModel, Field
from sqlalchemy import Date, Numeric, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def hashify(*args):
raw = "".join(str(args))
return hashlib.sha256(raw.encode()).hexdigest()
class Sign(str, Enum):
DEBIT = "DBIT"
CREDIT = "CRDT"
def __str__(self):
return self.value
def __repr__(self):
return f"Sign.{self.name}"
def to_sign(self) -> int:
return -1 if self is Sign.DEBIT else 1
def _parse_date_german(date_str: str) -> date | None:
if date_str in ["offen", "neu", "--"]:
return None
day, month, year = list(map(int, date_str.split(".")))
return date(day=day, month=month, year=year)
def date2str(value: date | None, default="--", date_format="%Y-%m-%d") -> str:
if value:
return value.strftime(date_format)
return default
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: Amount) -> str:
value = Decimal(f"{value:.2f}")
int_part, _, frac = str(value).partition(".")
groups: list[str] = []
while len(int_part) > 3:
groups.insert(0, int_part[-3:])
int_part = int_part[:-3]
groups.insert(0, int_part or "0")
return f"{'.'.join(groups)},{frac or '00'}"
type Csv2DictRow = dict[str, str]
type Csv2Dict = list[Csv2DictRow]
type CsV2DictMulti = dict[str, Csv2Dict]
class ExportBaseModel(BaseModel):
@classmethod
def _my_name(cls):
return cls.__name__
def parameters_to_dict(self) -> dict[str, str]:
return {}
def parameters_to_str(self):
parameters = ", ".join(
f"{key}='{value}'" for key, value in self.parameters_to_dict().items()
)
return parameters
def __repr__(self):
return f"{self._my_name()}({self.parameters_to_str()})"
def __str__(self):
return f"{self._my_name()}({self.parameters_to_str()})"
class BaseModelDb(DeclarativeBase):
pass
class IngestdbBase(BaseModelDb):
__abstract__ = 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)
+78
View File
@@ -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
View File
@@ -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
+134 -2
View File
@@ -1,9 +1,141 @@
from decimal import Decimal
from pathlib import Path
from typing import Any
from faker import Faker
from polyfactory.factories.pydantic_factory import ModelFactory
from polyfactory.fields import Use
from src.models import B4wExport
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):
if faker.pyfloat(min_value=0.0, max_value=1.0) < p_value:
return default
return non_default
def _amount() -> Decimal:
return B4wExportFactory.__faker__.pydecimal(
left_digits=6,
right_digits=2,
positive=True,
min_value=Decimal("0.01"),
)
def _amount_eur() -> str:
"""Generate German-formatted decimal string (e.g., '32,43')."""
val = Faker().pydecimal(
right_digits=2, min_value=Decimal("-4000.00"), max_value=Decimal("4000.00")
)
# Format as German string with comma decimal separator
return f"{val:.2f}".replace(".", ",").strip()
def _german_date() -> str:
return Faker().date("%d.%m.%Y")
def _sentence() -> str:
return B4wExportFactory.__faker__.sentence(
nb_words=8,
variable_nb_words=False,
)
def _names() -> str:
return B4wExportFactory.__faker__.company()
def _iban() -> str:
return B4wExportFactory.__faker__.iban()
def _iban_or_none() -> str:
return _faker_or_none(Faker(), Faker().iban(), default="")
class B4wExportFactory(ModelFactory[B4wExport]):
__faker__ = Faker("de_DE")
__use_examples__ = True
Amt = Use(_amount)
RmtInf = Use(_sentence)
Notes = Use(_sentence)
RmtdNm = Use(_names)
OwnrAcctIBAN = Use(_iban)
RmtdAcctIBAN = Use(_iban_or_none)
class ComdirectGiroFactory(ModelFactory[ComdirectGiro]):
__faker__ = Faker("de_DE")
__use_examples__ = True
text = Use(_sentence)
amount_eur = Use(_amount_eur)
date_val = Use(_german_date)
date_book = Use(_german_date)
class ComdirectCreditCardFactory(ModelFactory[ComdirectCreditCard]):
__faker__ = Faker("de_DE")
__use_examples__ = True
text = Use(_sentence)
amount_eur = Use(_amount_eur)
date_val = Use(_german_date)
date_book = Use(_german_date)
class ComdirectDepotFactory(ModelFactory[ComdirectDepot]):
__faker__ = Faker("de_DE")
__use_examples__ = True
amount_eur = Use(_amount_eur)
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)
+112
View File
@@ -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]
+164
View File
@@ -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
+116
View File
@@ -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
+50 -50
View File
@@ -1,51 +1,51 @@
Id1,OwnrAcctIBAN,OwnrAcctCcy,ValDt,BookgDt,Amt,AmtCcy,CdtDbtInd,RmtInf,RmtdNm,RmtdAcctIBAN,Category,Notes
546,BB94K6C321C502O,GBP,2025-12-29,2021-05-31,966249079068317412764571542407919514412758243437140957631412514241265507048511272714647319346008254.45380506761321740967397160412882982325923639777828329138314768657367549356366752060752331132,GBP,DBIT,xobiDcwzRKxgCdlMhOqA,tKVeYnvYCbpbogKulljk,TDgXpTthOVshHpNKRvbQ,Beruf:Gehalt:super Firma,WGCaHsVAXWVeCLsPbdrN
3130,CY01AL0ZPFKXL0Z,CHF,2020-04-27,2025-04-02,-91380510785950845491826614519754504907024428700812006803291502570873.211019996196381685494424904026971409543,EUR,CRDT,aYEtztGZMJkkjbGNhrGl,CbvOzQGjcTWWXPrKfyjp,nOBawCuxZMLTiFKuZnsx,,MVznQDrIqmCmYrTDhBbp
5356,AE43DPLDC66ENP6,USD,2026-01-09,2020-03-27,12495147566547030449243528706787437899952753255929949.510436466181518004685,EUR,DBIT,lckOKAAYRGuzCUqnXbfW,vOGbcayDxBlnejGwfLjA,xxjAuYKPhMmqaMfZDhAw,Lebenshaltung:Lebensmitteleinkäuft,iwUPRIJRxPSlRXcWDWNo
5605,NS44V0K3D40LZEC,GBP,2021-12-22,2021-06-05,0.5943269284524336922642283862359244002375250667,CHF,CRDT,joJcLPuIFvxHxnpTMRZS,LRMmWOiXpcpNkZNCotyG,WawuMyUUpqZRmIWRpzOF,Freizeit,AUSRXmzbzyZwBREOZOgi
9030,UL50MIF1W1KAS3C,USD,2024-03-13,2021-03-07,94229449126346152407566499816383306669237.8035079624692824996218720107585445554234173,USD,CRDT,AcQtetkWOZpHsvpFAFLY,sIieEkhvUFSOsLcLPzIq,nUOVSAvyyjaXaGrtwHkQ,Freizeit,xdNHKUVfoqzfwCykZIvd
5139,KN98I6KMGOBJGDQ,GBP,2021-01-08,2021-12-18,-15902928538656294489371181513645784480574990906804212434837506917428638594538599376635051278636.78520642113315033999770538105015205921897385545146693383944328,USD,CRDT,lMrbuAYxXwuHurNyFYzg,PVoYOsiYrgiycTpYqwaq,EaPFovWogTiCIIVqPnHZ,Beruf:Gehalt:super Firma,rZVnmZqzUaqgrTejRQBv
3262,VP35ANQ489KSRBJ,CHF,2022-02-11,2022-12-15,1456.3272931,EUR,DBIT,ZSDukJoElAhJnbqxCemY,gQZioaMYeNkeDIWUJQOJ,DaRRlkzDhfzwgqsmyKfI,,RmcCnGYIJwwXhUoDebtx
6370,EG06AML3VX70NBZ,CHF,2020-10-30,2026-06-24,-85985373736852359001228845964.12923,GBP,CRDT,nGcVCPmxqcCWOwLCqNcL,ELTpZKLLPxtkBQpFfcXO,QqCDEBwHcqhYqNmrIjRJ,Lebenshaltung:Lebensmitteleinkäuft,MlltKALcNRksLbrBdqHB
9610,LS00DDSRWKI2GCN,CHF,2022-12-24,2023-11-13,111302702519553016947918391140.83336651886149677841069515026199226910390124216959835264362430572949455458094205858204009469,GBP,DBIT,rIqXIlJHQUmNryiFhZgl,fmlShtEaPYxDxLPLVFtA,ZuEmBQavkZkliTgQslkX,Beruf:Gehalt:super Firma,QxxGfOhHEKlRIBtxLBzN
7958,IM49W0MERUPAEYL,GBP,2024-09-23,2026-08-07,997654494541331560967.23894678707663633993031462271276158599265860951730613416835071421468,EUR,CRDT,lIFaiRDOHpPsLZYMvSLL,COjPCyRHmQJAJjXpHtoc,wLGNwAmIoguSFiPOwhop,Beruf:Gehalt:super Firma,wIVvfgWAGpXaNtmCMdtl
81,NB13I5PF8RT1KBF,EUR,2023-10-01,2025-10-14,-623.171747883897331930600196592034302396711514091988656250527836008950413955561,EUR,CRDT,gKujLbFKPTJWNHCwLEbP,BNNSobmkOJYvruTmVpsY,JFTexNOzIHpIDYxFyxXC,Lebenshaltung:Lebensmitteleinkäuft,EXRfMRisDxjfvTFbbScl
4306,MA16EMD5TXB6W2Q,CHF,2022-03-09,2022-07-04,1967316692371140884668.697876470482728723376342302791065723059022794527986321223057196172445638187219626476457610180,EUR,CRDT,PxRyYSeHHCnLsOGvkJBR,tdUmPUqXPyVAwcMonTOz,FkhuAmZMFEYHXfIRbYNa,,hoOqFYFhQZMNFflTTRzr
1340,QC15X8QJ925LIIZ,USD,2023-05-13,2024-10-19,51002714193280774680213206683817944775146555520957438367304201512763096320304094279.5917694840241093438785611670208852758416238660282666099607713108189846992146195070603042071073,USD,CRDT,KXmqOKVhWFhNyeTrYcqi,xrXHIHUrhVotQHkfTSbR,XftIqQPwfIZaQWPgfvTq,Beruf:Gehalt:super Firma,aIBfbrbYXZILZipenuQs
6971,IU89H1O656FOARG,CHF,2023-01-05,2023-01-26,-635472806525340497640508823188167.73094955967757797936541852850962382275616205624981776791984877714957051928106426532873075,EUR,CRDT,aBFnGYtfSndKKDcuKbOA,mFDeQxsPDxRwgSxHoPar,rWitPpvexQZfYaRknvVZ,Lebenshaltung:Lebensmitteleinkäuft,SdKWfeCRdmIuQtpYXpcP
5765,YO97BHIS7F13W5T,CHF,2025-04-27,2024-09-23,-84170840.062065298147641969564638230620262747954,EUR,DBIT,PQzlBfLSMjHPhmzCNWlW,OAuifNsHLzDRYOshTltg,SrdmTqlAcjYZqspXUqWl,Beruf:Gehalt:super Firma,gdyHSZQRVGVDljyWukxF
873,CU84CRHLBLDVPG4,USD,2024-09-10,2021-04-05,31708414063036408560039191005.628517576703091956518065559718890786318411169242277168731009729,EUR,DBIT,jEqWELcluqYzeoFKWrSv,rMeRFYSEneKpjTQxTAWE,irpXsNFkucPYaXhtiGVU,Beruf:Gehalt:super Firma,tDYCusMlvSzPxJtAoGwU
7512,OK88REZY7LC3ZJO,USD,2024-06-20,2024-02-12,-91309800414850316424146855043265998301745409273389966051295090800556208516373.6106630,EUR,CRDT,RjKWAFvwkGwehDeVgAMe,ZBCofGxOxAuhCNdoRwJK,CBjHBmiutfFjnNqGNsys,Freizeit,ibeMySwyUuaKJmopdFeo
468,ZI7268MWFCVKJET,GBP,2021-07-08,2023-08-04,-9566140937946527477623537138113287213.7764645148994252143659337484894589906652837718944384583356873367,GBP,DBIT,dHnVUadgIXaQokZAaddZ,MJpKJyJXAGVFHGXqyeLb,jgWKQYgfzCjzpWDqSfMV,Beruf:Gehalt:super Firma,rRrGzQHiNWxftPiobuBl
6125,VX319YHC45XB9FI,CHF,2024-04-08,2023-11-02,-404943589.503,EUR,CRDT,DLgsRpbgvYUZiFwwfxYX,NFCMuqKchwfGolxUoszx,wToRonKwFFOWrnmfMWZd,Beruf:Gehalt:super Firma,EiYGrzzlsMweQPlFHdlx
5352,WI30AMBG4K8GC5M,USD,2022-07-28,2023-07-12,298801846977579969716883410099510711907273580263884196580644541848777146559224.91128608265378630536063943513079726365933334413452853303576298418192825,GBP,DBIT,BkBNoWLKZmNptdlkcdFy,jdCcnjNmSYMVZYBAqXpk,KdVUoLgaiQoorPtFXfuD,Lebenshaltung:Lebensmitteleinkäuft,aPilIRVyMZhDaLreCtVB
8157,AS89HYB3PJOCKGQ,USD,2021-12-11,2021-05-02,-5383257045022212194476332790.58249908408715153448695588386599010825479811059729154822,USD,CRDT,hLscFzoYamBLLTCOLtJX,mJzlPJMFlwXsvDWCdPEX,zcgAipzPSDbNvDgsUsdf,Lebenshaltung:Lebensmitteleinkäuft,KzpTvMfREqDcraLsnAFa
8562,XF55JJHCFY9VFPN,EUR,2021-10-06,2023-03-07,58513152657357786429578134004715672662808018369796246403461323977035786109578790774134514968087.32196519574363119760206835296320588360455621,EUR,CRDT,WwygiOMjDsxlmBrgMBUD,CPkjRvCOcTGVQRxdVSco,WEmukvnPtZyCmopsKLeB,Freizeit,vNxjYBDrcjIJXRYsioVn
6932,PK53O1QFHWMM80N,CHF,2021-11-22,2022-04-29,-35687720973389008791120348643747347909777063417264503383154431851328499756311481892.525588482723067939754169870785253637544209574930786950647614924757554933127740331928,USD,DBIT,ocyWAbkFrEGhLKcqIUrJ,eIdWToAESlHQGGnykQZI,urMJiqlsVuLqGbGmIOdR,Beruf:Gehalt:super Firma,wbVGbcIRqoAyhXPQFmbn
5603,LM99PWTKWNVKFUK,CHF,2020-10-03,2024-10-23,137912574664116210905504437194561515849372037.5300228797878339289857585366614065212461747,CHF,DBIT,mJHgXdjzfGTiqdPFYKJu,iCPXYqUtKwBTilCrsJTM,AqLdwtlWBsQmQaBvwHii,Lebenshaltung:Lebensmitteleinkäuft,DVEvjvPzjBahqIEOddlq
7707,LP36QSQ8LN9F6U8,EUR,2020-01-26,2026-07-22,20617377959559934081335048761128552951261867125124238167249671785612681.51317409949621954528827764556416450523039696133111537499104315164348776636640613432,GBP,CRDT,IcRLxekFBpooSGzJqqGw,rCFLSzlzvLBdEpXFPZfV,vkMfGTUYKzrINNryKNJN,,tjNBGXokHzSQvBVABxCV
7964,WB16POIGIKIRSRB,GBP,2025-01-15,2026-07-14,19212833314539080562967961976059399140703968646971623411156202797393305194593.881945095397465505661922398,USD,DBIT,CoxjaXrcDJVITkyHjZzv,IunvUBVgzwgJVzAvOyKQ,BcYtrPUKMrJkEKNNnYyD,,awUfzbbQZVwNaLaTiFsr
4725,AT598204ZYZODV1,EUR,2024-03-25,2024-08-09,82856650729233542595012846123790016186861935974808282995711867343684454016512697494.88917524421022914453765603187655206018877538527053112,EUR,DBIT,xlCBJxJOxqvLAjgICrbX,msAsDTuxgXrTntkliYmb,FXyFAuyPwdvXjTTNcPrz,Lebenshaltung:Lebensmitteleinkäuft,gwHdLpOEMZgALigVFmpM
6050,TY84APSJTGLW0Y7,GBP,2020-09-04,2025-08-15,-3472009449932706821864839468227645291697670610604942221835405160979793916196159877775819802166678286.924897290769355741386357676998033855577136669559598846,GBP,DBIT,sqcQIypoDiHETEPLtswm,bkRKycwiqnHBrCWFDWvp,IsMCNagZuWQgjbHotLkZ,Lebenshaltung:Lebensmitteleinkäuft,PmxAqaLvtdKHOqufnPEr
1636,TT56OLKXD9FFLHW,EUR,2022-04-05,2025-01-21,-8700251749693498471853131195688590000252839147762542676728724966.70275220280,CHF,CRDT,LYftAZAxwAbsKqKJJDox,AutZAoZFOVswxydiSajl,peXIkhZxuXokaHbIHulP,Freizeit,qAADExaKKWlrrLTDOeJd
7753,BS67UNQVFAG259C,CHF,2024-06-05,2021-06-24,24861162756159054957.670585366670739666042308388630102242932146348848932417907909353461828706673705,EUR,CRDT,xZhCgZOvnVzrCDKLqbhK,XhDZhAWtaSbEctUEYLjT,aClycbHxjTAgkNnTRcuY,,LmeGwybyUMOrxHkjgLaZ
6189,ZZ871MTLUU9A1RU,CHF,2021-08-01,2023-03-17,-409780077475199424796148695159372908162375055189181880910638677.248670538597146440670239645579725174792942268017121149370963751622201496,EUR,CRDT,GQlNICMrebnglDvBDTYS,CXDqKkEcjnJngYAqpByV,NHVCfnmSrnFolUjPSEIr,,uwEFhSBDVdAPtdhJavhW
9624,AU87XCMRZZWGH86,USD,2024-09-02,2022-09-19,-442193693537141866795259204406415934110882954978333632301457685242868631049543099431520126945516367.745400551449,EUR,DBIT,cbaGAkeofJGsqaiNUEIY,ljZDuEzncPYZLDWNMgnH,EcVuFQSISKGSBDNIdqXJ,Beruf:Gehalt:super Firma,KhFiMeuprVqYAZtZQwIp
9203,FX68NTN2D10VVKO,GBP,2022-02-15,2024-06-23,-534572334.4012791130107478240377375826129655674355510806820651340789640,USD,DBIT,tNihPvPVLoZiwWmdZZRZ,zJGOcjBqCiLbirDJCgVR,oeFcARYRdznzUieqiHxt,Lebenshaltung:Lebensmitteleinkäuft,ihcfdoHuUNdBWRSOyeBB
3927,WH16E7SHB62XKIH,GBP,2023-10-04,2023-01-05,415593598910615971016549932285154.643848021414472037085778014035161865336120601218617113853923085787470877370,EUR,CRDT,MreRryaDFIhSFXkthwko,AqECnwyKoevMnedwdaFc,DXMnEnltpMbzNPWinMrk,Freizeit,IDiYkQfxVozPBqInPIQV
6283,HU452U8CMEYLRNE,CHF,2022-04-25,2025-03-24,-31056127574668984711914.148062642678712152287686837162829872960394642611046974293342,CHF,CRDT,uvRVCtUcGqAYppkZfxar,VvKDwohUZmOQWHpTbbox,rGrLuSeVEZhbtgGBcOFx,Lebenshaltung:Lebensmitteleinkäuft,mNqLNmnFbTXThCWaoBpa
4041,AR48BWK3T5RZ570,GBP,2025-10-10,2020-07-06,1812531959.04051886610531349,EUR,DBIT,VICxZqioNNFBXEnZvvHZ,HHKlheyBNtageACANISB,mPbiYFXCzowXbuRckcJU,Freizeit,jAtyPevPSKJdGTVjowmo
7217,OZ17J7ZD7YG7O1A,USD,2024-01-23,2024-06-19,-968844607026826575302933681105715018281543501601087944362850708075305.66200706173538352524957865510877,GBP,CRDT,RQxftSphrPpwMKVlnKpn,uqNrspSFVXYYPveWyuNv,CWyQeuLcZPAuGlNWjGdM,Lebenshaltung:Lebensmitteleinkäuft,HAyMDEDnKqtDgfIYZDiC
2615,WX91BRBHNDBXKU0,EUR,2021-10-30,2025-11-23,-8343466178334617252604602.569949636202667336077288532506921508691215873161097,CHF,CRDT,RyhUcmsrSAXNUaXevPMB,bFIZFcQCTivRZcMYhJAx,mfRHcvsWGYIMPHtWOYpm,Lebenshaltung:Lebensmitteleinkäuft,PHOtyKuWgmyZaxJYQPjO
251,ZJ430BMO81CDA44,EUR,2025-08-08,2022-04-02,-944177666499883169717087768179297037355028367029688127.633698125531,USD,DBIT,lUaArRWPnlxRwoWKSDyZ,jFSXDqiPxpdkDkjduezx,ndEGQjdezYvakfXmSeer,Freizeit,NbDQJseGHjiMLMFmhlQX
8432,RG9882TQXT40HDH,GBP,2020-05-29,2026-06-29,-4620473059978062834290693674666409952444643118783437106239403117968074841905810680891909.179130304762746994760945084576,GBP,DBIT,QakUGBIAuAmdabucXlOf,WlRItduFSMEjCwmkbVPV,haAnIWAPZnqWKJHZubtA,Freizeit,tZHnaMEcNMMxpvFZAdjx
9952,JF78B330IL8AKEQ,CHF,2023-03-16,2023-01-31,-4493693464311613709632110286395250278421205912044580776714732064115337203268.547530,GBP,DBIT,hTIWMdfJKdCycCtcVZIa,OABZhgwiXUqfXMmSklHk,GpItXzAVpjsAvDFHxtHP,Freizeit,QbKsSKahDgJtFJQcPJyq
3604,NJ18VUU9VH26T0A,GBP,2026-08-11,2020-04-09,0.64973001968427147068,EUR,CRDT,qupJQwFywzUjbEdwPDUA,uyOHMYgdPtMhcVdnxCZx,cLMmgjuVZtoDOwxoKrFt,Lebenshaltung:Lebensmitteleinkäuft,XQYYbtGKlEBIBnEWqecs
7263,KT85HBZNJL59FFT,USD,2022-11-27,2024-07-15,-37374807.958562351366178447898590057117071635996571407457660485745838424652255349422449887484,GBP,DBIT,uHcJnYVJYKDQMoFMjGXC,PQGdgBCLypSRkHiJYdev,rGwrTRHsFHjLPYhFpMTJ,,dIxwCBlTvNJpYULlJAaS
9675,BJ715E6CD1OIU6T,CHF,2020-12-16,2024-01-07,-5040941689111955157468104725713611935085598625070764074521071414015940351382113590909197304063575.4515354590973595602470431141798115590834959037133034277462703823,GBP,DBIT,HGLztVTGOpGnChJsjprC,qLFITvGssIbHlFBfuZyg,SWQbyfBlwfdSCnoAtwmn,Beruf:Gehalt:super Firma,jtabNPVEZOlBVnaIvamZ
2631,BB0000NISDKBWM1,USD,2023-12-09,2021-09-25,35870065450695.1976,GBP,DBIT,gtwRCGANaKlcNLbLqXCW,qhkoHtHuLrwxdDjUitUM,SMAvrVhBeuNflTTfByZh,Lebenshaltung:Lebensmitteleinkäuft,JodnUWDKlXidVgYIIkZo
7105,FM81K0IHYG2B1K8,CHF,2022-07-17,2021-04-08,7605971335219411809496337191273512429.19144246140951276630353263005965894263583159362498576671567919,GBP,DBIT,bvkiJapOkQoCqDQaeSsj,wcUfBTvFUoxaSRsaNbOk,wzNzdPXmfLEXEgEvtXBP,Lebenshaltung:Lebensmitteleinkäuft,mQFwQMQRmhadGjzUhyIP
4791,XO29KPUGVXKV7P4,GBP,2025-05-09,2025-03-06,-3047323302463055726328888340075326228246153418725869094694662399.9369795962317797302779791691824533,CHF,CRDT,WPBXOTiXiJFuZyjStJxI,bKFooYlWkOIHkeDlcJxu,OXIINcOhpetOqPvcLwkf,Freizeit,jHrZgYXhlOxQlibgCCRq
9872,NX87N16NYF88GY3,CHF,2024-09-17,2022-11-07,-7503175603096494776785070745277206265109071257232396346239801093970295294378754146262745092564314724.58147937995295429167115163695901432938,USD,DBIT,PskmmmqmEWvvjVwdTbpa,IKnFvpHdkVECQXMJgPEb,XgxsPRqDemqlLkHurTtC,Lebenshaltung:Lebensmitteleinkäuft,iDHDfFpVGofjugPlXzMa
2685,DI50CT1EAUXO5ZQ,CHF,2020-11-27,2022-08-17,804255947854847664075204915880058282080805548244876707562721818851111658361963075718333712782.054580103972864189422158609687925786112724117046925451325120196326468662088148238188624,GBP,DBIT,EUSdiqhdVykkRxbeknBL,aUXqpaJrSZvchOtFcDZK,ruepxYDTWMuSxXoNiEBQ,Beruf:Gehalt:super Firma,tSCewecchqoQJuQXVXEr
6119,MJ453EJXQAF5KF9,USD,2020-06-02,2020-07-17,18687868432897828853760232671664003846966802581954654987871.542014558313629353382101420030766,EUR,DBIT,HhAovEIGHJoxAVgFeCVG,iUoROrwSAhKLpKuJqQZl,xHvmkmSMiiuCtJVFZDpN,Freizeit,GgDhdWxjgwwJUmNQOEzl
1036,DE07271846982018433140,GBP,2026-07-04,2023-10-12,608251.74,CHF,CRDT,Wohl Straße als gut Zeit laufen wenn Wissen.,Scholtz AG,DE05340442071223320995,Beruf:Gehalt:super Firma,Krank mich erschrecken Leute den nehmen fahren man.
4849,DE42187219246703133408,CHF,2023-09-10,2025-08-05,725899.19,USD,CRDT,Berg im Bruder fröhlich Herr wahr wer leben.,Oestrovsky,DE58802771240736400847,Freizeit,Maus wohnen vergessen Vater plötzlich Schule Meer fallen.
1914,DE70452496991202055362,USD,2025-04-15,2025-12-24,984194.63,GBP,CRDT,Schreiben sollen voll ab Wort kochen schlimm wohl.,Beckmann,DE59790637683034706744,Freizeit,Anfangen fünf weg Woche Boden schreiben hier hängen.
6930,DE67943298499847570990,USD,2025-11-09,2021-09-08,156898.87,USD,DBIT,Verlieren sein dich Papa fragen ließ denn packen.,Heinz,DE54265758309837023543,Beruf:Gehalt:super Firma,Halbe so Leute Bild Schuh an früh traurig.
1699,DE84149253738055473900,EUR,2026-08-01,2021-07-20,818063.51,EUR,CRDT,Rad grün schlecht Pferd beide noch erschrecken Uhr.,Hofmann Steinberg Stiftung & Co. KGaA,DE21220975806758335585,,Neun man kam sechs blau Sache langsam Blume.
7046,DE82274490061461449020,EUR,2023-01-23,2024-05-14,669696.87,USD,DBIT,Vogel hinein Tante brauchen Weihnachten nicht klein braun.,Hübel GmbH,DE39283738756876306318,Freizeit,Ja Loch Winter rufen laufen Woche gelb hinter.
366,DE25389044843010198038,USD,2024-02-09,2023-03-08,220167.30,USD,DBIT,Gefährlich nein Mann nie schlecht Arbeit treffen damit.,Scheuermann,DE77031936189338919289,Lebenshaltung:Lebensmitteleinkäuft,Sofort Blume ging warten gewinnen kaufen Wissen später.
6922,DE73307102808802733774,USD,2025-03-20,2023-01-30,129827.89,EUR,DBIT,Rennen jetzt damit Brief See dumm stellen legen.,Reuter auch Schlauchin GmbH & Co. KG,DE22372707001113794894,Lebenshaltung:Lebensmitteleinkäuft,Hart ins sagen fest eigentlich wo schlimm ganz.
9114,DE16057368814344996724,USD,2021-03-08,2020-10-16,725225.13,EUR,DBIT,Kam waschen Wohnung Sache sprechen kaufen gern spielen.,Hentschel Wilms KG,DE41311487016568247161,Beruf:Gehalt:super Firma,Haare Winter bauen Mädchen gesund Teller hoch deshalb.
6739,DE05970178849203811396,GBP,2020-05-13,2020-08-18,333023.84,GBP,CRDT,Bin schicken unser Tier will endlich dem gefährlich.,Heuser Wiek KG,DE75493228729510129693,Beruf:Gehalt:super Firma,Versuchen etwas fertig den schnell fast Bein Freude.
1278,DE96528552228817399257,CHF,2024-07-25,2024-11-16,385205.95,EUR,CRDT,Auge darin ohne Schnee Frage stark mein oft.,Scholl,DE70979826731102142893,Lebenshaltung:Lebensmitteleinkäuft,Spät Minutenmir Brief Essen geben heiß nah warm.
406,DE89506092918989440859,EUR,2024-11-13,2026-03-18,978962.38,CHF,DBIT,Kaufen turnen Weg dick ging vom rund Sommer.,Thies,DE36862593824516545526,Beruf:Gehalt:super Firma,Für Zeit Fenster Abend warten Geschichte dann stellen.
2026,DE14445771047668409127,GBP,2020-05-06,2024-02-20,967493.86,EUR,CRDT,Glück verlieren Wetter Bauer turnen baden Freude spät.,Mohaupt GmbH & Co. KG,DE52407611264263373889,,Winter nicht bis beide hängen Rad Meer lassen.
3041,DE58304085373743696607,USD,2024-03-10,2026-09-08,807803.15,EUR,CRDT,Auch schlimm groß bauen Welt das alt alle.,Hartmann AG,DE37816718253864239200,Lebenshaltung:Lebensmitteleinkäuft,Spiel erst nicht gelb Vogel über fahren Hilfe.
5405,DE05257576358009350425,GBP,2024-05-30,2022-05-29,611553.21,EUR,DBIT,Darin gut ihn reich mit mögen dauern Ferien.,Rosenow,DE29315257044202664260,Lebenshaltung:Lebensmitteleinkäuft,Tief Tag Polizei Zahl Sommer Abend neun vom.
6543,DE91371884570994329173,CHF,2025-07-16,2022-09-05,762232.56,USD,DBIT,Monate Glas waschen Brief fliegen gesund Essen kam.,Jäntsch Ullrich AG & Co. KG,DE33181542563621737977,,Versuchen waschen gestern Fahrrad Arbeit zwischen können ich.
5190,DE13303497125457728906,CHF,2020-06-26,2022-01-18,944585.95,EUR,DBIT,Das Sonne Klasse frei beide dort Vogel fiel.,Margraf,DE67573080640653441068,Beruf:Gehalt:super Firma,Reich fiel führen Tisch fertig gesund Stein Wagen.
6694,DE32400010286267483201,GBP,2024-02-15,2022-09-05,896628.93,EUR,CRDT,Genau Wiese danach auf Land Fuß Zahl braun.,Weinhold,DE82843035570517646373,Beruf:Gehalt:super Firma,Fehlen Licht hinein Frage möglich Hund sechs danach.
2153,DE70374437913789686109,USD,2024-02-23,2020-07-19,809680.27,USD,CRDT,Schon ließ Arzt Arbeit müde hoch Tag auch.,Roskoth,DE61342749791049237138,Beruf:Gehalt:super Firma,Zu weit Freund Brief noch durch seit sofort.
1462,DE07599923076063186293,USD,2022-03-05,2021-11-29,563454.79,CHF,CRDT,Bei stark über denn wo Lehrer gibt las.,Gehringer AG,DE71382372368571415704,Beruf:Gehalt:super Firma,Schwarz finden laut werden Zug Vater aber schreiben.
4268,DE75725070066084697801,USD,2023-12-21,2025-06-24,461694.56,GBP,DBIT,Schule Küche Erde letzte Schnee halten und sechs.,Scholtz Pärtzelt AG & Co. KG,DE63255914448215384283,Lebenshaltung:Lebensmitteleinkäuft,Zurück spät trinken Tür Stunde Boden Licht Ende.
3951,DE05688473582233220472,GBP,2022-08-15,2023-01-18,998250.23,GBP,DBIT,Weit draußen Feuer eigentlich wünschen Wagen fertig tragen.,Binner AG & Co. KGaA,DE73947638506756550295,Beruf:Gehalt:super Firma,Dumm verlieren nehmen erschrecken fressen zurück hängen weit.
1124,DE90298843179938014585,GBP,2023-11-14,2023-07-05,371813.63,USD,DBIT,Platz fragen wer weinen Geburtstag ganz einigen gar.,Boucsein Stiftung & Co. KG,DE05290733575870594578,Lebenshaltung:Lebensmitteleinkäuft,Weg andere wohnen steigen Hilfe schaffen mehr das.
1744,DE13238979080075655943,GBP,2022-12-14,2025-02-19,579578.97,EUR,DBIT,Immer fiel wieder überall bald legen Tisch wahr.,Heser Hahn GmbH,DE68405054791747194888,Lebenshaltung:Lebensmitteleinkäuft,Fünf See neu früh wird fehlen sonst wer.
5892,DE04011963778496207452,USD,2025-05-20,2023-05-10,648012.28,EUR,DBIT,Polizei weinen gut Kind immer Ding oder Licht.,Jacobi Jäckel,DE13372573108475954117,Lebenshaltung:Lebensmitteleinkäuft,Fragen immer acht Sache heute nämlich Platz warum.
362,DE92124261075456158918,EUR,2022-10-26,2025-08-31,299548.47,CHF,DBIT,Laufen ihr lassen Pferd ruhig müssen Hund unten.,Sölzer Meyer OHG mbH,DE93416735067530701655,Beruf:Gehalt:super Firma,Schwimmen voll neun beim sehr dich Ferien Nase.
9327,DE56968036606213916258,EUR,2022-09-12,2025-06-10,669422.17,EUR,CRDT,Reich kochen Ende früher denken Gott bauen Wagen.,Rogge Carsten AG & Co. KG,DE17055312400520689014,,Schwer verkaufen oder gefährlich sprechen Hunger rot ins.
680,DE41089047416450094816,GBP,2025-06-19,2020-11-14,514360.52,EUR,DBIT,Essen schlimm schnell sich vor drei Ding schwimmen.,Stroh Margraf GmbH,DE30951508691902962996,Freizeit,Drei gelb Meer mit rot Hand da führen.
4786,DE42895215744806933501,USD,2024-10-12,2021-06-23,421775.78,USD,DBIT,Rennen rund und acht werden wieder richtig Tür.,Pieper KG,DE50031826122338744320,Lebenshaltung:Lebensmitteleinkäuft,Monate Licht bis Himmel nächste von Stück ab.
5155,DE48350990582715356385,EUR,2024-01-16,2020-07-12,132026.65,CHF,DBIT,Freude setzen offen bauen offen aus Bruder wirklich.,Oestrovsky Mülichen Stiftung & Co. KG,DE31183689182369696496,,Hund legen Schiff einige Brief schlecht jeder voll.
9202,DE79223829316538123746,CHF,2024-10-23,2026-09-07,683650.17,GBP,CRDT,Platz gelb heiß uns Abend schreien bis ziehen.,Meyer OHG mbH,DE85492444746577878500,Lebenshaltung:Lebensmitteleinkäuft,Angst schwarz war Auge darauf Lehrerin gerade oft.
4370,DE96935887403881776524,CHF,2022-03-09,2025-06-12,238525.09,GBP,DBIT,Sitzen Katze vergessen schicken laufen spielen deshalb weiter.,Müller,DE64513317359137578745,,Gesicht fangen klein Glas ein ohne seit Gesicht.
6861,DE53091086333961111752,EUR,2022-01-18,2020-04-09,949477.16,GBP,DBIT,Gerade genau dürfen gelb klettern nächste zum Bauer.,Schleich,DE55073850335806125294,Beruf:Gehalt:super Firma,Mit weil rechnen Hund Wiese einmal ja gern.
1968,DE98454978214248264491,GBP,2024-01-14,2022-09-19,435993.30,CHF,CRDT,Apfel setzen Polizei einfach Minutenmir schnell zwischen vom.,Schulz GmbH,DE08932615790658012392,Beruf:Gehalt:super Firma,Schwer spielen Leben sind brauchen Finger dabei Essen.
934,DE72179461629364066237,GBP,2020-12-07,2023-12-31,936759.29,GBP,CRDT,Selbst aber treffen springen setzen hoch lesen zusammen.,Textor Schaaf AG & Co. KG,DE68736100578473881809,Lebenshaltung:Lebensmitteleinkäuft,Warm unser gehören Zug früher schlagen stehen langsam.
288,DE36079718826004443058,USD,2025-03-20,2024-07-16,410828.73,GBP,DBIT,Bauen Geschenk Hund beide Stück Sohn ab denken.,Drewes,DE37024088386878351977,Lebenshaltung:Lebensmitteleinkäuft,Dürfen jetzt danach Oma See alt Flasche sofort.
909,DE07992119720440262287,CHF,2024-02-20,2020-10-05,713491.26,GBP,CRDT,Ende krank Arzt Wohnung finden vom Blume Monat.,Kohl Ortmann AG,DE46570978124442579293,,Heute Haus nach ab heißen versuchen Schüler wahr.
6200,DE65667575255962659468,USD,2024-12-29,2023-07-30,859574.20,EUR,CRDT,Erklären schreiben Frau kann leise Papa Arzt fliegen.,Wulf Grein Groth AG & Co. KG,DE45122342340503421393,Freizeit,Pferd gut schenken spät wirklich Kind bauen lange.
5198,DE82737311374514326219,USD,2022-09-14,2025-12-23,899940.80,EUR,CRDT,Glauben wissen ab sehr zwischen Frau essen Minutenmir.,Schwital Mohaupt Stiftung & Co. KGaA,DE31111358336117661206,Lebenshaltung:Lebensmitteleinkäuft,Ihm rechnen drehen etwas jeder Gesicht zusammen vorbei.
2088,DE84805952945908475488,GBP,2020-06-23,2023-03-19,706456.94,CHF,CRDT,Gerade zeigen Stück ziehen dumm Minutenmir alt Brief.,Thies GbR,DE30848702698273936264,,Fröhlich kaufen im gefährlich gar Zeitung leben voll.
4094,DE96513254882011465283,USD,2021-03-12,2023-08-10,844148.26,GBP,DBIT,Fußball führen Tier nichts hoch Lehrerin brauchen grün.,Meister,DE39122919808000752227,Beruf:Gehalt:super Firma,Müde schauen rechnen Geschichte Apfel kann daran weinen.
407,DE34550143031579571279,GBP,2020-03-01,2021-10-24,802001.66,EUR,CRDT,Scheinen wahr Luft merken Hilfe Sommer Weg sprechen.,Sager AG,DE95396414587780784446,Freizeit,Wasser jeder los werfen Loch Kopf Sommer stehen.
1882,DE93756550001006525563,GBP,2020-05-22,2020-04-02,255936.18,CHF,CRDT,Schüler Wetter unser Nase lesen erklären Herr damit.,Bohlander e.G.,DE33912765450255003824,Lebenshaltung:Lebensmitteleinkäuft,Vielleicht Papa Sonntag Glück dem krank Ende Lehrer.
674,DE34636975346401640379,GBP,2025-04-21,2023-07-13,107958.15,GBP,CRDT,Um rufen Schnee nämlich leise Tag gesund hin.,Ruppersberger Förster AG & Co. KG,DE82042132477947420315,Beruf:Gehalt:super Firma,Ich wo Oma Leute gegen früh Herz hoch.
4938,DE18280828921382275721,CHF,2024-09-06,2026-06-01,307489.25,USD,CRDT,Zehn los stark wissen vor dein an Schule.,Holsten Bien e.G.,DE54634488965756369503,Freizeit,Er merken Seite von früher schlimm weit gestern.
7493,DE75969937757392474988,EUR,2024-07-01,2025-05-15,769646.98,USD,CRDT,Wollen kalt alt uns acht heißen deshalb Licht.,Losekann Mentzel AG,DE06861731830241040674,Beruf:Gehalt:super Firma,Sehen los Herr dort ließ fährt gibt als.
7741,DE27930829328207938603,USD,2023-12-10,2021-12-25,332130.35,CHF,DBIT,Unser Essen Ferien ließ Lehrerin bringen trinken wichtig.,Beer Dehmel OHG mbH,DE56877468032283033000,Lebenshaltung:Lebensmitteleinkäuft,Weil Dorf verlieren deshalb wirklich Himmel dein schreien.
8664,DE17107663775980137420,USD,2020-12-02,2026-04-02,695808.54,EUR,CRDT,Da aber frei schlafen sicher Ding schreiben endlich.,Steckel,DE34731945010152636545,Beruf:Gehalt:super Firma,Plötzlich beißen bringen Boden auch verstecken lieb vorbei.
7929,DE32942674407704323077,CHF,2020-09-06,2026-09-09,165324.34,EUR,CRDT,Sehen ob Bruder drei Kind dumm Hilfe auf.,Spieß Fröhlich KGaA,DE68174009519788157086,Freizeit,Stunde Hilfe ganz nie las kam oft voll.
8900,DE03689805605701261449,USD,2021-03-03,2020-11-12,186334.15,USD,CRDT,Brief gern der Eis Freude lange schlagen Flasche.,Radisch Keudel GbR,DE49885385858776558412,Lebenshaltung:Lebensmitteleinkäuft,Dafür singen ein sonst uns Mensch ins Ding.
1 Id1 OwnrAcctIBAN OwnrAcctCcy ValDt BookgDt Amt AmtCcy CdtDbtInd RmtInf RmtdNm RmtdAcctIBAN Category Notes
2 546 1036 BB94K6C321C502O DE07271846982018433140 GBP 2025-12-29 2026-07-04 2021-05-31 2023-10-12 966249079068317412764571542407919514412758243437140957631412514241265507048511272714647319346008254.45380506761321740967397160412882982325923639777828329138314768657367549356366752060752331132 608251.74 GBP CHF DBIT CRDT xobiDcwzRKxgCdlMhOqA Wohl Straße als gut Zeit laufen wenn Wissen. tKVeYnvYCbpbogKulljk Scholtz AG TDgXpTthOVshHpNKRvbQ DE05340442071223320995 Beruf:Gehalt:super Firma WGCaHsVAXWVeCLsPbdrN Krank mich erschrecken Leute den nehmen fahren man.
3 3130 4849 CY01AL0ZPFKXL0Z DE42187219246703133408 CHF 2020-04-27 2023-09-10 2025-04-02 2025-08-05 -91380510785950845491826614519754504907024428700812006803291502570873.211019996196381685494424904026971409543 725899.19 EUR USD CRDT aYEtztGZMJkkjbGNhrGl Berg im Bruder fröhlich Herr wahr wer leben. CbvOzQGjcTWWXPrKfyjp Oestrovsky nOBawCuxZMLTiFKuZnsx DE58802771240736400847 Freizeit MVznQDrIqmCmYrTDhBbp Maus wohnen vergessen Vater plötzlich Schule Meer fallen.
4 5356 1914 AE43DPLDC66ENP6 DE70452496991202055362 USD 2026-01-09 2025-04-15 2020-03-27 2025-12-24 12495147566547030449243528706787437899952753255929949.510436466181518004685 984194.63 EUR GBP DBIT CRDT lckOKAAYRGuzCUqnXbfW Schreiben sollen voll ab Wort kochen schlimm wohl. vOGbcayDxBlnejGwfLjA Beckmann xxjAuYKPhMmqaMfZDhAw DE59790637683034706744 Lebenshaltung:Lebensmitteleinkäuft Freizeit iwUPRIJRxPSlRXcWDWNo Anfangen fünf weg Woche Boden schreiben hier hängen.
5 5605 6930 NS44V0K3D40LZEC DE67943298499847570990 GBP USD 2021-12-22 2025-11-09 2021-06-05 2021-09-08 0.5943269284524336922642283862359244002375250667 156898.87 CHF USD CRDT DBIT joJcLPuIFvxHxnpTMRZS Verlieren sein dich Papa fragen ließ denn packen. LRMmWOiXpcpNkZNCotyG Heinz WawuMyUUpqZRmIWRpzOF DE54265758309837023543 Freizeit Beruf:Gehalt:super Firma AUSRXmzbzyZwBREOZOgi Halbe so Leute Bild Schuh an früh traurig.
6 9030 1699 UL50MIF1W1KAS3C DE84149253738055473900 USD EUR 2024-03-13 2026-08-01 2021-03-07 2021-07-20 94229449126346152407566499816383306669237.8035079624692824996218720107585445554234173 818063.51 USD EUR CRDT AcQtetkWOZpHsvpFAFLY Rad grün schlecht Pferd beide noch erschrecken Uhr. sIieEkhvUFSOsLcLPzIq Hofmann Steinberg Stiftung & Co. KGaA nUOVSAvyyjaXaGrtwHkQ DE21220975806758335585 Freizeit xdNHKUVfoqzfwCykZIvd Neun man kam sechs blau Sache langsam Blume.
7 5139 7046 KN98I6KMGOBJGDQ DE82274490061461449020 GBP EUR 2021-01-08 2023-01-23 2021-12-18 2024-05-14 -15902928538656294489371181513645784480574990906804212434837506917428638594538599376635051278636.78520642113315033999770538105015205921897385545146693383944328 669696.87 USD CRDT DBIT lMrbuAYxXwuHurNyFYzg Vogel hinein Tante brauchen Weihnachten nicht klein braun. PVoYOsiYrgiycTpYqwaq Hübel GmbH EaPFovWogTiCIIVqPnHZ DE39283738756876306318 Beruf:Gehalt:super Firma Freizeit rZVnmZqzUaqgrTejRQBv Ja Loch Winter rufen laufen Woche gelb hinter.
8 3262 366 VP35ANQ489KSRBJ DE25389044843010198038 CHF USD 2022-02-11 2024-02-09 2022-12-15 2023-03-08 1456.3272931 220167.30 EUR USD DBIT ZSDukJoElAhJnbqxCemY Gefährlich nein Mann nie schlecht Arbeit treffen damit. gQZioaMYeNkeDIWUJQOJ Scheuermann DaRRlkzDhfzwgqsmyKfI DE77031936189338919289 Lebenshaltung:Lebensmitteleinkäuft RmcCnGYIJwwXhUoDebtx Sofort Blume ging warten gewinnen kaufen Wissen später.
9 6370 6922 EG06AML3VX70NBZ DE73307102808802733774 CHF USD 2020-10-30 2025-03-20 2026-06-24 2023-01-30 -85985373736852359001228845964.12923 129827.89 GBP EUR CRDT DBIT nGcVCPmxqcCWOwLCqNcL Rennen jetzt damit Brief See dumm stellen legen. ELTpZKLLPxtkBQpFfcXO Reuter auch Schlauchin GmbH & Co. KG QqCDEBwHcqhYqNmrIjRJ DE22372707001113794894 Lebenshaltung:Lebensmitteleinkäuft MlltKALcNRksLbrBdqHB Hart ins sagen fest eigentlich wo schlimm ganz.
10 9610 9114 LS00DDSRWKI2GCN DE16057368814344996724 CHF USD 2022-12-24 2021-03-08 2023-11-13 2020-10-16 111302702519553016947918391140.83336651886149677841069515026199226910390124216959835264362430572949455458094205858204009469 725225.13 GBP EUR DBIT rIqXIlJHQUmNryiFhZgl Kam waschen Wohnung Sache sprechen kaufen gern spielen. fmlShtEaPYxDxLPLVFtA Hentschel Wilms KG ZuEmBQavkZkliTgQslkX DE41311487016568247161 Beruf:Gehalt:super Firma QxxGfOhHEKlRIBtxLBzN Haare Winter bauen Mädchen gesund Teller hoch deshalb.
11 7958 6739 IM49W0MERUPAEYL DE05970178849203811396 GBP 2024-09-23 2020-05-13 2026-08-07 2020-08-18 997654494541331560967.23894678707663633993031462271276158599265860951730613416835071421468 333023.84 EUR GBP CRDT lIFaiRDOHpPsLZYMvSLL Bin schicken unser Tier will endlich dem gefährlich. COjPCyRHmQJAJjXpHtoc Heuser Wiek KG wLGNwAmIoguSFiPOwhop DE75493228729510129693 Beruf:Gehalt:super Firma wIVvfgWAGpXaNtmCMdtl Versuchen etwas fertig den schnell fast Bein Freude.
12 81 1278 NB13I5PF8RT1KBF DE96528552228817399257 EUR CHF 2023-10-01 2024-07-25 2025-10-14 2024-11-16 -623.171747883897331930600196592034302396711514091988656250527836008950413955561 385205.95 EUR CRDT gKujLbFKPTJWNHCwLEbP Auge darin ohne Schnee Frage stark mein oft. BNNSobmkOJYvruTmVpsY Scholl JFTexNOzIHpIDYxFyxXC DE70979826731102142893 Lebenshaltung:Lebensmitteleinkäuft EXRfMRisDxjfvTFbbScl Spät Minutenmir Brief Essen geben heiß nah warm.
13 4306 406 MA16EMD5TXB6W2Q DE89506092918989440859 CHF EUR 2022-03-09 2024-11-13 2022-07-04 2026-03-18 1967316692371140884668.697876470482728723376342302791065723059022794527986321223057196172445638187219626476457610180 978962.38 EUR CHF CRDT DBIT PxRyYSeHHCnLsOGvkJBR Kaufen turnen Weg dick ging vom rund Sommer. tdUmPUqXPyVAwcMonTOz Thies FkhuAmZMFEYHXfIRbYNa DE36862593824516545526 Beruf:Gehalt:super Firma hoOqFYFhQZMNFflTTRzr Für Zeit Fenster Abend warten Geschichte dann stellen.
14 1340 2026 QC15X8QJ925LIIZ DE14445771047668409127 USD GBP 2023-05-13 2020-05-06 2024-10-19 2024-02-20 51002714193280774680213206683817944775146555520957438367304201512763096320304094279.5917694840241093438785611670208852758416238660282666099607713108189846992146195070603042071073 967493.86 USD EUR CRDT KXmqOKVhWFhNyeTrYcqi Glück verlieren Wetter Bauer turnen baden Freude spät. xrXHIHUrhVotQHkfTSbR Mohaupt GmbH & Co. KG XftIqQPwfIZaQWPgfvTq DE52407611264263373889 Beruf:Gehalt:super Firma aIBfbrbYXZILZipenuQs Winter nicht bis beide hängen Rad Meer lassen.
15 6971 3041 IU89H1O656FOARG DE58304085373743696607 CHF USD 2023-01-05 2024-03-10 2023-01-26 2026-09-08 -635472806525340497640508823188167.73094955967757797936541852850962382275616205624981776791984877714957051928106426532873075 807803.15 EUR CRDT aBFnGYtfSndKKDcuKbOA Auch schlimm groß bauen Welt das alt alle. mFDeQxsPDxRwgSxHoPar Hartmann AG rWitPpvexQZfYaRknvVZ DE37816718253864239200 Lebenshaltung:Lebensmitteleinkäuft SdKWfeCRdmIuQtpYXpcP Spiel erst nicht gelb Vogel über fahren Hilfe.
16 5765 5405 YO97BHIS7F13W5T DE05257576358009350425 CHF GBP 2025-04-27 2024-05-30 2024-09-23 2022-05-29 -84170840.062065298147641969564638230620262747954 611553.21 EUR DBIT PQzlBfLSMjHPhmzCNWlW Darin gut ihn reich mit mögen dauern Ferien. OAuifNsHLzDRYOshTltg Rosenow SrdmTqlAcjYZqspXUqWl DE29315257044202664260 Beruf:Gehalt:super Firma Lebenshaltung:Lebensmitteleinkäuft gdyHSZQRVGVDljyWukxF Tief Tag Polizei Zahl Sommer Abend neun vom.
17 873 6543 CU84CRHLBLDVPG4 DE91371884570994329173 USD CHF 2024-09-10 2025-07-16 2021-04-05 2022-09-05 31708414063036408560039191005.628517576703091956518065559718890786318411169242277168731009729 762232.56 EUR USD DBIT jEqWELcluqYzeoFKWrSv Monate Glas waschen Brief fliegen gesund Essen kam. rMeRFYSEneKpjTQxTAWE Jäntsch Ullrich AG & Co. KG irpXsNFkucPYaXhtiGVU DE33181542563621737977 Beruf:Gehalt:super Firma tDYCusMlvSzPxJtAoGwU Versuchen waschen gestern Fahrrad Arbeit zwischen können ich.
18 7512 5190 OK88REZY7LC3ZJO DE13303497125457728906 USD CHF 2024-06-20 2020-06-26 2024-02-12 2022-01-18 -91309800414850316424146855043265998301745409273389966051295090800556208516373.6106630 944585.95 EUR CRDT DBIT RjKWAFvwkGwehDeVgAMe Das Sonne Klasse frei beide dort Vogel fiel. ZBCofGxOxAuhCNdoRwJK Margraf CBjHBmiutfFjnNqGNsys DE67573080640653441068 Freizeit Beruf:Gehalt:super Firma ibeMySwyUuaKJmopdFeo Reich fiel führen Tisch fertig gesund Stein Wagen.
19 468 6694 ZI7268MWFCVKJET DE32400010286267483201 GBP 2021-07-08 2024-02-15 2023-08-04 2022-09-05 -9566140937946527477623537138113287213.7764645148994252143659337484894589906652837718944384583356873367 896628.93 GBP EUR DBIT CRDT dHnVUadgIXaQokZAaddZ Genau Wiese danach auf Land Fuß Zahl braun. MJpKJyJXAGVFHGXqyeLb Weinhold jgWKQYgfzCjzpWDqSfMV DE82843035570517646373 Beruf:Gehalt:super Firma rRrGzQHiNWxftPiobuBl Fehlen Licht hinein Frage möglich Hund sechs danach.
20 6125 2153 VX319YHC45XB9FI DE70374437913789686109 CHF USD 2024-04-08 2024-02-23 2023-11-02 2020-07-19 -404943589.503 809680.27 EUR USD CRDT DLgsRpbgvYUZiFwwfxYX Schon ließ Arzt Arbeit müde hoch Tag auch. NFCMuqKchwfGolxUoszx Roskoth wToRonKwFFOWrnmfMWZd DE61342749791049237138 Beruf:Gehalt:super Firma EiYGrzzlsMweQPlFHdlx Zu weit Freund Brief noch durch seit sofort.
21 5352 1462 WI30AMBG4K8GC5M DE07599923076063186293 USD 2022-07-28 2022-03-05 2023-07-12 2021-11-29 298801846977579969716883410099510711907273580263884196580644541848777146559224.91128608265378630536063943513079726365933334413452853303576298418192825 563454.79 GBP CHF DBIT CRDT BkBNoWLKZmNptdlkcdFy Bei stark über denn wo Lehrer gibt las. jdCcnjNmSYMVZYBAqXpk Gehringer AG KdVUoLgaiQoorPtFXfuD DE71382372368571415704 Lebenshaltung:Lebensmitteleinkäuft Beruf:Gehalt:super Firma aPilIRVyMZhDaLreCtVB Schwarz finden laut werden Zug Vater aber schreiben.
22 8157 4268 AS89HYB3PJOCKGQ DE75725070066084697801 USD 2021-12-11 2023-12-21 2021-05-02 2025-06-24 -5383257045022212194476332790.58249908408715153448695588386599010825479811059729154822 461694.56 USD GBP CRDT DBIT hLscFzoYamBLLTCOLtJX Schule Küche Erde letzte Schnee halten und sechs. mJzlPJMFlwXsvDWCdPEX Scholtz Pärtzelt AG & Co. KG zcgAipzPSDbNvDgsUsdf DE63255914448215384283 Lebenshaltung:Lebensmitteleinkäuft KzpTvMfREqDcraLsnAFa Zurück spät trinken Tür Stunde Boden Licht Ende.
23 8562 3951 XF55JJHCFY9VFPN DE05688473582233220472 EUR GBP 2021-10-06 2022-08-15 2023-03-07 2023-01-18 58513152657357786429578134004715672662808018369796246403461323977035786109578790774134514968087.32196519574363119760206835296320588360455621 998250.23 EUR GBP CRDT DBIT WwygiOMjDsxlmBrgMBUD Weit draußen Feuer eigentlich wünschen Wagen fertig tragen. CPkjRvCOcTGVQRxdVSco Binner AG & Co. KGaA WEmukvnPtZyCmopsKLeB DE73947638506756550295 Freizeit Beruf:Gehalt:super Firma vNxjYBDrcjIJXRYsioVn Dumm verlieren nehmen erschrecken fressen zurück hängen weit.
24 6932 1124 PK53O1QFHWMM80N DE90298843179938014585 CHF GBP 2021-11-22 2023-11-14 2022-04-29 2023-07-05 -35687720973389008791120348643747347909777063417264503383154431851328499756311481892.525588482723067939754169870785253637544209574930786950647614924757554933127740331928 371813.63 USD DBIT ocyWAbkFrEGhLKcqIUrJ Platz fragen wer weinen Geburtstag ganz einigen gar. eIdWToAESlHQGGnykQZI Boucsein Stiftung & Co. KG urMJiqlsVuLqGbGmIOdR DE05290733575870594578 Beruf:Gehalt:super Firma Lebenshaltung:Lebensmitteleinkäuft wbVGbcIRqoAyhXPQFmbn Weg andere wohnen steigen Hilfe schaffen mehr das.
25 5603 1744 LM99PWTKWNVKFUK DE13238979080075655943 CHF GBP 2020-10-03 2022-12-14 2024-10-23 2025-02-19 137912574664116210905504437194561515849372037.5300228797878339289857585366614065212461747 579578.97 CHF EUR DBIT mJHgXdjzfGTiqdPFYKJu Immer fiel wieder überall bald legen Tisch wahr. iCPXYqUtKwBTilCrsJTM Heser Hahn GmbH AqLdwtlWBsQmQaBvwHii DE68405054791747194888 Lebenshaltung:Lebensmitteleinkäuft DVEvjvPzjBahqIEOddlq Fünf See neu früh wird fehlen sonst wer.
26 7707 5892 LP36QSQ8LN9F6U8 DE04011963778496207452 EUR USD 2020-01-26 2025-05-20 2026-07-22 2023-05-10 20617377959559934081335048761128552951261867125124238167249671785612681.51317409949621954528827764556416450523039696133111537499104315164348776636640613432 648012.28 GBP EUR CRDT DBIT IcRLxekFBpooSGzJqqGw Polizei weinen gut Kind immer Ding oder Licht. rCFLSzlzvLBdEpXFPZfV Jacobi Jäckel vkMfGTUYKzrINNryKNJN DE13372573108475954117 Lebenshaltung:Lebensmitteleinkäuft tjNBGXokHzSQvBVABxCV Fragen immer acht Sache heute nämlich Platz warum.
27 7964 362 WB16POIGIKIRSRB DE92124261075456158918 GBP EUR 2025-01-15 2022-10-26 2026-07-14 2025-08-31 19212833314539080562967961976059399140703968646971623411156202797393305194593.881945095397465505661922398 299548.47 USD CHF DBIT CoxjaXrcDJVITkyHjZzv Laufen ihr lassen Pferd ruhig müssen Hund unten. IunvUBVgzwgJVzAvOyKQ Sölzer Meyer OHG mbH BcYtrPUKMrJkEKNNnYyD DE93416735067530701655 Beruf:Gehalt:super Firma awUfzbbQZVwNaLaTiFsr Schwimmen voll neun beim sehr dich Ferien Nase.
28 4725 9327 AT598204ZYZODV1 DE56968036606213916258 EUR 2024-03-25 2022-09-12 2024-08-09 2025-06-10 82856650729233542595012846123790016186861935974808282995711867343684454016512697494.88917524421022914453765603187655206018877538527053112 669422.17 EUR DBIT CRDT xlCBJxJOxqvLAjgICrbX Reich kochen Ende früher denken Gott bauen Wagen. msAsDTuxgXrTntkliYmb Rogge Carsten AG & Co. KG FXyFAuyPwdvXjTTNcPrz DE17055312400520689014 Lebenshaltung:Lebensmitteleinkäuft gwHdLpOEMZgALigVFmpM Schwer verkaufen oder gefährlich sprechen Hunger rot ins.
29 6050 680 TY84APSJTGLW0Y7 DE41089047416450094816 GBP 2020-09-04 2025-06-19 2025-08-15 2020-11-14 -3472009449932706821864839468227645291697670610604942221835405160979793916196159877775819802166678286.924897290769355741386357676998033855577136669559598846 514360.52 GBP EUR DBIT sqcQIypoDiHETEPLtswm Essen schlimm schnell sich vor drei Ding schwimmen. bkRKycwiqnHBrCWFDWvp Stroh Margraf GmbH IsMCNagZuWQgjbHotLkZ DE30951508691902962996 Lebenshaltung:Lebensmitteleinkäuft Freizeit PmxAqaLvtdKHOqufnPEr Drei gelb Meer mit rot Hand da führen.
30 1636 4786 TT56OLKXD9FFLHW DE42895215744806933501 EUR USD 2022-04-05 2024-10-12 2025-01-21 2021-06-23 -8700251749693498471853131195688590000252839147762542676728724966.70275220280 421775.78 CHF USD CRDT DBIT LYftAZAxwAbsKqKJJDox Rennen rund und acht werden wieder richtig Tür. AutZAoZFOVswxydiSajl Pieper KG peXIkhZxuXokaHbIHulP DE50031826122338744320 Freizeit Lebenshaltung:Lebensmitteleinkäuft qAADExaKKWlrrLTDOeJd Monate Licht bis Himmel nächste von Stück ab.
31 7753 5155 BS67UNQVFAG259C DE48350990582715356385 CHF EUR 2024-06-05 2024-01-16 2021-06-24 2020-07-12 24861162756159054957.670585366670739666042308388630102242932146348848932417907909353461828706673705 132026.65 EUR CHF CRDT DBIT xZhCgZOvnVzrCDKLqbhK Freude setzen offen bauen offen aus Bruder wirklich. XhDZhAWtaSbEctUEYLjT Oestrovsky Mülichen Stiftung & Co. KG aClycbHxjTAgkNnTRcuY DE31183689182369696496 LmeGwybyUMOrxHkjgLaZ Hund legen Schiff einige Brief schlecht jeder voll.
32 6189 9202 ZZ871MTLUU9A1RU DE79223829316538123746 CHF 2021-08-01 2024-10-23 2023-03-17 2026-09-07 -409780077475199424796148695159372908162375055189181880910638677.248670538597146440670239645579725174792942268017121149370963751622201496 683650.17 EUR GBP CRDT GQlNICMrebnglDvBDTYS Platz gelb heiß uns Abend schreien bis ziehen. CXDqKkEcjnJngYAqpByV Meyer OHG mbH NHVCfnmSrnFolUjPSEIr DE85492444746577878500 Lebenshaltung:Lebensmitteleinkäuft uwEFhSBDVdAPtdhJavhW Angst schwarz war Auge darauf Lehrerin gerade oft.
33 9624 4370 AU87XCMRZZWGH86 DE96935887403881776524 USD CHF 2024-09-02 2022-03-09 2022-09-19 2025-06-12 -442193693537141866795259204406415934110882954978333632301457685242868631049543099431520126945516367.745400551449 238525.09 EUR GBP DBIT cbaGAkeofJGsqaiNUEIY Sitzen Katze vergessen schicken laufen spielen deshalb weiter. ljZDuEzncPYZLDWNMgnH Müller EcVuFQSISKGSBDNIdqXJ DE64513317359137578745 Beruf:Gehalt:super Firma KhFiMeuprVqYAZtZQwIp Gesicht fangen klein Glas ein ohne seit Gesicht.
34 9203 6861 FX68NTN2D10VVKO DE53091086333961111752 GBP EUR 2022-02-15 2022-01-18 2024-06-23 2020-04-09 -534572334.4012791130107478240377375826129655674355510806820651340789640 949477.16 USD GBP DBIT tNihPvPVLoZiwWmdZZRZ Gerade genau dürfen gelb klettern nächste zum Bauer. zJGOcjBqCiLbirDJCgVR Schleich oeFcARYRdznzUieqiHxt DE55073850335806125294 Lebenshaltung:Lebensmitteleinkäuft Beruf:Gehalt:super Firma ihcfdoHuUNdBWRSOyeBB Mit weil rechnen Hund Wiese einmal ja gern.
35 3927 1968 WH16E7SHB62XKIH DE98454978214248264491 GBP 2023-10-04 2024-01-14 2023-01-05 2022-09-19 415593598910615971016549932285154.643848021414472037085778014035161865336120601218617113853923085787470877370 435993.30 EUR CHF CRDT MreRryaDFIhSFXkthwko Apfel setzen Polizei einfach Minutenmir schnell zwischen vom. AqECnwyKoevMnedwdaFc Schulz GmbH DXMnEnltpMbzNPWinMrk DE08932615790658012392 Freizeit Beruf:Gehalt:super Firma IDiYkQfxVozPBqInPIQV Schwer spielen Leben sind brauchen Finger dabei Essen.
36 6283 934 HU452U8CMEYLRNE DE72179461629364066237 CHF GBP 2022-04-25 2020-12-07 2025-03-24 2023-12-31 -31056127574668984711914.148062642678712152287686837162829872960394642611046974293342 936759.29 CHF GBP CRDT uvRVCtUcGqAYppkZfxar Selbst aber treffen springen setzen hoch lesen zusammen. VvKDwohUZmOQWHpTbbox Textor Schaaf AG & Co. KG rGrLuSeVEZhbtgGBcOFx DE68736100578473881809 Lebenshaltung:Lebensmitteleinkäuft mNqLNmnFbTXThCWaoBpa Warm unser gehören Zug früher schlagen stehen langsam.
37 4041 288 AR48BWK3T5RZ570 DE36079718826004443058 GBP USD 2025-10-10 2025-03-20 2020-07-06 2024-07-16 1812531959.04051886610531349 410828.73 EUR GBP DBIT VICxZqioNNFBXEnZvvHZ Bauen Geschenk Hund beide Stück Sohn ab denken. HHKlheyBNtageACANISB Drewes mPbiYFXCzowXbuRckcJU DE37024088386878351977 Freizeit Lebenshaltung:Lebensmitteleinkäuft jAtyPevPSKJdGTVjowmo Dürfen jetzt danach Oma See alt Flasche sofort.
38 7217 909 OZ17J7ZD7YG7O1A DE07992119720440262287 USD CHF 2024-01-23 2024-02-20 2024-06-19 2020-10-05 -968844607026826575302933681105715018281543501601087944362850708075305.66200706173538352524957865510877 713491.26 GBP CRDT RQxftSphrPpwMKVlnKpn Ende krank Arzt Wohnung finden vom Blume Monat. uqNrspSFVXYYPveWyuNv Kohl Ortmann AG CWyQeuLcZPAuGlNWjGdM DE46570978124442579293 Lebenshaltung:Lebensmitteleinkäuft HAyMDEDnKqtDgfIYZDiC Heute Haus nach ab heißen versuchen Schüler wahr.
39 2615 6200 WX91BRBHNDBXKU0 DE65667575255962659468 EUR USD 2021-10-30 2024-12-29 2025-11-23 2023-07-30 -8343466178334617252604602.569949636202667336077288532506921508691215873161097 859574.20 CHF EUR CRDT RyhUcmsrSAXNUaXevPMB Erklären schreiben Frau kann leise Papa Arzt fliegen. bFIZFcQCTivRZcMYhJAx Wulf Grein Groth AG & Co. KG mfRHcvsWGYIMPHtWOYpm DE45122342340503421393 Lebenshaltung:Lebensmitteleinkäuft Freizeit PHOtyKuWgmyZaxJYQPjO Pferd gut schenken spät wirklich Kind bauen lange.
40 251 5198 ZJ430BMO81CDA44 DE82737311374514326219 EUR USD 2025-08-08 2022-09-14 2022-04-02 2025-12-23 -944177666499883169717087768179297037355028367029688127.633698125531 899940.80 USD EUR DBIT CRDT lUaArRWPnlxRwoWKSDyZ Glauben wissen ab sehr zwischen Frau essen Minutenmir. jFSXDqiPxpdkDkjduezx Schwital Mohaupt Stiftung & Co. KGaA ndEGQjdezYvakfXmSeer DE31111358336117661206 Freizeit Lebenshaltung:Lebensmitteleinkäuft NbDQJseGHjiMLMFmhlQX Ihm rechnen drehen etwas jeder Gesicht zusammen vorbei.
41 8432 2088 RG9882TQXT40HDH DE84805952945908475488 GBP 2020-05-29 2020-06-23 2026-06-29 2023-03-19 -4620473059978062834290693674666409952444643118783437106239403117968074841905810680891909.179130304762746994760945084576 706456.94 GBP CHF DBIT CRDT QakUGBIAuAmdabucXlOf Gerade zeigen Stück ziehen dumm Minutenmir alt Brief. WlRItduFSMEjCwmkbVPV Thies GbR haAnIWAPZnqWKJHZubtA DE30848702698273936264 Freizeit tZHnaMEcNMMxpvFZAdjx Fröhlich kaufen im gefährlich gar Zeitung leben voll.
42 9952 4094 JF78B330IL8AKEQ DE96513254882011465283 CHF USD 2023-03-16 2021-03-12 2023-01-31 2023-08-10 -4493693464311613709632110286395250278421205912044580776714732064115337203268.547530 844148.26 GBP DBIT hTIWMdfJKdCycCtcVZIa Fußball führen Tier nichts hoch Lehrerin brauchen grün. OABZhgwiXUqfXMmSklHk Meister GpItXzAVpjsAvDFHxtHP DE39122919808000752227 Freizeit Beruf:Gehalt:super Firma QbKsSKahDgJtFJQcPJyq Müde schauen rechnen Geschichte Apfel kann daran weinen.
43 3604 407 NJ18VUU9VH26T0A DE34550143031579571279 GBP 2026-08-11 2020-03-01 2020-04-09 2021-10-24 0.64973001968427147068 802001.66 EUR CRDT qupJQwFywzUjbEdwPDUA Scheinen wahr Luft merken Hilfe Sommer Weg sprechen. uyOHMYgdPtMhcVdnxCZx Sager AG cLMmgjuVZtoDOwxoKrFt DE95396414587780784446 Lebenshaltung:Lebensmitteleinkäuft Freizeit XQYYbtGKlEBIBnEWqecs Wasser jeder los werfen Loch Kopf Sommer stehen.
44 7263 1882 KT85HBZNJL59FFT DE93756550001006525563 USD GBP 2022-11-27 2020-05-22 2024-07-15 2020-04-02 -37374807.958562351366178447898590057117071635996571407457660485745838424652255349422449887484 255936.18 GBP CHF DBIT CRDT uHcJnYVJYKDQMoFMjGXC Schüler Wetter unser Nase lesen erklären Herr damit. PQGdgBCLypSRkHiJYdev Bohlander e.G. rGwrTRHsFHjLPYhFpMTJ DE33912765450255003824 Lebenshaltung:Lebensmitteleinkäuft dIxwCBlTvNJpYULlJAaS Vielleicht Papa Sonntag Glück dem krank Ende Lehrer.
45 9675 674 BJ715E6CD1OIU6T DE34636975346401640379 CHF GBP 2020-12-16 2025-04-21 2024-01-07 2023-07-13 -5040941689111955157468104725713611935085598625070764074521071414015940351382113590909197304063575.4515354590973595602470431141798115590834959037133034277462703823 107958.15 GBP DBIT CRDT HGLztVTGOpGnChJsjprC Um rufen Schnee nämlich leise Tag gesund hin. qLFITvGssIbHlFBfuZyg Ruppersberger Förster AG & Co. KG SWQbyfBlwfdSCnoAtwmn DE82042132477947420315 Beruf:Gehalt:super Firma jtabNPVEZOlBVnaIvamZ Ich wo Oma Leute gegen früh Herz hoch.
46 2631 4938 BB0000NISDKBWM1 DE18280828921382275721 USD CHF 2023-12-09 2024-09-06 2021-09-25 2026-06-01 35870065450695.1976 307489.25 GBP USD DBIT CRDT gtwRCGANaKlcNLbLqXCW Zehn los stark wissen vor dein an Schule. qhkoHtHuLrwxdDjUitUM Holsten Bien e.G. SMAvrVhBeuNflTTfByZh DE54634488965756369503 Lebenshaltung:Lebensmitteleinkäuft Freizeit JodnUWDKlXidVgYIIkZo Er merken Seite von früher schlimm weit gestern.
47 7105 7493 FM81K0IHYG2B1K8 DE75969937757392474988 CHF EUR 2022-07-17 2024-07-01 2021-04-08 2025-05-15 7605971335219411809496337191273512429.19144246140951276630353263005965894263583159362498576671567919 769646.98 GBP USD DBIT CRDT bvkiJapOkQoCqDQaeSsj Wollen kalt alt uns acht heißen deshalb Licht. wcUfBTvFUoxaSRsaNbOk Losekann Mentzel AG wzNzdPXmfLEXEgEvtXBP DE06861731830241040674 Lebenshaltung:Lebensmitteleinkäuft Beruf:Gehalt:super Firma mQFwQMQRmhadGjzUhyIP Sehen los Herr dort ließ fährt gibt als.
48 4791 7741 XO29KPUGVXKV7P4 DE27930829328207938603 GBP USD 2025-05-09 2023-12-10 2025-03-06 2021-12-25 -3047323302463055726328888340075326228246153418725869094694662399.9369795962317797302779791691824533 332130.35 CHF CRDT DBIT WPBXOTiXiJFuZyjStJxI Unser Essen Ferien ließ Lehrerin bringen trinken wichtig. bKFooYlWkOIHkeDlcJxu Beer Dehmel OHG mbH OXIINcOhpetOqPvcLwkf DE56877468032283033000 Freizeit Lebenshaltung:Lebensmitteleinkäuft jHrZgYXhlOxQlibgCCRq Weil Dorf verlieren deshalb wirklich Himmel dein schreien.
49 9872 8664 NX87N16NYF88GY3 DE17107663775980137420 CHF USD 2024-09-17 2020-12-02 2022-11-07 2026-04-02 -7503175603096494776785070745277206265109071257232396346239801093970295294378754146262745092564314724.58147937995295429167115163695901432938 695808.54 USD EUR DBIT CRDT PskmmmqmEWvvjVwdTbpa Da aber frei schlafen sicher Ding schreiben endlich. IKnFvpHdkVECQXMJgPEb Steckel XgxsPRqDemqlLkHurTtC DE34731945010152636545 Lebenshaltung:Lebensmitteleinkäuft Beruf:Gehalt:super Firma iDHDfFpVGofjugPlXzMa Plötzlich beißen bringen Boden auch verstecken lieb vorbei.
50 2685 7929 DI50CT1EAUXO5ZQ DE32942674407704323077 CHF 2020-11-27 2020-09-06 2022-08-17 2026-09-09 804255947854847664075204915880058282080805548244876707562721818851111658361963075718333712782.054580103972864189422158609687925786112724117046925451325120196326468662088148238188624 165324.34 GBP EUR DBIT CRDT EUSdiqhdVykkRxbeknBL Sehen ob Bruder drei Kind dumm Hilfe auf. aUXqpaJrSZvchOtFcDZK Spieß Fröhlich KGaA ruepxYDTWMuSxXoNiEBQ DE68174009519788157086 Beruf:Gehalt:super Firma Freizeit tSCewecchqoQJuQXVXEr Stunde Hilfe ganz nie las kam oft voll.
51 6119 8900 MJ453EJXQAF5KF9 DE03689805605701261449 USD 2020-06-02 2021-03-03 2020-07-17 2020-11-12 18687868432897828853760232671664003846966802581954654987871.542014558313629353382101420030766 186334.15 EUR USD DBIT CRDT HhAovEIGHJoxAVgFeCVG Brief gern der Eis Freude lange schlagen Flasche. iUoROrwSAhKLpKuJqQZl Radisch Keudel GbR xHvmkmSMiiuCtJVFZDpN DE49885385858776558412 Freizeit Lebenshaltung:Lebensmitteleinkäuft GgDhdWxjgwwJUmNQOEzl Dafür singen ein sonst uns Mensch ins Ding.
@@ -0,0 +1,115 @@
;
"Umsätze Girokonto";"Zeitraum: 180 Tage";
"Neuer Kontostand";"2.363,30 EUR";
"Wertstellung (Valuta)";"Umsatz in EUR";"Buchungstag";"Vorgang";"Buchungstext"
"12.01.2023";"-2.494,62";"28.09.2013";"Kartenumsatz";"Jeder Feuer fünf Hand Kind Jahr plötzlich leben."
"03.11.2014";"3.840,37";"12.02.2023";"Kartenumsatz";"Nehmen Name schnell Tier schreien gefährlich Arzt Monate."
"07.09.2008";"-1.895,07";"05.07.1999";"Kartenumsatz";"Frau stehen bleiben Minute Hund ein Fisch gern."
"23.06.1971";"1.996,11";"27.05.2015";"Kartenumsatz";"Glück wie doch tief gefährlich rennen schlafen Milch."
"28.05.1998";"3.183,63";"23.08.1993";"Lastschrift / Belastung";"Vom braun Seite fiel arbeiten bin kommen wichtig."
"20.08.1972";"707,94";"22.10.2001";"Kartenumsatz";"Auch letzte böse geben kurz nämlich es nass."
"21.09.2024";"-3.019,63";"25.12.1989";"Lastschrift / Belastung";"Boden böse denken oder sie denken später Kind."
"09.06.2016";"-2.908,98";"30.01.1978";"Kartenumsatz";"Stadt sofort nein Lehrerin fünf reiten gehen Haare."
"09.03.1994";"-2.972,60";"21.11.2009";"Lastschrift / Belastung";"Laut Weihnachten wenig dauern Schuh schwer alle nächste."
"30.11.2017";"1.994,76";"29.09.2011";"Lastschrift / Belastung";"Früher ob darauf krank wo Leute nach für."
"18.08.2013";"2.925,64";"16.02.2011";"Lastschrift / Belastung";"Weg wirklich Freude reich da erst man sind."
"19.10.1973";"-.837,10";"04.09.1987";"Lastschrift / Belastung";"Los mögen Stunde Weihnachten nass wo drei ohne."
"15.09.1975";"3.691,37";"19.01.2010";"Kartenumsatz";"Treffen glauben Spiel gab will vom waschen antworten."
"10.07.2011";"3.650,99";"16.02.2012";"Lastschrift / Belastung";"Katze seit freuen freuen den Eis die traurig."
"07.05.2022";"722,12";"12.01.1989";"Lastschrift / Belastung";"Tun an als finden wie klein aber nun."
"06.02.2004";"1.499,05";"20.09.1986";"Lastschrift / Belastung";"Oder natürlich Essen zur rennen Platz spielen Monat."
"26.10.2009";"-3.454,94";"21.06.2024";"Kartenumsatz";"Es Erde Hand bauen Winter gerade böse vom."
"03.07.2013";"2.746,42";"23.01.1988";"Kartenumsatz";"Natürlich schon Tier Opa Fußball Stadt brauchen kochen."
"25.06.2014";"1.487,38";"26.11.1979";"Lastschrift / Belastung";"Dunkel her möglich essen Pferd Stadt ziehen seit."
"16.05.2009";"1.866,68";"21.12.1994";"Lastschrift / Belastung";"Genau wer glauben erklären nennen schicken Hilfe stellen."
"26.12.1998";"-1.925,12";"06.02.1992";"Lastschrift / Belastung";"Katze gegen wo stellen uns sprechen fertig nah."
"24.01.1996";"-1.089,91";"30.03.1992";"Lastschrift / Belastung";"Mich los Essen Lehrerin hinter heißen danach weit."
"21.07.1973";"-3.206,61";"27.08.1984";"Kartenumsatz";"Dein Schuh Garten glauben Vater Opa die Gott."
"22.11.1981";"1.112,51";"25.06.1974";"Lastschrift / Belastung";"Krank fast Papa verlieren Kopf Monate endlich ruhig."
"01.11.1973";"-2.077,99";"05.08.2017";"Lastschrift / Belastung";"Ein liegen See jung wie Name Sonntag ihm."
"26.08.1972";"-4.000,00";"25.01.2025";"Lastschrift / Belastung";"Darauf in dauern schön las jeder Freude Auto."
"21.06.1989";"-3.639,78";"04.08.1982";"Kartenumsatz";"Wissen merken nie Essen Rad sitzen wo letzte."
"02.12.1982";"-3.371,26";"19.07.2005";"Lastschrift / Belastung";"Setzen so dabei wenn es Hunger Meer Hase."
"16.05.1987";"-1.715,06";"15.10.1984";"Lastschrift / Belastung";"Feuer Wald vor wo auf wohl fressen Haare."
"05.04.2024";"322,78";"11.07.1988";"Lastschrift / Belastung";"Her Pferd zusammen arbeiten schenken gar Lehrer hinter."
"25.12.1984";"397,36";"15.03.1978";"Kartenumsatz";"Essen blau deshalb laufen für am sieht weinen."
"19.07.1970";"-2.245,70";"22.05.1991";"Kartenumsatz";"Vorbei wird braun bis Tier als erschrecken Ende."
"21.11.2019";"732,25";"19.06.1993";"Lastschrift / Belastung";"Acht Minutenmir man böse natürlich rund immer heißen."
"28.10.2003";"2.397,64";"21.07.2026";"Kartenumsatz";"Arzt kommen haben lassen fünf wer gar kochen."
"16.10.1973";"2.271,59";"27.02.1987";"Lastschrift / Belastung";"Blume Auge weiter böse fallen Essen Erde kam."
"12.01.2005";"1.472,05";"02.12.2006";"Kartenumsatz";"Lehrerin verkaufen Wald ziehen hin lesen Hilfe klettern."
"02.05.2015";"2.284,43";"08.08.1991";"Lastschrift / Belastung";"Zu Freude Geld will Glück die seit vielleicht."
"18.09.2012";"-1.940,73";"16.04.2016";"Lastschrift / Belastung";"So ein Mensch schenken am wir wollen merken."
"13.03.2010";"1.899,59";"10.02.1997";"Kartenumsatz";"Das auch glauben Tür gewinnen schreien fangen Flasche."
"01.03.1983";"3.566,27";"27.03.2015";"Kartenumsatz";"Bekommen warum ja wohnen gefährlich fährt langsam Monat."
"14.01.2011";"-2.962,06";"07.03.2010";"Lastschrift / Belastung";"Gesund Minutenmir etwas fallen Nacht schaffen nach ihr."
"30.05.2000";"-1.915,23";"29.08.1979";"Kartenumsatz";"Nennen leicht Name Schuh Bild reiten laut letzte."
"11.10.1970";"-3.359,95";"31.05.2025";"Lastschrift / Belastung";"Den singen Wald Geschenk Wald durch Stück Oma."
"10.09.2012";"3.953,53";"15.03.1993";"Kartenumsatz";"Um Opa fragen müssen endlich Bein leben zum."
"21.11.2012";"-1.197,06";"17.04.1999";"Kartenumsatz";"Ist also wo Eis zum Stück kommen Hunger."
"12.07.1996";"2.381,22";"23.04.2010";"Lastschrift / Belastung";"Dich Auge können Stück seit fliegen trinken können."
"13.03.1988";"1.748,03";"01.06.1996";"Lastschrift / Belastung";"Berg Leben steigen Licht nehmen vom Hilfe Kind."
"29.12.1979";"1.727,59";"26.07.1998";"Lastschrift / Belastung";"Nämlich laufen Stelle nächste noch schwimmen Wagen grün."
"09.02.1973";"-.393,09";"28.12.1979";"Lastschrift / Belastung";"Reich Onkel schön danach Flasche klein am wahr."
"12.11.1982";"727,08";"26.04.2001";"Kartenumsatz";"Lustig dem zehn mich fressen Straße Auto langsam."
"Alter Kontostand";"1.632,48 EUR";
"Umsätze Visa-Karte (Kreditkarte)";"Zeitraum: 180 Tage";
"Neuer Kontostand";"0,00 EUR";
"Wertstellung (Valuta)";"Umsatz in EUR";"Buchungstag";"Vorgang";"Buchungstext";"Umsatztag";"Referenz"
"10.08.2009";"846,45";"19.02.1980";"Kartenumsatz";"Zehn stehen zusammen liegen weil neu sollen Monat.";"10.08.2009";"awdexfhWsPiVoDLETNyk"
"16.07.2025";"-3.343,34";"19.07.2000";"Kartenumsatz";"Ihm Wagen Apfel Sommer sechs Rad Finger Wetter.";"16.07.2025";"KDPhAroRFDegPTQBwXZF"
"21.09.2011";"-3.094,59";"24.02.1998";"Kartenumsatz";"Bauen dick Opa wollen gegen See Tür erklären.";"21.09.2011";"TYCRvkfvRnKAZZHyjMJf"
"22.11.2019";"2.501,72";"18.09.2023";"Kartenumsatz";"Wiese Tür Beispiel als richtig oft hören Glück.";"22.11.2019";"MRXprlArMtGEOYyrivvb"
"25.06.1972";"-.797,60";"20.12.2000";"Kartenumsatz";"Minute alt sollen arbeiten Wissen ihn viel hören.";"25.06.1972";"WLNlXtEPMVPWNQinqVoX"
"23.06.1997";"3.688,65";"24.07.1983";"Kartenumsatz";"Glas sieben Freund bleiben Geld als laufen gelb.";"23.06.1997";"kMtKBlMZfVzaQpFJVrdf"
"07.08.1970";"236,47";"26.10.2025";"Lastschrift / Belastung";"Können sehr los Klasse gern Glas dabei baden.";"07.08.1970";"XcTWEhERXSrKhTpwxKwq"
"05.03.1995";"-3.699,34";"28.04.2022";"Lastschrift / Belastung";"Neu traurig damit gar plötzlich Küche kam erzählen.";"05.03.1995";"DCSPqPoExMJVIXDJEmba"
"13.05.2014";"-2.301,46";"02.02.1993";"Kartenumsatz";"Wiese Blume von verlieren trinken Lehrerin reiten dick.";"13.05.2014";"FyjQDvaDRthPmTPacdcN"
"12.07.2006";"-1.002,95";"16.06.1990";"Kartenumsatz";"Kam bin frei setzen Lehrer er Schnee ziehen.";"12.07.2006";"CxGyzurWIlEVpidlaHgz"
"03.01.1989";"-1.538,17";"17.11.2025";"Kartenumsatz";"Merken Geschichte nein sprechen holen kein Stein müde.";"03.01.1989";"LUBzVEGaOaXicGKGRXLK"
"27.10.1995";"-1.588,75";"09.08.1998";"Kartenumsatz";"Überall genau spielen es dort sechs ich es.";"27.10.1995";"IlEguSkSvqMwjWjgrPJJ"
"21.05.1976";"771,43";"05.02.2023";"Kartenumsatz";"Schüler früh Tür Dorf traurig er Ende der.";"21.05.1976";"OkireOnJlianvjBgiKLP"
"10.11.1991";"-3.682,38";"11.12.1976";"Kartenumsatz";"Mädchen der Tag lachen Spaß singen nehmen fangen.";"10.11.1991";"EtrQVskkdZQgaxCnaNuX"
"27.05.2005";"-2.049,77";"18.05.1992";"Lastschrift / Belastung";"Stehen Stadt kein Stein einige wir dürfen Monate.";"27.05.2005";"yayAivkfmnKnKjaEeqHx"
"28.09.2009";"2.950,65";"07.10.2017";"Kartenumsatz";"Einfach warm Spaß er wird sonst Seite jung.";"28.09.2009";"xTmXkYMLxHlUWVgzDurB"
"26.09.1978";"691,87";"09.04.2015";"Kartenumsatz";"Böse zusammen an jung Land spät Brot so.";"26.09.1978";"OZCVRrNQxLzDaTJaYgir"
"07.01.1986";"1.899,21";"16.06.1975";"Kartenumsatz";"Wird offen ruhig Teller Arbeit sofort bleiben Weihnachten.";"07.01.1986";"YexccmpQrHfXPEaNqjYN"
"22.11.1970";"-1.088,74";"22.06.1979";"Lastschrift / Belastung";"Kopf Arzt bei jeder krank Haus Haare laut.";"22.11.1970";"uiRZmISGnUnmICslCQKR"
"06.10.2011";"2.627,51";"06.09.2018";"Lastschrift / Belastung";"Berg Auge auch die Milch packen Junge Bett.";"06.10.2011";"HjQTAhQreZPzYhSlyoDp"
"Alter Kontostand";"0,00 EUR";
"Umsätze Tagesgeld PLUS-Konto";"Zeitraum: 180 Tage";
"Neuer Kontostand";"1.673,90 EUR";
"Wertstellung (Valuta)";"Umsatz in EUR";"Buchungstag";"Vorgang";"Buchungstext"
"02.09.2000";"-2.652,98";"13.12.1985";"Lastschrift / Belastung";"Uhr wohl führen See setzen mal krank hoch."
"27.02.1976";"-2.302,68";"11.05.1997";"Kartenumsatz";"Ohne gut Stein wirklich gar Winter Welt nämlich."
"28.05.1986";"-3.343,51";"24.04.2026";"Kartenumsatz";"Dich wenig Oma oft denn Ferien hinter halten."
"18.08.1980";"3.519,25";"08.06.1985";"Lastschrift / Belastung";"Erklären tragen See ging versuchen gern heiß vielleicht."
"04.10.1981";"3.268,02";"25.07.2014";"Kartenumsatz";"Nein Junge Tier beide noch Wasser hinein gefährlich."
"09.01.1991";"-3.052,80";"13.06.2023";"Kartenumsatz";"Fisch Schwester viel sicher bis dürfen es Opa."
"05.07.2011";"-.633,49";"27.04.1994";"Kartenumsatz";"Frei fährt hat mich gleich gibt er Mutter."
"22.02.2015";"-1.146,44";"16.04.2010";"Lastschrift / Belastung";"Tag stellen in nichts Winter unser Eltern traurig."
"17.03.1989";"350,28";"24.03.2000";"Kartenumsatz";"Ding fröhlich fangen Herz allein tief weiter ihn."
"26.10.1994";"3.490,67";"10.05.1988";"Kartenumsatz";"Gibt Zeit Zug doch hören Frau Zimmer Onkel."
"Alter Kontostand";"6.686,20 EUR";
"Umsätze Depot";"Zeitraum: 180 Tage";
"Wertstellung (Valuta)";"Umsatz in EUR";"Geschäftstag";"Stück / Nom.";"Bezeichnung";"WKN";"Währung";"Ausführungskurs"
"28.10.1974";"1.685,37";"28.10.1974";"3.782,02";"zVQTjfeGndFUgHRYoOJV";"UIKWcmkLpKvlEaxsJNAY";"IVIQpMQspcGkoCQUMKKQ";"-1.169,05"
"23.03.1994";"1.559,84";"23.03.1994";"-.507,97";"rWidmLRRjtCtJbGCxxiJ";"clkDytzVOZfNqNqNqwpq";"vinALLmPCGosxxYbxQOT";"-.170,74"
"21.07.1997";"1.885,70";"21.07.1997";"3.199,90";"zbFJlhZJhFyVckyTkbWF";"NCLFEpfFrQAsoRLpEdNB";"BdqBcCsARBsTONKUEMUG";"2.167,34"
"04.11.2007";"-2.336,08";"04.11.2007";"3.374,10";"UmhrGDbbJhTbtBVnMEWU";"kfrvJSzEonSnYSvSwhzR";"WsyqHHvMGewNQWnQVOab";"-44,87"
"10.05.1995";"2.777,21";"10.05.1995";"-.466,66";"QBcTBCTJDuIWJKthZKKN";"DOBQNhJNlHjRdTYQfihc";"tTdPJSTffQjGknRiARpp";"1.108,37"
"02.08.2021";"95,64";"02.08.2021";"-2.473,15";"sURiquSzaJuCrwWdnuBf";"VltMSrMBshpZqfZltkJz";"mDBoFnnxoaMnkJcJwXtE";"1.989,84"
"08.01.2006";"723,60";"08.01.2006";"-3.788,97";"lENmCDzBnAoOlUWoIEOh";"vsTfHGBstXTwHpVQQIMN";"woeYzPNzwxhbsVuBQSzW";"723,80"
"24.08.1972";"-2.752,39";"24.08.1972";"121,81";"QmboZenDJYKYJZHrwBzg";"TOOOVZqQAxZLvZBqhttK";"iCRZdDaRZbJzKjDwweZz";"1.508,94"
"17.10.2004";"-3.983,82";"17.10.2004";"-2.862,12";"tBoMSqznKQiDGPEaZgtK";"KCxxVLPJcUdCHWRHiLuG";"MuwYMYDVjiZdGPPchFan";"2.170,06"
"13.01.2003";"1.172,75";"13.01.2003";"3.559,62";"DZUeNsJNwFiobKvjqbFE";"lKBJTcOkTowTcbtEDnHC";"DDMOrfHrxGjnjDQplaXe";"515,78"
+238
View File
@@ -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"
+736
View File
@@ -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()
+125
View File
@@ -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
View File
@@ -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()
+45
View File
@@ -0,0 +1,45 @@
import re
from datetime import date
from decimal import Decimal
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from src.constants import IBAN_PATTERN, Currency
from src.ingest.b4w import ingest_b4w
from src.models.b4w import B4wDb
b4w_db_column_names = [col.key for col in B4wDb.__mapper__.columns]
# TODO: Unit tests for other funtions in src/ingest/b4w.py
# TODO: Similar file for Comdirect (more work due to several tables)
def test_ingest_b4w_e2e(db_session: Session):
path = Path("tests/test_data/b4w_export_dummy_01.csv")
ingest_b4w(path, db_session)
result = db_session.execute(select(B4wDb)).scalars().all()
assert isinstance(result, list)
assert all(isinstance(row.id_b4w, int) for row in result)
assert all(isinstance(row.date_book, date) for row in result)
assert all(isinstance(row.other_name, str) for row in result)
assert all(isinstance(row.other_iban, str) for row in result)
assert all(re.findall(IBAN_PATTERN, str(row.other_iban)) for row in result)
assert all(
(isinstance(row.category_id, int) or row.category_id is None) for row in result
)
assert all(isinstance(row.notes, str) for row in result)
assert all(isinstance(row.id, int) for row in result)
assert all(isinstance(row.hash_id, str) for row in result)
assert all(isinstance(row.account_iban, str) for row in result)
assert all(re.findall(IBAN_PATTERN, str(row.account_iban)) for row in result)
assert all(isinstance(row.account_currency, str) for row in result)
assert all(isinstance(row.date_valid, date) for row in result)
assert all(isinstance(row.amount, Decimal) for row in result)
assert all(isinstance(row.amount_currency, str) for row in result)
assert all(row.amount_currency in list(Currency) for row in result)
assert all(isinstance(row.text, str) for row in result)
print(b4w_db_column_names)
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
import pytest
from faker import Faker
from sqlalchemy.orm import Session
from src.constants import Currency
from src.ingest.comdirect import (
_reader_raw_comdirect,
ingest_comdirect,
reader_comdirect,
)
TEST_DATA_PATH = Path("tests/test_data/comdirect_export_dummy_01.txt")
def test_reader_comdirect_raw():
result = _reader_raw_comdirect(TEST_DATA_PATH)
assert result
def test_reader_comdirect():
result = reader_comdirect(TEST_DATA_PATH)
assert result
assert isinstance(result, dict)
@pytest.mark.skip
def test_ingest_comdirect(db_session: Session):
ingest_comdirect(
session=db_session,
path=TEST_DATA_PATH,
account_iban=Faker().iban(),
account_currency=Currency.EUR,
)
+33
View File
@@ -33,6 +33,27 @@ def test_b4w_export_account_iban_to_short():
_ = B4wExportFactory.build(OwnrAcctIBAN="DE123")
def test_b4w_export_other_iban_empty_is_none():
data = B4wExportFactory.build(RmtdAcctIBAN="")
assert data.other_iban is None
def test_b4w_export_other_iban_blank_is_none():
data = B4wExportFactory.build(RmtdAcctIBAN=" ")
assert data.other_iban is None
def test_b4w_export_other_iban_invalid():
with pytest.raises(ValidationError):
_ = B4wExportFactory.build(RmtdAcctIBAN="not an IBAN")
def test_b4w_export_other_iban_repr_round_trip_empty():
data = B4wExportFactory.build(RmtdAcctIBAN="")
data2 = eval(str(data))
assert data == data2
def test_b4w_export_parameters_to_dict():
data = B4wExportFactory.build()
params = data.parameters_to_dict()
@@ -59,3 +80,15 @@ def test_b4w_export_repr_round_trip():
data_str = str(data)
data2 = eval(data_str)
assert data == data2
def test_b4w_export_amount_two_decimal_places():
data = B4wExportFactory.build()
assert data.amount > 0
assert data.amount.as_tuple().exponent >= -2
def test_b4w_export_text_fields_non_empty():
data = B4wExportFactory.build()
assert data.reference
assert data.notes
+86 -49
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
from src.models.comdirect import ComdirectGiro
from tests.factories import ComdirectGiroFactory
def test_comdirect_giro_basic_and_stringification():
data = ComdirectGiroFactory.build()
assert data
assert isinstance(data, ComdirectGiro)
orig_headers = data.get_orig_headers()
assert orig_headers
assert isinstance(orig_headers, str)
header_list = orig_headers.split(";")
assert len(header_list) > 0
assert set(header_list) == {
'"Wertstellung (Valuta)"',
'"Umsatz in EUR"',
'"Buchungstag"',
'"Vorgang"',
'"Buchungstext"',
}
orig_row = data.get_orig_row()
assert orig_row
assert isinstance(orig_row, str)
assert len(orig_row.split(";")) > 0
+145
View File
@@ -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"
+110
View File
@@ -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)
+34
View File
@@ -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))
+196
View File
@@ -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"}
Generated
+295 -1
View File
@@ -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" },
]
@@ -130,6 +269,7 @@ dev = [
{ name = "debugpy" },
{ name = "faker" },
{ name = "polyfactory" },
{ name = "ruff" },
]
[package.metadata]
@@ -137,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" },
]
@@ -149,6 +291,7 @@ dev = [
{ name = "debugpy", specifier = ">=1.8.21" },
{ name = "faker", specifier = ">=40.38.0" },
{ name = "polyfactory", specifier = ">=3.3.0" },
{ name = "ruff", specifier = ">=0.16.7" },
]
[[package]]
@@ -199,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"
@@ -241,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"
@@ -380,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"
@@ -408,6 +677,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
[[package]]
name = "ruff"
version = "0.16.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"