Metadata-Version: 2.4
Name: riftbound-database
Version: 1.1.0
Summary: Acquisition, normalization, versioning, and persistence for Riftbound data.
Project-URL: Homepage, https://github.com/baobabgit/riftbound-database
Project-URL: Repository, https://github.com/baobabgit/riftbound-database
Project-URL: Issues, https://github.com/baobabgit/riftbound-database/issues
Author: Michel ANDRIANAIVO
License: MIT
License-File: LICENSE
Keywords: database,ingestion,riftbound,sqlalchemy,tcg
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: alembic<2,>=1.13
Requires-Dist: baobab-database[postgresql]<3,>=2.0
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.7
Requires-Dist: riftbound-core<0.2,>=0.1.1
Requires-Dist: sqlalchemy<3,>=2.0
Requires-Dist: typer<1,>=0.12
Provides-Extra: dev
Requires-Dist: bandit<2,>=1.7; extra == 'dev'
Requires-Dist: coverage[toml]<8,>=7; extra == 'dev'
Requires-Dist: mypy<2,>=1.10; extra == 'dev'
Requires-Dist: pytest<10,>=9.0.3; extra == 'dev'
Requires-Dist: ruff<1,>=0.11; extra == 'dev'
Description-Content-Type: text/markdown

# riftbound-database

`riftbound-database` est la librairie d'acquisition, de normalisation,
d'historisation et de persistance des données Riftbound.

Le projet suit cette direction de dépendances :

```text
riftbound-core
    ↑ utilisé par
riftbound-database
    ↓ utilise
baobab-database
```

Le modèle métier canonique appartient à `riftbound-core`. Ce dépôt ne doit pas
redéfinir les cartes, sets, domaines, types, tags, coûts ou textes de carte.
L'accès aux moteurs, sessions et transactions SQLAlchemy sera encapsulé derrière
un adaptateur local de `baobab-database`.

## Prérequis

- Python 3.12 ou 3.13 ;
- PostgreSQL pour l'intégration, SQLite étant réservé aux tests rapides.

## Installation de développement

```bash
python -m venv .venv
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```

La dépendance `riftbound-core` est résolue depuis PyPI (`>=0.1.1,<0.2`).

## Configuration

| Variable | Obligatoire | Valeur par défaut | Rôle |
|---|---:|---|---|
| `RIFTBOUND_DATABASE_URL` | Non au bootstrap | aucune | URL PostgreSQL ; obligatoire en production et pour l'API |
| `RIFTBOUND_STORAGE_PATH` | Non | `storage` | Racine du stockage local |
| `RIFTBOUND_LOG_LEVEL` | Non | `INFO` | Niveau de logs Python |
| `RIFTBOUND_ENVIRONMENT` | Non | `development` | Nom de l'environnement d'exécution |
| `RIFTBOUND_SYNC_JOB_STALE_AFTER_SECONDS` | Non | `300` | Seuil (secondes) sans heartbeat avant qu'un job `RUNNING` soit considéré stale |
| `RIFTBOUND_TEST_POSTGRESQL_URL` | Tests E2E | aucune | URL PostgreSQL pour `pytest -m postgresql` |

Les secrets éventuellement présents dans `RIFTBOUND_DATABASE_URL` ne doivent
jamais être écrits dans les logs. Les fichiers `.env` sont ignorés par Git.

## Provider de base de données

Les moteurs, sessions et transactions passent exclusivement par
`riftbound_database.database.BaobabProvider`. PostgreSQL utilise
`baobab-database 2.0.0` avec le driver `psycopg`; SQLite est limité aux tests
sur fichier temporaire.

L'adaptation complète et les options SSL supportées sont documentées dans
[`docs/database-provider.md`](docs/v1.0.0/database-provider.md).

## Modèles et migrations

Les modèles SQLAlchemy vivent sous `riftbound_database.database.models`. Les
mappers domaine ↔ persistance sont sous `database/mappers`. Alembic est configuré
via `alembic.ini` et exécutable avec :

