Metadata-Version: 2.4
Name: baobab-api-call
Version: 2.1.0
Summary: Socle Python générique pour appels HTTP synchrones et asynchrones (package parent).
Project-URL: Homepage, https://github.com/baobabgit/core-baobab-python-api-call
Project-URL: Documentation, https://github.com/baobabgit/core-baobab-python-api-call#readme
Project-URL: Repository, https://github.com/baobabgit/core-baobab-python-api-call.git
Project-URL: Issues, https://github.com/baobabgit/core-baobab-python-api-call/issues
Project-URL: Changelog, https://github.com/baobabgit/core-baobab-python-api-call/blob/main/CHANGELOG.md
Author: Baobab API Call contributors
License: MIT
License-File: LICENSE
Keywords: api,async,baobab,client,http,httpx,middleware,redaction,requests,rest,retry
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: bandit<2,>=1.7; extra == 'dev'
Requires-Dist: black<26,>=24; extra == 'dev'
Requires-Dist: coverage[toml]<8,>=7; extra == 'dev'
Requires-Dist: flake8<8,>=7; extra == 'dev'
Requires-Dist: mypy<2,>=1.10; extra == 'dev'
Requires-Dist: pylint<5,>=3; extra == 'dev'
Requires-Dist: pytest-asyncio<1,>=0.23; extra == 'dev'
Requires-Dist: pytest-cov<7,>=4; extra == 'dev'
Requires-Dist: pytest<9,>=7; extra == 'dev'
Provides-Extra: docs
Requires-Dist: furo<2026,>=2024.1; extra == 'docs'
Requires-Dist: myst-parser<5,>=2; extra == 'docs'
Requires-Dist: sphinx<9,>=7; extra == 'docs'
Provides-Extra: release
Requires-Dist: build<2,>=1; extra == 'release'
Requires-Dist: cyclonedx-bom<7,>=4; extra == 'release'
Requires-Dist: twine<7,>=5; extra == 'release'
Description-Content-Type: text/markdown

# baobab-api-call

> Socle parent générique d'appels HTTP pour Python — synchrones et asynchrones.