```bash
python -m alembic upgrade head
```

Pour les tests locaux sans PostgreSQL, définir `RIFTBOUND_MIGRATION_SQLITE_PATH`
ou laisser Alembic utiliser la base SQLite par défaut sous `.my_cache/`.

## Repositories

Les écritures applicatives passent par `RiftboundUnitOfWork`, qui expose les
repositories sans créer de session locale. Les retours sont limités aux objets
`riftbound-core` ou aux read models sous `database/read_models/`.

```python
from riftbound_database.database.baobab_provider import BaobabProvider
from riftbound_database.database.unit_of_work import RiftboundUnitOfWork

provider = BaobabProvider.for_sqlite("test.db")
unit_of_work = RiftboundUnitOfWork(provider)
with unit_of_work.transaction() as repositories:
    card = repositories.cards.find_by_stable_id("card-001")
    job = repositories.sync_jobs.create(
        SyncJobCreateInput(type=SyncJobType.CARDS, mode=SyncJobMode.INCREMENTAL),
    )
```

### Collections utilisateur et decks (v1.0.0)

La persistance applicative pour `riftbound-api` est disponible via les mêmes
repositories transactionnels :

```python
with unit_of_work.transaction() as repositories:
    collection = repositories.collections.get_or_create_default_collection(user_id)
    deck = repositories.decks.create_deck(
        user_id,
        DeckCreatePayload(name="Competitive", visibility="private"),
    )
```

Guide complet : [`docs/collections-decks.md`](docs/v1.0.0/collections-decks.md) (tables,
migrations, intégration API, remplacement des adaptateurs mémoire).

## Ingestion

Les imports passent par `BaseImporter`, qui orchestre fetch, stockage raw,
checksum, batch et persistance via les repositories. `SetImporter` importe
des fichiers JSON locaux de façon idempotente avec normalisation, checksum
canonique et stockage sous `normalized_data/sets/`. `CardNormalizer` produit
des DTO canoniques compatibles avec `riftbound-core`, calcule un checksum
stable et signale les champs ambigus via le statut `needs_review`. Les
importers cartes (`LocalFileCardImporter`, `OfficialApiCardImporter`,
`CardGalleryImporter`) normalisent, versionnent et persistent les cartes via
les repositories. Les documents normatifs passent par `RulesImporter`,
`ErrataImporter` et `BanlistImporter` avec normalisation, checksums stables,
historisation (`rules_versions`, snapshots banlist) et statuts
`matched` / `ambiguous` / `needs_review`. Un `FakeImporter` est disponible
pour les tests.

## Assets

Les images de cartes sont téléchargées par `CardAssetDownloader`, validées
(MIME, taille minimale, signature binaire) puis stockées sous
`{RIFTBOUND_STORAGE_PATH}/card_assets/{set_code}/{card_id}/` avec un
`metadata.json` par carte. La persistance relationnelle passe par
`AssetRepository` ; un checksum inchangé évite les écritures inutiles.

```python
from riftbound_database.assets import AssetDownloadRequest, AssetVariant, CardAssetDownloader
from riftbound_database.config.settings import Settings
from riftbound_database.database.baobab_provider import BaobabProvider
from riftbound_database.database.unit_of_work import RiftboundUnitOfWork

settings = Settings()
downloader = CardAssetDownloader()
provider = BaobabProvider.for_sqlite("test.db")
unit_of_work = RiftboundUnitOfWork(provider)
request = AssetDownloadRequest(
    set_code="OGN",
    card_id="card-unit-001",
    variant=AssetVariant.ORIGINAL,
    source_url="https://example.test/card.png",
)
with unit_of_work.transaction() as repositories:
    result = downloader.download(request, settings, repositories.assets)
```

## Synchronisation

Les jobs sous `riftbound_database.sync` orchestrent les importers via un
**`SyncManifest`** JSON (ADR-0002) : chemins locaux + liste `assets[]` avec URLs
source explicites. `FullSyncJob` exécute dans l'ordre
sets → cartes → assets → règles → errata → banlists, avec `dry_run` et
`SyncReport` final.

```python
from riftbound_database.sync import FullSyncJob, SyncContext, SyncManifestLoader

manifest = SyncManifestLoader.load(Path("sync.json"))
context = SyncContext(settings=settings, unit_of_work=unit_of_work, manifest=manifest)
report = FullSyncJob().run(context)
```

### Synchronisation pilotée (`sync_job`, v1.1.0)

Lorsqu'un `sync_job_id` est fourni, les imports appellent périodiquement
`heartbeat()` et vérifient `is_cancel_requested()` via
`ImportExecutionSupervisor`. L'annulation est **coopérative** : elle ne tue
pas le processus worker ; le job en cours détecte la demande, interrompt
proprement l'import et passe le `sync_job` en `CANCELLED`. Les jobs `RUNNING`
sans heartbeat récent sont détectables via `SyncJobStaleDetector` (seuil
configurable par `RIFTBOUND_SYNC_JOB_STALE_AFTER_SECONDS`).

```python
from riftbound_database.sync import SyncJobStaleDetector

detector = SyncJobStaleDetector(unit_of_work, settings)
stale_jobs = detector.list_stale_jobs()
```

### Concurrence et idempotence

Deux demandes identiques (même `type` + `source`) sans `force=True` retournent le
job actif existant au lieu d'en créer un second silencieusement. Avec
`force=True`, un nouveau job est créé même si un job actif existe déjà.

```python
from riftbound_database.ingestion.services.sync_job_service import SyncJobService

with unit_of_work.transaction() as repositories:
    service = SyncJobService(repositories.sync_jobs)
    job = service.create_card_sync_job("admin", {"set_code": "OGN"}, force=False)
    same = service.create_card_sync_job("admin", {"set_code": "OGN"}, force=False)
    assert job.id == same.id
```

Guide complet : [`docs/sync-jobs.md`](docs/sync-jobs.md) (modèle, CLI, frontière
API/Web, checklist intégration `riftbound-api`).

## CLI

La CLI Typer est exposée via le script `riftbound-database` (ou
`python -m riftbound_database.cli.main`). Options globales :
`--database-url`, `--sqlite-path`, `--storage-path`, `--log-level`.

```bash
riftbound-database --sqlite-path ./local.db db upgrade
riftbound-database --sqlite-path ./local.db import sets --path ./sets.json
riftbound-database --sqlite-path ./local.db import cards --source local-file --path ./cards.json
riftbound-database --sqlite-path ./local.db import rules --path ./rules.json
riftbound-database --sqlite-path ./local.db import errata --path ./errata.json
riftbound-database --sqlite-path ./local.db import banlist --path ./banlist.json
riftbound-database --sqlite-path ./local.db assets download --manifest ./sync.json
riftbound-database --sqlite-path ./local.db sync full --manifest ./sync.json
riftbound-database --sqlite-path ./local.db sync jobs list
riftbound-database --sqlite-path ./local.db sync jobs status <job-uuid>
riftbound-database --sqlite-path ./local.db sync jobs logs <job-uuid>
riftbound-database --sqlite-path ./local.db sync jobs cancel <job-uuid>
riftbound-database checksums verify
riftbound-database export normalized --format json
```

Les imports supportent `--dry-run` et `--limit`. Les commandes retournent un
code de sortie non nul en cas d'échec ; les secrets de connexion ne sont jamais
loggés.

## Qualité

```bash
python -m pytest
python -m coverage run -m pytest
python -m coverage report
python -m ruff check .
python -m mypy src tests
python -m bandit -r src
```

### Tests rapides vs intégration