[![CI](https://github.com/baobabgit/core-baobab-python-api-call/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/baobabgit/core-baobab-python-api-call/actions/workflows/ci.yml)
[![CodeQL](https://github.com/baobabgit/core-baobab-python-api-call/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/baobabgit/core-baobab-python-api-call/actions/workflows/codeql.yml)
[![PyPI version](https://img.shields.io/pypi/v/baobab-api-call.svg)](https://pypi.org/project/baobab-api-call/)
[![Python versions](https://img.shields.io/pypi/pyversions/baobab-api-call.svg)](https://pypi.org/project/baobab-api-call/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Coverage](https://img.shields.io/badge/coverage-99%25-brightgreen.svg)](https://github.com/baobabgit/core-baobab-python-api-call)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Documentation Status](https://readthedocs.org/projects/baobab-api-call/badge/?version=latest)](https://baobab-api-call.readthedocs.io/fr/latest/)

`baobab-api-call` est un package Python destiné à servir de **socle générique**
pour construire des librairies filles d'accès à des APIs HTTP. Il fournit
(progressivement, au fil des features) les briques transverses : client
synchrone et asynchrone, retries, gestion d'erreurs typée, middlewares,
authentification, observabilité, etc.

> **Note sur les badges** : la PyPI version, Python versions et coverage
> Codecov deviennent réels dès que (1) le projet est publié sur PyPI via
> Trusted Publishing OIDC (workflow `release.yml`), et (2) Read the Docs
> est activé pour ce dépôt (cf. `docs/post_merge_user_actions_v2.1.0.md`).

## Statut

- Version courante : **2.1.0** (release mineure — **F-19** **CLOSED**,
  **DEC-0110**). **Production readiness** : packaging PyPI complet
  (readme, urls, keywords, classifier OS Independent, classifier
  Python 3.13), CI GitHub Actions multi-Python, workflow release
  Trusted Publishing OIDC + SBOM CycloneDX + sigstore, CodeQL,
  Dependabot, pre-commit, Sphinx + Read the Docs, fichiers OSS
  (CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, templates, CODEOWNERS).
  **Aucun changement d'API publique runtime** depuis 2.0.0 ; ajout
  du support Python 3.13 dans la matrice testée. Voir
  `CHANGELOG.md`, `docs/releases/2.1.0.md` et
  `docs/post_merge_user_actions_v2.1.0.md`.
- Les features **F-01** à **F-15** ont été livrées en 1.0.0 ; **F-16**
  a refactoré la structure interne en 1.1.0 (rule 011 « une classe par
  fichier »), **F-17** a réorganisé `tests/unit/` en miroir strict de
  `src/baobab_api_call/` (rule 011 §3), **F-18** a finalisé en 2.0.0
  la suppression des stubs de rétrocompatibilité, et **F-19** a livré
  en 2.1.0 l'outillage release-ready (packaging, CI, supply chain,
  documentation publique).

### Politique de versionnement (SemVer)

- **MAJOR** (**x**.0.0) : changements incompatibles avec la garantie portée par
  **`baobab_api_call.__all__`** ou évolutions cassantes documentées.
- **MINOR** (1.**y**.0) : nouvelles capacités **rétrocompatibles** (nouveaux symboles
  publics optionnels, extensions sans casser les appelants existants).
- **PATCH** (1.0.**z**) : corrections **rétrocompatibles** (bugs, docs, robustesse).

La liste canonique des symboles publics racine est **`docs/api_public_inventory.md`**
(**BL-15-01**). Préférer **`from baobab_api_call import …`** aux imports profonds
pour rester aligné sur cette garantie.

## Exclusions permanentes

Le socle `baobab-api-call` **ne doit pas** implémenter directement les
endpoints métier de Scryfall, Altered ou de toute autre API spécifique.
Il fournit uniquement les mécanismes génériques permettant à des
librairies filles de les implémenter proprement.

## Documentation utilisateur

Hub des guides **F-14** (orientation vers les documents canoniques du dépôt) :
[**`docs/guides/README.md`**](docs/guides/README.md) — erreurs, logging, sécurité,
tests sans réseau. Le guide pour construire une librairie **fille** reste
[**`docs/extension_guide.md`**](docs/extension_guide.md) (**F-13**).

## Gouvernance et workflow IA

Ce dépôt est piloté par le pack **Cursor AI Project Workflow v4** (mode hybride :
production dans l'agent courant, contrôles critiques en Subagents indépendants).
Voir :

- [`AGENTS.md`](AGENTS.md) — gouvernance générale et rôles.
- [`docs/ia_workflow/README.md`](docs/ia_workflow/README.md) — protocole de
  communication, runs, statuts et journal de décisions.
- [`docs/ia_workflow/config/execution_mode.md`](docs/ia_workflow/config/execution_mode.md)
  — modes d'exécution disponibles et matrice de décision.
- [`docs/ia_workflow/derogations.md`](docs/ia_workflow/derogations.md) —
  dérogations validées (notamment règle « une classe par fichier »).

## Prérequis

- Python **>= 3.11** (cf. `pyproject.toml`).

## Installation

### Depuis le dépôt (développement)

Python **≥ 3.11** est requis (voir `requires-python` dans `pyproject.toml`).
Un environnement virtuel est recommandé :

```bash
python3.11 -m venv .venv
source .venv/bin/activate  # Windows : .venv\Scripts\activate
pip install -U pip
pip install -e ".[dev]"
python -c "import baobab_api_call as b; print(b.__version__)"
```

La forme `pip install -e ".[dev]"` installe le package en mode éditable et
les extras **dev** (`pytest`, `coverage`, `black`, `flake8`, `pylint`, `mypy`,
`bandit`, etc.).

### Dans une application consommatrice

En attendant une publication **PyPI** éventuelle, vous pouvez pointer votre outil
de dépendances vers ce dépôt Git ou un chemin local (wheel/sdist avec **hatchling**).
Pour une contrainte stable : **`baobab-api-call>=2.1.0,<3`** (ajuster selon votre politique). La gamme 2.x est entièrement compatible API depuis 2.0.0 (la 2.1.0 ajoute le support Python 3.13 dans la matrice testée et l'outillage release sans rupture). La gamme 1.x reste consommable pour les projets existants ; **2.0.0** a introduit une rupture sur les seuls chemins de modules secondaires (`baobab_api_call.config`, etc.) — voir `docs/releases/2.0.0.md` pour la migration.

## Démarrage rapide — client synchrone

Le flux minimal : une **`ApiClientConfig`** (URL de base), un transport qui
implémente **`SyncTransport`** sans réseau réel pour l’exemple, puis
**`SyncApiClient`**. Les erreurs HTTP typées et les retries se branchent sur les
mêmes primitives ; voir les sections détaillées plus bas et
[**`docs/errors.md`**](docs/errors.md).

```python
from baobab_api_call import ApiClientConfig, ApiRequest, ApiResponse, SyncApiClient, SyncTransport


class StubSyncTransport:
    """Double sans socket ; suffit pour satisfaire le port SyncTransport."""

    def send(self, request: ApiRequest) -> ApiResponse:
        _ = request
        return ApiResponse(status_code=200, content=b"{}")

    def close(self) -> None:
        pass


cfg = ApiClientConfig(base_url="https://api.example.com")
transport: SyncTransport = StubSyncTransport()
with SyncApiClient(cfg, transport) as client:
    resp = client.get("/v1/items")
    assert resp.is_success
```

Pour les tests unitaires sans I/O réseau, préférez **`FakeSyncTransport`** /
**`FakeAsyncTransport`** ([**`docs/testing_without_network.md`**](docs/testing_without_network.md)).

## Configuration (`ApiClientConfig`)

La configuration est une dataclass **immuable** dans
[`baobab_api_call.config`](src/baobab_api_call/config.py). **`base_url`** est
obligatoire (HTTP/HTTPS, sans identifiants dans l’autorité, normalisation du slash
final). Les autres champs sont optionnels avec des défauts sûrs.

| Champ | Rôle |
|-------|------|
| **`base_url`** | Racine des URLs résolues par les clients. |
| **`default_headers`** | En-têtes fusionnés avec ceux de chaque appel (la requête l’emporte en cas de clé identique). |
| **`timeout_seconds`** | Délai global (secondes), défaut **30** si omis. |
| **`timeouts`** | **`TimeoutConfig`** détaillé (**F-08**) ; **incompatible** avec **`timeout_seconds`** sur le même constructeur (**`ApiConfigError`**). |
| **`retry_policy`** | **`RetryPolicy`** ; défaut équivalent à pas de nouvelle tentative tant que **`max_attempts == 1`**. |
| **`auth_strategy`** | **`AuthStrategy`** (**F-06**) ou **`None`**. |
| **`middlewares`** | Tuple **`Middleware`** pour **`SyncApiClient`** (**F-09**). |
| **`async_middlewares`** | Tuple **`MiddlewareAsync`** pour **`AsyncApiClient`** (champ séparé — **DEC-0060**). |
| **`logging_config`** | **`LoggingConfig`** pour les middlewares de log (**F-10**). |
| **`json_serializer`** | Port **`JsonSerializer`** pour **`json=`** ; défaut **`DefaultJsonSerializer()`** côté clients si **`None`**. |

Pour ajuster un client sans muter l’instance, utilisez **`config.copy_with(...)`**
(validation identique à une construction neuve). **`repr(config)`** masque les
en-têtes sensibles et la stratégie d’auth.

## Quickstart

Le package expose progressivement les briques du socle HTTP. À ce stade,
l’import racine rend notamment disponibles `__version__`, `ApiClientConfig`,
`ApiError`, `ApiHTTPError`, ainsi que **`ApiConfigError`**, **`ApiNetworkError`**,
**`ApiTimeoutError`**, **`ApiRetryError`** et **`ApiSerializationError`**
(hiérarchie F-07 / erreurs typées sans dépendance au transport sous-jacent à ce
stade), `ApiRequest`, **`ApiResponse`**, **`TimeoutConfig`**, **`RetryPolicy`**
(politique de nouvelles tentatives : backoff exponentiel / jitter, `should_retry`,
`compute_backoff` ; désactivée par défaut avec `max_attempts=1`), **`SyncApiClient`**
(client synchrone générique qui construit des `ApiRequest` et appelle
`transport.send`), **`AsyncApiClient`** (client async : même orchestration retry /
timeout sur `request` que le synchrone — **BL-08-04** ; cycle de vie `aclose` /
`async with` — **BL-05-03**), le protocole **`SyncTransport`** (port de transport synchrone,
défini dans `baobab_api_call.transports`), le protocole **`AsyncTransport`**
(port async : `send` / `aclose`, même module — **BL-05-01**), le protocole
**`AuthStrategy`** (module `baobab_api_call.auth`, réexport racine — **BL-06-01**) :
enrichissement synchrone des requêtes via `apply(request) -> ApiRequest`, sans
mutation documentée de l’entrée ; stratégies livrées **`BearerAuth`**,
**`ApiKeyAuth`**, **`BasicAuth`** (**BL-06-02** ; exemples ci-dessous) ; branchement
dans les clients via **`ApiClientConfig.auth_strategy`** (**BL-06-03**) ; l’alias de type **`HTTPMethod`**
(`baobab_api_call.sync_client`, verbes HTTP acceptés par `ApiRequest`), ainsi que
les symboles du module **`metadata`**
réexportés au package racine : `CORRELATION_ID_KEY`, `REQUEST_ID_KEY`,
`TRACE_ID_KEY`, `generate_correlation_id`, `with_correlation_id`,
`get_correlation_id` (voir `baobab_api_call.__all__`). Les **retries** et le
**timeout** effectif sur la requête sont orchestrés de la même façon dans
`SyncApiClient.request` et `AsyncApiClient.request` à partir de `config.retry_policy`
et `timeout_seconds` / `TimeoutConfig` (défaut retry équivalent à `RetryPolicy()`
sans nouvelle tentative tant que `max_attempts == 1`) — **BL-08-03** /
**BL-08-04** ; protocoles **`Middleware`** et **`MiddlewareAsync`**
(`baobab_api_call.middleware`, réexport racine — **BL-09-01**, **DEC-0056**) :
hooks `before_request`, `after_response`, `on_error`. La chaîne sync
**`SyncMiddlewareChain`** et **`ApiClientConfig.middlewares`** sont branchées dans
**`SyncApiClient.request`** (**BL-09-02**, **DEC-0058**) ; la chaîne async
**`AsyncMiddlewareChain`** et **`ApiClientConfig.async_middlewares`** dans
**`AsyncApiClient.request`** (**BL-09-03**, **DEC-0058**, **DEC-0060**). Les garde-fous
(isolation, `replace`, erreurs / **`on_error`**, non-fuite de secret dans le message) sont couverts
par les tests **`tests/unit/test_middleware_isolation_mutations_errors.py`** (**BL-09-04**).
**`LoggingConfig`** (**BL-10-01**, réexport racine) décrit une politique de logs HTTP sûre par défaut
(`enabled=False`, corps requête/réponse non journalisés par défaut, jeux de rédaction) ; **`ApiClientConfig`**
accepte un champ optionnel **`logging_config`**. Les helpers de rédaction **`redact_headers`**,
**`redact_query_params`**, **`redact_body_keys`**, **`redact_url_query`** et les constantes
**`DEFAULT_SENSITIVE_*`** / **`DEFAULT_PLACEHOLDER`** sont fournis par **`baobab_api_call.redaction`**
(réexport racine — **BL-10-02**) pour composer des traces ou représentations sans fuite, en combinaison avec
**`LoggingConfig.redacted_value`** et les jeux de clés sensibles. Les middlewares **`LoggingMiddleware`** /
**`LoggingMiddlewareAsync`** (**BL-10-03**, même **`LoggingConfig`** que **`ApiClientConfig.logging_config`**) émettent
les traces HTTP requête / réponse dans la chaîne sync ou async. La non-fuite des secrets dans ces traces sur les
surfaces prévues (auth, corps, query structurée, en-têtes, erreurs, retries) est couverte par les tests
**`tests/unit/test_logging_no_secret_leak.py`** (**BL-10-04**) ; limites **R-4** et messages d’exception volontairement
secrets : voir [**`docs/errors.md`**](docs/errors.md) et le rapport
[**`docs/ia_workflow/runs/BL-10-04/security_report.md`**](docs/ia_workflow/runs/BL-10-04/security_report.md).
**`JsonSerializer`** et **`DefaultJsonSerializer`**
([**`baobab_api_call.json_serializer`**](src/baobab_api_call/json_serializer.py), réexport racine — **BL-11-01**) :
port structurel pour encoder / décoder du JSON (sortie **`dumps`** en octets UTF-8 pour les corps HTTP) ; l’implémentation
par défaut s’appuie sur **`json`** et relève **`ApiSerializationError`** sur échec (cause et payload conservés).
**BL-11-02** : **`ApiClientConfig.json_serializer`** (optionnel) pilote la sérialisation des appels **`json={…}`** sur les
clients sync et async ; si **`None`**, le socle utilise **`DefaultJsonSerializer()`**.
**`FakeSyncTransport`** / **`FakeAsyncTransport`** ([**`baobab_api_call.testing`**](src/baobab_api_call/testing.py),
réexport racine — **BL-12-01** / **BL-12-02**, **DEC-0079** / **DEC-0081**) : doubles de test sans I/O réseau pour
**`SyncTransport`** et **`AsyncTransport`** (même file scriptée d’**`ApiResponse`** / **`BaseException`** / callables,
historique **`requests`**, cycles **`close`** / **`aclose`** idempotents ; voir *Doubles de test* ci-dessous).
**Fabriques et scénarios** (**BL-12-03**, **DEC-0083**) : **`make_response`**, **`make_request`**, séquences et alias
(**`http_errors_then_json_success`**, **`EXAMPLE_API_URL`**, etc.), réexport racine — guide
[**`docs/testing_without_network.md`**](docs/testing_without_network.md). La bibliothèque ne configure pas la pile **`logging`**
globale : activer le journal côté **application** avec **`logging.getLogger(cfg.logger_name)`** et vos handlers
(`basicConfig`, `dictConfig`, etc.). **Auth** :
exemples d’usage
ci-dessous et docstrings dans
[`baobab_api_call/auth.py`](src/baobab_api_call/auth.py). Guide d’extension pour
construire une librairie fille : [**`docs/extension_guide.md`**](docs/extension_guide.md)
(**BL-13-01**, **F-13**) ; politique **logs / rédaction** pour packages fils :
[**`docs/security_logging.md`**](docs/security_logging.md) (**BL-13-03**) ; exemples détaillés : **BL-13-02**.

```python
import baobab_api_call
from baobab_api_call import (
    ApiClientConfig,
    ApiHTTPError,
    ApiRequest,
    ApiResponse,
    SyncApiClient,
    SyncTransport,
    get_correlation_id,
    with_correlation_id,
)
from baobab_api_call.sync_api_client import HTTPMethod

print(baobab_api_call.__version__)  # 2.1.0

resp = ApiResponse(status_code=200, content=b'{"ok": true}')
assert resp.is_success
assert resp.json() == {"ok": True}

# Sans transport : corrélation légère sur la requête, propagation vers la réponse.
req = ApiRequest(method="GET", path="/items", metadata=with_correlation_id(None))
cid = get_correlation_id(req.metadata)
resp2 = ApiResponse(status_code=200, content=b"{}").with_request_metadata(req)
assert get_correlation_id(resp2.metadata) == cid

# Sans transport : construire une réponse « à la main », puis contrôler les erreurs HTTP.
ApiResponse(status_code=200, content=b"").raise_for_status()  # no-op (< 400)

try:
    ApiResponse(status_code=404, content=b"").raise_for_status()
except ApiHTTPError as exc:
    assert exc.status_code == 404
    assert str(exc) == "HTTP 404 Not Found"

# Port SyncTransport : double minimal sans réseau (pas de bibliothèque HTTP).
class StubSyncTransport:
    """Implémentation factice pour tests ou squelette ; aucun appel réseau."""

    def send(self, request: ApiRequest) -> ApiResponse:
        _ = request
        return ApiResponse(status_code=200, content=b"")

    def close(self) -> None:
        pass


transport: SyncTransport = StubSyncTransport()
try:
    stub_resp = transport.send(ApiRequest(method="GET", path="/health"))
    assert stub_resp.is_success
finally:
    transport.close()

# Client synchrone : même stub — aucun client HTTP réel.
config = ApiClientConfig(base_url="https://api.example.com")
client_transport: SyncTransport = StubSyncTransport()
sync_client = SyncApiClient(config, client_transport)
try:
    via_client = sync_client.get("/v1/items")
    assert via_client.is_success
    _verb: HTTPMethod = "GET"
    via_request = sync_client.request(_verb, "/search", params={"q": "x"})
    assert via_request.is_success
finally:
    client_transport.close()

# Même configuration et stub : usage recommandé avec context manager
# (appelle close() à la sortie du bloc, y compris en cas d'exception).
with SyncApiClient(config, StubSyncTransport()) as client:
    assert not client.is_closed
    via_cm = client.get("/v1/items")
    assert via_cm.is_success
assert client.is_closed
```

### Sérialisation JSON (`JsonSerializer`)

```python
from baobab_api_call import DefaultJsonSerializer, JsonSerializer

ser: JsonSerializer = DefaultJsonSerializer()
blob = ser.dumps({"count": 2})
assert ser.loads(blob) == {"count": 2}
assert ser.loads(blob.decode("utf-8")) == {"count": 2}
```

**Sérialiseur personnalisé** (**BL-11-04**) : toute implémentation du port **`JsonSerializer`** peut être injectée via
**`ApiClientConfig.json_serializer`** ou **`ApiResponse.json(serializer=…)`**. Les tests
**`tests/unit/test_json_serializer_extensibility.py`** couvrent un round-trip custom, une signature tolérante (kwargs
supplémentaires, style **orjson**) et la parité avec le défaut. Exemple minimal — même contrat que
**`DefaultJsonSerializer`** pour des charges **`dict`** simples :

```python
import json

from baobab_api_call import JsonSerializer


class StdlibUtf8Json:
    """Structural typing : le port est satisfait sans héritage explicite."""

    def dumps(self, obj: object) -> bytes:
        return json.dumps(obj, ensure_ascii=False).encode("utf-8")

    def loads(self, data: bytes | str) -> object:
        text = data.decode("utf-8") if isinstance(data, bytes) else data
        return json.loads(text)


assert isinstance(StdlibUtf8Json(), JsonSerializer)
```

**Requêtes HTTP avec corps JSON** (BL-11-02) : passez un objet sérialisable au paramètre **`json=`** du client ; le corps
envoyé au transport est en octets et **`ApiRequest.json`** reste **`None`** après construction par le client (évite la double
porte du même payload). Exemple minimal avec le même **`StubSyncTransport`** que ci-dessus :

```python
from baobab_api_call import ApiClientConfig, DefaultJsonSerializer, ApiResponse, SyncApiClient


class StubSyncTransport:
    """Transport factice : vérifie le corps sérialisé."""

    def send(self, request):
        assert request.body == b'{"item":"a"}'
        assert request.json is None
        assert request.headers.get("Content-Type", "").startswith("application/json")
        return ApiResponse(status_code=201, content=b"{}")

    def close(self):
        pass


json_cfg = ApiClientConfig(
    base_url="https://api.example.com",
    json_serializer=DefaultJsonSerializer(),
)
with SyncApiClient(json_cfg, StubSyncTransport()) as json_client:
    json_client.post("/v1/items", json={"item": "a"})
```

**Réponses HTTP** (**BL-11-03**) : **`ApiResponse.json(*, serializer=None)`** utilise le même port **`JsonSerializer`**
que les requêtes (**`loads`** sur **`text`** en priorité, sinon **`content`**). Sans argument **`serializer`**, les clients
enrichissent **`metadata`** avec le sérialiseur de configuration sous la clé réservée **`JSON_SERIALIZER_METADATA_KEY`**
(réexport racine **`baobab_api_call.JSON_SERIALIZER_METADATA_KEY`**) ; une valeur incorrecte sous cette clé lève
**`TypeError`**. Le corps brut reste dans **`content`** / **`text`**.

**Compatibilité :** ne vous appuyez pas sur l’identité d’objet (**`is`**) entre la **`ApiResponse`** renvoyée par votre
transport et celle exposée par **`SyncApiClient`** / **`AsyncApiClient`** : le socle peut retourner une instance distincte
avec la même charge utile mais une **`metadata`** enrichie pour **`json()`**. **`json()`** n’accepte que le mot-clé
**`serializer=`** ; elle ne transmet pas d’options nommées à **`json.loads`** — utilisez un **`JsonSerializer`** dédié pour
des réglages fins.

### Authentification : Bearer, clé API, Basic

Les classes **`BearerAuth`**, **`ApiKeyAuth`** et **`BasicAuth`** implémentent
**`AuthStrategy`** : elles retournent une **nouvelle** `ApiRequest` enrichie
(en-têtes et/ou paramètres de query), sans muter l’instance passée à `apply`.

> **Secrets et données sensibles** — Ne commitez jamais de jetons, mots de passe
> ou clés API réels dans le code ou la documentation. Chargez les credentials
> depuis l’environnement, un gestionnaire de secrets ou un coffre adapté à votre
> contexte. Pour **`ApiKeyAuth`** en mode query (`scheme` non `None`), la valeur
> peut apparaître dans des journaux serveur ou proxy, l’historique client ou des
> en-têtes `Referer` : préférez le **mode en-tête** (`scheme=None`, défaut
> `header_name="X-API-Key"`) lorsque la confidentialité prime.

```python
from baobab_api_call import ApiRequest, ApiKeyAuth, BasicAuth, BearerAuth

base = ApiRequest(method="GET", path="https://api.example.com/v1/me")

# Bearer : remplace tout en-tête Authorization déjà présent
bearer_req = BearerAuth(token="opaque-token").apply(base)
assert bearer_req.headers["Authorization"] == "Bearer opaque-token"

# Clé API en en-tête (nom personnalisable ; fusion avec les autres en-têtes)
header_key_req = ApiKeyAuth(api_key="sk-example", header_name="X-API-Key").apply(base)

# Clé API en query : scheme = nom du paramètre (?api_key=...) — exclusif avec le mode header
query_key_req = ApiKeyAuth(api_key="sk-example", scheme="api_key").apply(base)
assert query_key_req.params.get("api_key") == "sk-example"

# Basic : RFC 7617 (UTF-8 puis Base64 ASCII dans Authorization)
basic_req = BasicAuth(username="user", password="pass").apply(base)
assert basic_req.headers["Authorization"].startswith("Basic ")
```

**Clients sync / async :** avec **`ApiClientConfig(..., auth_strategy=…)`**, chaque
appel à **`request`** / **`get`** / … construit d’abord la requête (fusion
**`default_headers`** + en-têtes d’appel, règles `json` / `body`), applique une fois
**`auth_strategy.apply(req)`** si la stratégie n’est pas **`None`**, puis enchaîne
transport et retry sur **la même** instance **`ApiRequest`** enrichie. Les collisions
d’en-têtes (ex. **`Authorization`** déjà présent) suivent le comportement documenté
de la stratégie — pour **`BearerAuth`**, l’en-tête **`Authorization`** est remplacé.

```python
from baobab_api_call import ApiClientConfig, BearerAuth, SyncApiClient

# Réutilise StubSyncTransport défini plus haut dans ce README.
auth_sync_cfg = ApiClientConfig(
    base_url="https://api.example.com",
    auth_strategy=BearerAuth(token="opaque-token"),
)
with SyncApiClient(auth_sync_cfg, StubSyncTransport()) as authed_sync:
    _ = authed_sync.get("/v1/me")  # Authorization injecté par BearerAuth
```

Pour **`AsyncApiClient`**, la configuration est identique ; un exemple minimal avec le
**`StubAsyncTransport`** défini dans **Quickstart async** suit plus bas (après la classe).

On peut toujours composer **`apply`** à la main sur une **`ApiRequest`** avant de la
passer à un transport hors **`SyncApiClient`** / **`AsyncApiClient`**.

### Chaîne de middlewares synchrones (BL-09-02)

**`ApiClientConfig(..., middlewares=(…))`** attend un **tuple** d’implémentations
**`Middleware`**. Ordre d’exécution (**DEC-0058**) : tous les **`before_request`** dans
l’ordre d’enregistrement, puis le transport, puis **`after_response`** en sens **inverse**
(patron « oignon »). **`on_error`** n’est appelé que si le transport **lève** une exception
(une réponse HTTP 4xx/5xx **sans** exception reste un chemin normal avec **`after_response`**).
La chaîne est rejouée **à chaque tentative** retry. La classe **`SyncMiddlewareChain`**
vit dans **`baobab_api_call.middleware`** (import explicite depuis ce module).

> Le docstring du module **`middleware`** évoque une **`ApiResponse`** synthétique pour
> court-circuiter le transport : ce mécanisme **n’est pas** implémenté dans la chaîne
> actuelle ; seuls les hooks du protocole **`Middleware`** sont pris en charge.

Exemple minimal sans réseau : un traceur de corrélation (**`with_correlation_id`**) puis un
injecteur d’en-tête factice (ne pas y placer de secrets).

```python
import logging
from dataclasses import replace

from baobab_api_call import (
    ApiClientConfig,
    ApiRequest,
    ApiResponse,
    LoggingConfig,
    LoggingMiddleware,
    SyncApiClient,
    get_correlation_id,
    with_correlation_id,
)


class CorrelationMiddleware:
    """Enrichit la metadata ; propage la corrélation vers la réponse en ``after_response``."""

    def before_request(self, request: ApiRequest) -> ApiRequest:
        return replace(request, metadata=with_correlation_id(request.metadata))

    def after_response(self, request: ApiRequest, response: ApiResponse) -> ApiResponse:
        return response.with_request_metadata(request)

    def on_error(self, request: ApiRequest, exc: BaseException) -> None:
        _ = (request, exc)


class HeaderInjectorMiddleware:
    """Ajoute un en-tête diagnostic (exemple uniquement)."""

    def before_request(self, request: ApiRequest) -> ApiRequest:
        merged = dict(request.headers or {})
        merged.setdefault("X-Demo-Client", "baobab-readme")
        return replace(request, headers=merged)

    def after_response(self, request: ApiRequest, response: ApiResponse) -> ApiResponse:
        return response

    def on_error(self, request: ApiRequest, exc: BaseException) -> None:
        _ = (request, exc)


log_cfg = LoggingConfig(enabled=True)  # niveau par défaut : INFO ; rédaction alignée BL-10-01 / BL-10-02
logging.getLogger(log_cfg.logger_name).setLevel(logging.INFO)

mw_cfg = ApiClientConfig(
    base_url="https://api.example.com",
    logging_config=log_cfg,
    middlewares=(
        LoggingMiddleware(log_cfg),
        CorrelationMiddleware(),
        HeaderInjectorMiddleware(),
    ),
)
with SyncApiClient(mw_cfg, StubSyncTransport()) as mw_client:
    mw_resp = mw_client.get("/v1/items")
    assert mw_resp.is_success
    assert get_correlation_id(mw_resp.metadata) is not None
```

## Démarrage rapide — client asynchrone

La même instance **`ApiClientConfig`** alimente **`AsyncApiClient`** et
**`SyncApiClient`** : timeouts, **`retry_policy`**, **`auth_strategy`**,
**`json_serializer`**, etc. Les hooks **`Middleware`** synchrones ne sont pas
rejoués automatiquement côté async : utilisez **`async_middlewares`** (**DEC-0060**).

`AsyncApiClient` suit la même construction de requête que le client synchrone
(URL résolue, fusion d’en-têtes, `json` vs `body`) et la même boucle retry /
timeout sur `request` que `SyncApiClient` (**BL-08-04**), avec attentes
`await sleep(...)` entre tentatives (`sleep` injectable, défaut `asyncio.sleep`).
Le cycle de vie async (`aclose`, `async with`) est exposé depuis **BL-05-03** : fermeture
idempotente, délégation à `transport.aclose` si le transport la fournit, et
`RuntimeError` si on appelle le client après fermeture.

```python
import asyncio

from baobab_api_call import (
    ApiClientConfig,
    ApiRequest,
    ApiResponse,
    AsyncApiClient,
    AsyncTransport,
    BearerAuth,
)

class StubAsyncTransport:
    """Double minimal sans réseau ; implémente le port ``AsyncTransport``."""

    async def send(self, request: ApiRequest) -> ApiResponse:
        _ = request
        return ApiResponse(status_code=200, content=b"{}")

    async def aclose(self) -> None:
        pass

async def main() -> None:
    config = ApiClientConfig(base_url="https://api.example.com")
    # Usage recommandé : context manager async (appelle aclose à la sortie).
    async with AsyncApiClient(config, StubAsyncTransport()) as client:
        assert not client.is_closed
        resp = await client.get("/v1/items")
        assert resp.is_success
    assert client.is_closed

asyncio.run(main())

# Même StubAsyncTransport : client async avec auth configurée (BL-06-03)
async def authed_async_example() -> None:
    auth_cfg = ApiClientConfig(
        base_url="https://api.example.com",
        auth_strategy=BearerAuth(token="opaque-token"),
    )
    async with AsyncApiClient(auth_cfg, StubAsyncTransport()) as authed:
        _ = await authed.get("/v1/me")

# asyncio.run(authed_async_example())
```

### Chaîne de middlewares asynchrones (BL-09-03)

**`ApiClientConfig(..., async_middlewares=(…))`** attend un **tuple** d’implémentations
**`MiddlewareAsync`** (méthodes **`async def`**). L’ordre d’exécution est le même que pour la
chaîne sync (**DEC-0058**) : **`before_request`** dans l’ordre, puis transport, puis
**`after_response`** en sens inverse ; **`on_error`** uniquement si le transport lève. La chaîne
est rejouée **à chaque tentative** retry. **`middlewares`** (sync) n’est **pas** utilisé par
**`AsyncApiClient`** : en usage mixte, dupliquer la logique dans des middlewares async ou des
implémentations dual sync/async (**DEC-0060**). **`AsyncMiddlewareChain`** vit dans
**`baobab_api_call.middleware`** (import explicite).

Exemple minimal : réutiliser la classe **`StubAsyncTransport`** du Quickstart async ci-dessus.

```python
import logging
from dataclasses import replace

from baobab_api_call import (
    ApiClientConfig,
    ApiRequest,
    ApiResponse,
    AsyncApiClient,
    LoggingConfig,
    LoggingMiddlewareAsync,
    get_correlation_id,
    with_correlation_id,
)


class AsyncCorrelationMiddleware:
    async def before_request(self, request: ApiRequest) -> ApiRequest:
        return replace(request, metadata=with_correlation_id(request.metadata))

    async def after_response(self, request: ApiRequest, response: ApiResponse) -> ApiResponse:
        return response.with_request_metadata(request)

    async def on_error(self, request: ApiRequest, exc: BaseException) -> None:
        _ = (request, exc)


class AsyncHeaderInjectorMiddleware:
    async def before_request(self, request: ApiRequest) -> ApiRequest:
        merged = dict(request.headers or {})
        merged.setdefault("X-Demo-Client", "baobab-readme-async")
        return replace(request, headers=merged)

    async def after_response(self, request: ApiRequest, response: ApiResponse) -> ApiResponse:
        return response

    async def on_error(self, request: ApiRequest, exc: BaseException) -> None:
        _ = (request, exc)


async def async_middleware_demo() -> None:
    log_cfg = LoggingConfig(enabled=True)
    logging.getLogger(log_cfg.logger_name).setLevel(logging.INFO)
    mw_async_cfg = ApiClientConfig(
        base_url="https://api.example.com",
        logging_config=log_cfg,
        async_middlewares=(
            LoggingMiddlewareAsync(log_cfg),
            AsyncCorrelationMiddleware(),
            AsyncHeaderInjectorMiddleware(),
        ),
    )
    async with AsyncApiClient(mw_async_cfg, StubAsyncTransport()) as mw_client:
        mw_resp = await mw_client.get("/v1/items")
        assert mw_resp.is_success
        assert get_correlation_id(mw_resp.metadata) is not None


# asyncio.run(async_middleware_demo())
```

### Doubles de test — transports factices (`FakeSyncTransport`, `FakeAsyncTransport`)

#### `FakeSyncTransport` (BL-12-01)

**`FakeSyncTransport`** ([**`baobab_api_call.testing`**](src/baobab_api_call/testing.py), réexport racine —
**BL-12-01**, **DEC-0079**) est un transport synchrone **factice** destiné aux tests des librairies filles
et des middlewares : aucune ouverture de socket, aucune dépendance HTTP, comportement entièrement piloté
par une **file scriptée**. Il satisfait structurellement le port **`SyncTransport`** (`send` / `close`).

| API | Comportement |
|---|---|
| `FakeSyncTransport(responses=())` | File initiale FIFO ; chaque entrée est soit une `ApiResponse`, soit une `BaseException`, soit une callable `(ApiRequest) -> ApiResponse \| BaseException`. |
| `add_response(resp_or_factory)` | Enfile une `ApiResponse` ou une fabrique `(ApiRequest) -> ApiResponse`. |
| `add_exception(exc_or_factory)` | Enfile une `BaseException` à lever ou une fabrique `(ApiRequest) -> BaseException`. |
| `responder(callback)` *(staticmethod)* | Alias lisible : retourne `callback`. |
| `send(request)` | Trace la requête dans `requests` puis consomme la prochaine entrée. Lève `RuntimeError("FakeSyncTransport: response queue exhausted (no scripted outcome left)")` si la file est vide, ou `RuntimeError("FakeSyncTransport.send() called after close()")` si appelé après `close`. |
| `close()` | Idempotent ; `closed` passe à `True`. |
| `closed` *(propriété)* | Vrai après le premier `close`. |
| `requests` *(propriété)* | **Copie défensive** (`list(...)`) de l’historique des `ApiRequest` reçus. |

> **Aucun réseau réel n’est possible** : la classe n’importe ni `socket`, ni `urllib*`, ni client HTTP ;
> les tests qui s’en servent restent hermétiques. Un retour callable hors contrat (objet ni `ApiResponse`
> ni `BaseException`) est renvoyé tel quel, sans validation runtime — utilisez les fabriques de manière
> conforme au protocole.

```python
from baobab_api_call import (
    ApiClientConfig,
    ApiRequest,
    ApiResponse,
    FakeSyncTransport,
    SyncApiClient,
)

# Réponses successives en FIFO + une exception scriptée pour la troisième tentative.
fake = FakeSyncTransport(
    responses=(
        ApiResponse(status_code=200, content=b'{"ok": true}'),
        ApiResponse(status_code=200, content=b'{"ok": true, "n": 2}'),
        TimeoutError("scripted slow upstream"),
    )
)

# Ajouts ultérieurs : réponses dynamiques basées sur la requête.
fake.add_response(
    FakeSyncTransport.responder(
        lambda req: ApiResponse(status_code=201, content=req.body or b"{}")
    )
)
fake.add_exception(ConnectionError("scripted disconnect"))

cfg = ApiClientConfig(base_url="https://api.example.com")
with SyncApiClient(cfg, fake) as client:
    first = client.get("/v1/items")
    assert first.is_success
    second = client.get("/v1/items", params={"page": 2})
    assert second.json() == {"ok": True, "n": 2}

# Inspection : ordre, identité et contenu des requêtes capturées.
assert [req.path.endswith("/v1/items") for req in fake.requests][:2] == [True, True]

# Cycle de vie : close() est idempotent ; tout send() ultérieur lève RuntimeError.
fake.close()
assert fake.closed
try:
    fake.send(ApiRequest(method="GET", path="/v1/items"))
except RuntimeError as exc:
    assert "after close()" in str(exc)
```

#### `FakeAsyncTransport` (BL-12-02)

**`FakeAsyncTransport`** (même module, réexport racine — **BL-12-02**, **DEC-0081**) est le **miroir async** de
**`FakeSyncTransport`** : même file FIFO et mêmes helpers, mais **`await send(...)`** / **`await aclose()`** et
conformité au port **`AsyncTransport`**.

| API | Comportement |
|---|---|
| `FakeAsyncTransport(responses=())` | Identique au constructeur sync : file initiale scriptée. |
| `add_response` / `add_exception` / `responder` | Identiques au fake sync. |
| `await send(request)` | Comme **`FakeSyncTransport.send`**, avec messages **`FakeAsyncTransport: …`** et **`after aclose()`**. |
| `await aclose()` | Idempotent ; **`closed`** passe à **`True`**. |
| `closed` / `requests` | Même sémantique que le fake sync. |

```python
import asyncio

from baobab_api_call import ApiClientConfig, ApiRequest, ApiResponse, AsyncApiClient, FakeAsyncTransport


async def main() -> None:
    fake = FakeAsyncTransport(responses=(ApiResponse(status_code=200, content=b"{}"),))
    cfg = ApiClientConfig(base_url="https://api.example.com")
    async with AsyncApiClient(cfg, fake) as client:
        resp = await client.get("/v1/ping")
        assert resp.is_success
    await fake.aclose()
    assert fake.closed


# asyncio.run(main())
```

**Résolution d’URL (`SyncApiClient`) :** le `path` passé à `request` / `get` /
etc. peut être relatif (joint à `ApiClientConfig.base_url`, sans double slash
indésirable) ou déjà une URL absolue `http://` ou `https://`. La valeur reçue
par le transport dans `ApiRequest.path` est **toujours** l’URL absolue ainsi
obtenue.

**Configuration client (`ApiClientConfig`) et timeouts :** un délai global
rétrocompatible (`timeout_seconds`, défaut 30 s) ou une instance
`TimeoutConfig` pour affiner `connect`, `read`, `write` et `total` ; ne pas
passer les deux modes sur le même constructeur (`ApiConfigError`). La classe
`TimeoutConfig` est réexportée au package racine (`baobab_api_call.__all__`).

**Retries (`SyncApiClient` et `AsyncApiClient`) :** la politique est lue sur
`ApiClientConfig.retry_policy` ou, si `None`, une `RetryPolicy()` par défaut.
Entre deux tentatives, le client synchrone appelle `sleep(secondes)` ; le client
async appelle `await sleep(secondes)` (`sleep` injectable, défaut `asyncio.sleep`).
En tests sync, passez par exemple `sleep=lambda _: None` ; en async,
`sleep` async no-op pour ne pas attendre. Après la dernière tentative infructueuse
sur une erreur HTTP ou une exception jugée replayable par la politique, une
`ApiRetryError` est levée (avec `attempts`, `last_error`, `request`).

```python
from baobab_api_call import ApiClientConfig, ApiRequest, ApiResponse, RetryPolicy, SyncApiClient

class StubTransport:
    def send(self, request: ApiRequest) -> ApiResponse:
        return ApiResponse(status_code=200, content=b"{}")

    def close(self) -> None:
        pass


retry_cfg = ApiClientConfig(
    base_url="https://api.example.com",
    retry_policy=RetryPolicy(max_attempts=3, jitter=False),
)
# sleep injecté : rapide en tests ; production : omettre pour time.sleep
with SyncApiClient(retry_cfg, StubTransport(), sleep=lambda _: None) as client:
    _ = client.get("/v1/ping")
```

**Logging (`LoggingConfig`, BL-10-01) :** le socle expose un **modèle de configuration** immuable ; aucune ligne
n’est émise tant qu’un middleware ou votre code n’utilise pas ces réglages. Par défaut : **`enabled=False`**,
**`log_request_body=False`**, **`log_response_body=False`**, niveau **`INFO`**, marqueur de rédaction **`***`** et
listes de clés sensibles pour en-têtes, query et corps (voir le module
[`baobab_api_call/logging_config.py`](src/baobab_api_call/logging_config.py)). Les opérations de masquage
réutilisables (**`redact_*`**, constantes **`DEFAULT_SENSITIVE_*`**) sont dans
[`baobab_api_call/redaction.py`](src/baobab_api_call/redaction.py) (**BL-10-02**). Attacher une instance à la config client :

```python
import logging

from baobab_api_call import ApiClientConfig, LoggingConfig

lc = LoggingConfig(enabled=True)  # défaut level=INFO ; ajuster logger_name, rédaction via with_overrides si besoin
logging.getLogger(lc.logger_name).setLevel(logging.INFO)  # handlers : à votre charge (racine ou logger dédié)

cfg = ApiClientConfig(base_url="https://api.example.com", logging_config=lc)
# Brancher la journalisation HTTP : ajouter LoggingMiddleware(lc) à middlewares (sync) ou
# LoggingMiddlewareAsync(lc) à async_middlewares — voir les sections chaîne de middlewares ci-dessus (**BL-10-03**).
```

La chaîne **F-10** (*Logging et rédaction*) est bouclée par **BL-10-04** (tests sécurité des logs) après merge sur `main` ; des évolutions hors périmètre F-10 (ex. métriques, corps de réponse enrichi) restent possibles sur la roadmap globale.

**Corps de requête :** ne pas combiner `json` et `body` sur le même appel — un
conflit lève `ValueError` avec le message `Cannot specify both 'json' and
'body'.`. Si seul `json` est fourni et qu’aucun `Content-Type` n’est défini
après fusion des en-têtes, `Content-Type: application/json` est ajouté.

Comportement livré avec le backlog **BL-04-03**.

## Tests sans réseau réel — garde-fou pytest (BL-12-04)

La suite **`pytest`** du dépôt s’exécute **sans accès réseau réel**. Le fichier
[`tests/conftest.py`](tests/conftest.py) installe, via les hooks **`pytest_configure`** /
**`pytest_unconfigure`**, des monkeypatches globaux sur **`socket.socket.connect`** et
**`socket.getaddrinfo`** : tout appel sortant lève une **`RuntimeError`** dont le message commence
par **`NetworkGuard:`** (constante **`NETWORK_GUARD_MESSAGE`**). Si **`httpx`** ou **`requests`**
sont importables dans l’environnement (ils ne sont **pas** des dépendances du paquet), les chemins
réseau par défaut de ces bibliothèques sont également neutralisés ; **`httpx.MockTransport`**, les
**`FakeSyncTransport`** / **`FakeAsyncTransport`** du socle et tout transport scripté restent
parfaitement utilisables — ils ne passent pas par ces primitives.

Preuves et non-régression : [`tests/unit/test_no_network_guard.py`](tests/unit/test_no_network_guard.py)
(blocage **`socket`** / **`getaddrinfo`**, préfixe message stable, **`SyncApiClient`** /
**`AsyncApiClient`** avec **`FakeSyncTransport`** / **`FakeAsyncTransport`**, cas **httpx** /
**requests** en **`pytest.importorskip`**). Politique complète, dérogations « hors socle » et
exemples : [`docs/testing_without_network.md`](docs/testing_without_network.md) (**DEC-0085**).

## Sécurité des secrets d’authentification (tests)

Les garde-fous contre la fuite de jetons ou mots de passe dans les surfaces
observables du périmètre auth (**`repr` / `str`**, messages d’exception,
logger **`baobab_api_call.auth`** pendant **`apply`**) sont exercés par les
tests unitaires
[`tests/unit/test_auth_secret_redaction.py`](tests/unit/test_auth_secret_redaction.py)
(**BL-06-04**, **DEC-0053**). Pour la politique générale des messages d’erreur
« safe », les risques résiduels (**R-4**, exception **`exc_message`** dans les logs middleware) et les tests **caplog**
sur le middleware de logging, voir
[`docs/errors.md`](docs/errors.md),
[`tests/unit/test_logging_no_secret_leak.py`](tests/unit/test_logging_no_secret_leak.py) (**BL-10-04**) et les tests **BL-07-04**
(`tests/unit/test_error_message_safety.py`). Les journaux HTTP requête / réponse peuvent être émis via
**`LoggingMiddleware`** / **`LoggingMiddlewareAsync`** et **`LoggingConfig`** (**BL-10-03**) ; le modèle
**`LoggingConfig`** et **`ApiClientConfig.logging_config`** sont livrés avec **BL-10-01** ; les helpers
**`redact_*`** et constantes **`DEFAULT_SENSITIVE_*`** (**`baobab_api_call.redaction`**) avec **BL-10-02**.

## Erreurs (réseau et JSON)

Politique détaillée des messages d’exception (« safe »), hiérarchie et risques
résiduels : voir [`docs/errors.md`](docs/errors.md).

En usage synchrone, `SyncApiClient` convertit les erreurs basses du transport
(`TimeoutError`, `ConnectionError`, `OSError`) en `ApiTimeoutError` ou
`ApiNetworkError`, en conservant la cause (`__cause__`). Les échecs de
`ApiResponse.json()` se manifestent par une `ApiSerializationError` plutôt que
par `json.JSONDecodeError` ou `UnicodeDecodeError` directement — l’exception
d’origine reste accessible via `exc.cause` / `exc.__cause__` (*breaking soft*,
BL-07-03).

La version est gérée comme **single source of truth** dans
`src/baobab_api_call/__init__.py` et exposée dynamiquement au packaging
via [`hatchling`](https://hatch.pypa.io/) (champ `dynamic = ["version"]`
dans `pyproject.toml`).

## Commandes qualité

Les commandes ci-dessous reflètent les contrôles attendus par le projet
(cf. `.cursor/rules/010_python_quality_constraints.mdc` et `AGENTS.md`).

```bash
python -m pytest tests/ -q
python -m coverage run -m pytest
python -m coverage report
python -m black --check .
python -m flake8 src tests
python -m pylint src tests
python -m mypy src tests
python -m bandit -r src
```

Couverture cible globale : **>= 90 %**.

## Structure du dépôt

Arborescence simplifiée :

```text
.
├── src/
│   └── baobab_api_call/        # package Python (layout src)
│       ├── __init__.py         # __version__, __all__
│       └── py.typed            # marqueur PEP 561
├── tests/
│   ├── conftest.py
│   └── unit/                   # tests unitaires (sans réseau réel)
├── docs/
│   ├── guides/                 # hub utilisateur F-14 (erreurs, logs, sécu, tests)
│   ├── 001_specifications.md   # cahier des charges
│   ├── 001_dev_diary.md        # journal de développement IA
│   ├── extension_guide.md      # guide librairies filles sur le socle (BL-13-01 / F-13)
│   ├── security_logging.md     # logs et rédaction pour librairies filles (BL-13-03)
│   ├── 010 - user-stories/
│   ├── 020 - features/
│   ├── 030 - backlogs/
│   └── ia_workflow/            # runs, décisions, NO GO, BOARD
├── pyproject.toml
├── README.md
├── CHANGELOG.md
└── LICENSE
```

## Licence

Distribué sous licence **MIT**. Voir le fichier [`LICENSE`](LICENSE) pour
le texte complet (copyright `baobab project contributors`, 2026).

## Contribution

Le projet suit un workflow piloté par documents et par agents IA :

```text
Cahier des charges → User Stories → Features → Backlogs → Prompts → Branches → PR → Merge
```

Avant toute contribution, lire :

- [`AGENTS.md`](AGENTS.md) — gouvernance des agents IA et règles Git.
- [`docs/ia_workflow/`](docs/ia_workflow/) — runs par backlog, journal des
  décisions, fiches NO GO, tableau de bord.
- `.cursor/rules/` — règles projet (qualité Python, sécurité,
  documentation, parallélisation).

Un fichier `CONTRIBUTING.md` détaillé est prévu pour une itération
ultérieure.

## Roadmap

Découpage haut niveau de la roadmap fonctionnelle (titres synthétiques,
détails dans `docs/020 - features/`) :

- **F-01** — Structure package, packaging et qualité projet *(en cours)*.
- **F-02** — Client HTTP synchrone générique.
- **F-03** — Client HTTP asynchrone générique.
- **F-04** — Erreurs HTTP et réseau typées.
- **F-05** — Retries et politiques de back-off.
- **F-06** — Authentification et gestion des credentials.
- **F-07** — Middlewares (requête / réponse).
- **F-08** — Sérialisation / désérialisation et payloads.
- **F-09** — Téléchargement / streaming.
- **F-10** — Logging et rédaction (chaîne **BL-10-01** … **BL-10-04** ; statut **CLOSED** après merge **BL-10-04**).
- **F-11** — Gestion de la pagination et des itérateurs.
- **F-12** — Cache HTTP côté client.
- **F-13** — Guide d’extension / librairies filles (**BL-13-01** … **BL-13-04** ; [**`docs/extension_guide.md`**](docs/extension_guide.md), [**`docs/security_logging.md`**](docs/security_logging.md)).
- **F-14** — Documentation utilisateur (**CLOSED**, **DEC-0094** ; hub [**`docs/guides/`**](docs/guides/README.md)).
- **F-15** — Stabilisation API publique et release `1.0.0`.