```bash
# PostgreSQL local via Docker (optionnel)
docker compose up -d postgres
$env:RIFTBOUND_TEST_POSTGRESQL_URL="postgresql+psycopg://riftbound:riftbound@localhost:5432/riftbound_test"

# unitaires (CI par défaut)
python -m pytest -m "not integration and not postgresql"

# migrations SQLite
python -m pytest tests/integration/riftbound_database/database/migrations/

# PostgreSQL (service local requis)
set RIFTBOUND_TEST_POSTGRESQL_URL=postgresql+psycopg://user:pass@localhost:5432/riftbound_test
python -m pytest -m postgresql

# E2E collections/decks PostgreSQL uniquement
python -m pytest tests/integration/riftbound_database/database/test_collections_decks_e2e_postgresql.py -q

# E2E sync jobs PostgreSQL uniquement
python -m pytest tests/integration/riftbound_database/sync/test_sync_jobs_e2e_postgresql.py -q

# tests contractuels (BL-027)
python -m pytest -m contract -q
```

Voir [`docs/v1.0.0/contract-tests.md`](docs/v1.0.0/contract-tests.md),
[`docs/v1.0.0/e2e-postgresql.md`](docs/v1.0.0/e2e-postgresql.md),
[`docs/v1.0.0/collections-decks.md`](docs/v1.0.0/collections-decks.md) et
[`docs/sync-jobs.md`](docs/sync-jobs.md).

Couverture minimale configurée : **85 %** (`pyproject.toml`).

### CI GitHub Actions

La suite de tests est factorisée dans `.github/workflows/reusable-tests.yml`
et invoquée par :

- **CI** (`.github/workflows/ci.yml`) : chaque push/PR vers `main` ou
  `release/**` ;
- **Release readiness** (`.github/workflows/release-readiness.yml`) : déclenchement
  manuel ou sur branches `release/**` — exécute la même suite puis génère les
  snapshots `integration-snapshot.md` et `analysis-snapshot.md` (artefacts) ;
- **Release** (`.github/workflows/release.yml`) : sur tag `v*.*.*` — tests
  PostgreSQL, **gates bloquants** (alignement tag / `__version__`, section
  CHANGELOG datée, guide de migration si nouvelles révisions Alembic, version
  absente de PyPI), puis build et publication PyPI.

Jobs de la suite partagée :

- **qualité** : ruff, mypy, bandit ;
- **unitaires** : pytest + couverture (Python 3.12 et 3.13) ;
- **intégration** : migrations SQLite + scénarios PostgreSQL via service container.

Les dépendances (`riftbound-core`, `baobab-database`) sont installées depuis PyPI ;
aucun secret GitHub n'est requis pour la CI si tous les packages restent publics.

## Documentation projet

Le cahier des charges et le découpage fonctionnel sont disponibles dans
[`docs/`](docs/v1.0.0/README.md).

Guides opérationnels :

- [`docs/collections-decks.md`](docs/v1.0.0/collections-decks.md) — intégration collections/decks (v1.0.0) ;
- [`docs/sync-jobs.md`](docs/sync-jobs.md) — synchronisation pilotée et frontière API (v1.1.0) ;
- [`docs/MIGRATION-1.1.0.md`](docs/MIGRATION-1.1.0.md) — migration Alembic 1.0.0 → 1.1.0 ;
- [`docs/contract-tests.md`](docs/v1.0.0/contract-tests.md) — tests contractuels ;
- [`docs/e2e-postgresql.md`](docs/v1.0.0/e2e-postgresql.md) — E2E PostgreSQL ;
- [`docs/api-port-compatibility.md`](docs/v1.0.0/api-port-compatibility.md) — contrat ports API ;

Le catalogue initial a été livré via les backlogs `BL-001` à `BL-014`. L'épopée
collections/decks (`BL-015` → `BL-029`) clôt la version **1.0.0** ; l'épopée
sync jobs (`BL-030` → `BL-042`) clôt la version **1.1.0**.
