mic-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. mic/_lazy.py +80 -0
  2. mic/auth/__init__.py +39 -0
  3. mic/auth/service_auth.py +317 -0
  4. mic/cli/__init__.py +24 -0
  5. mic/cli/builder.py +176 -0
  6. mic/cli/spec.py +68 -0
  7. mic/client/__init__.py +26 -0
  8. mic/client/async_service_client.py +210 -0
  9. mic/client/service_client.py +229 -0
  10. mic/contracts/__init__.py +15 -0
  11. mic/contracts/envelope.py +45 -0
  12. mic/contracts/strict_schema.py +48 -0
  13. mic/fastapi/__init__.py +79 -0
  14. mic/fastapi/_resolve.py +86 -0
  15. mic/fastapi/middlewares.py +680 -0
  16. mic/fastapi/router.py +186 -0
  17. mic/grpc/__init__.py +75 -0
  18. mic/grpc/client.py +175 -0
  19. mic/grpc/errors.py +110 -0
  20. mic/grpc/interceptors.py +183 -0
  21. mic/grpc/rpc_spec.py +125 -0
  22. mic/grpc/server.py +56 -0
  23. mic/grpc/spec.py +42 -0
  24. mic/healthcheck/__init__.py +40 -0
  25. mic/healthcheck/readiness.py +268 -0
  26. mic/http/__init__.py +40 -0
  27. mic/http/api_key.py +120 -0
  28. mic/http/auth_gate.py +335 -0
  29. mic/http/basic_auth.py +114 -0
  30. mic/http/bearer.py +67 -0
  31. mic/http/correlation.py +169 -0
  32. mic/http/csrf.py +173 -0
  33. mic/http/request_context.py +55 -0
  34. mic/http/route_spec.py +140 -0
  35. mic/litestar/__init__.py +64 -0
  36. mic/litestar/_resolve.py +86 -0
  37. mic/litestar/middlewares.py +503 -0
  38. mic/litestar/router.py +241 -0
  39. mic/model/__init__.py +21 -0
  40. mic/model/errors.py +130 -0
  41. mic/observability/__init__.py +208 -0
  42. mic/observability/caller.py +102 -0
  43. mic/observability/circuit_breaker_metrics.py +111 -0
  44. mic/observability/log_context_middleware.py +140 -0
  45. mic/observability/logging.py +442 -0
  46. mic/observability/metrics.py +105 -0
  47. mic/observability/metrics_backend.py +656 -0
  48. mic/observability/metrics_guard.py +185 -0
  49. mic/observability/redact_logs.py +101 -0
  50. mic/observability/redact_traces.py +115 -0
  51. mic/observability/sentry.py +98 -0
  52. mic/observability/tracing.py +207 -0
  53. mic/observability/tracing_middleware.py +142 -0
  54. mic/py.typed +0 -0
  55. mic/resilience/__init__.py +44 -0
  56. mic/resilience/circuit_breaker.py +301 -0
  57. mic_core-0.1.0.dist-info/METADATA +100 -0
  58. mic_core-0.1.0.dist-info/RECORD +59 -0
  59. mic_core-0.1.0.dist-info/WHEEL +4 -0
mic/_lazy.py ADDED
@@ -0,0 +1,80 @@
1
+ """Lazy package re-exports (PEP 562) — keeps ``import mic.X`` cheap and
2
+ import-safe even when the optional extra for X isn't installed.
3
+
4
+ Problem
5
+ -------
6
+ A package ``__init__.py`` that eagerly does
7
+ ``from mic.X.submodule import Symbol`` forces ``import mic.X`` to import
8
+ ``submodule`` at package-load time. If that submodule imports an optional
9
+ dependency at module top-level (``import prometheus_client``,
10
+ ``import httpx``, ``import grpc``, …), then ``import mic.X`` **crashes**
11
+ for anyone who ran ``pip install mic-struct`` without the ``[X]`` extra —
12
+ even if they only wanted a stdlib-only symbol from another submodule.
13
+
14
+ Fix
15
+ ---
16
+ Defer the submodule import to first attribute access via PEP 562
17
+ ``__getattr__``. ``import mic.X`` only runs ``__init__.py`` itself; the
18
+ heavy submodule loads when (and only when) a symbol from it is used.
19
+ If the optional dependency is missing at that point, the ``ImportError``
20
+ is re-raised with a clear ``pip install 'mic-struct[X]'`` hint.
21
+
22
+ This keeps the established "Philosophy B" of the codebase consistent:
23
+ *importing a package never fails for a missing optional dependency —
24
+ only actually using the dependency-backed symbol does.*
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib
30
+ from collections.abc import Callable
31
+ from typing import Any
32
+
33
+ #: Une entrée d'export lazy : ``(chemin du sous-module, nom de l'extra | None)``.
34
+ #: ``extra=None`` = sous-module sans dépendance optionnelle (stdlib only) —
35
+ #: le lazy reste utile pour ne pas charger inutilement, mais pas de
36
+ #: message "install extra" à émettre.
37
+ LazyExport = tuple[str, str | None]
38
+
39
+
40
+ def make_lazy_getattr(
41
+ *,
42
+ package: str,
43
+ exports: dict[str, LazyExport],
44
+ fallback: Callable[[str], Any] | None = None,
45
+ ) -> Callable[[str], Any]:
46
+ """Construit un ``__getattr__`` PEP 562 pour un ``__init__.py`` de package.
47
+
48
+ Args:
49
+ package: le ``__name__`` du package (pour les messages d'erreur).
50
+ exports: ``{symbole: (sous-module, extra | None)}``. Le sous-module
51
+ est importé à la demande au premier accès du symbole.
52
+ fallback: handler optionnel essayé quand le nom n'est **pas** dans
53
+ ``exports`` (ex: alias deprecated). Doit lever ``AttributeError``
54
+ s'il ne sait pas résoudre — sinon la vraie ``AttributeError``
55
+ Python serait masquée.
56
+
57
+ Returns:
58
+ Une fonction ``__getattr__(name) -> Any`` à assigner au niveau
59
+ module dans le ``__init__.py`` du package.
60
+ """
61
+
62
+ def __getattr__(name: str) -> Any:
63
+ entry = exports.get(name)
64
+ if entry is None:
65
+ if fallback is not None:
66
+ return fallback(name)
67
+ raise AttributeError(f"module {package!r} has no attribute {name!r}")
68
+ module_path, extra = entry
69
+ try:
70
+ module = importlib.import_module(module_path)
71
+ except ImportError as exc:
72
+ if extra is not None:
73
+ raise ImportError(
74
+ f"{package}.{name} requires the optional [{extra}] extra. "
75
+ f"Install it with: pip install 'mic-struct[{extra}]'"
76
+ ) from exc
77
+ raise
78
+ return getattr(module, name)
79
+
80
+ return __getattr__
mic/auth/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Service-to-service auth — JWT HS256 audience-bound.
2
+
3
+ - Émetteur signe avec un secret partagé (HS256)
4
+ - Audience-bound (chaque token est destiné à UN service)
5
+ - Double-key support pour rotation zéro-downtime : le verifier accepte
6
+ N et N-1 pendant la fenêtre de transition.
7
+
8
+ Import lazy (PEP 562) : ``import mic.auth`` ne charge PAS ``pyjwt``.
9
+ Les symboles tirent ``service_auth`` (donc ``jwt``) au premier accès —
10
+ si l'extra ``[auth]`` n'est pas installé, l'erreur est claire. Cf.
11
+ :mod:`mic._lazy`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from mic._lazy import LazyExport, make_lazy_getattr
19
+
20
+ if TYPE_CHECKING: # pragma: no cover — résolution de types only, pas de coût runtime
21
+ from mic.auth.service_auth import (
22
+ ServiceAuthClaims,
23
+ ServiceAuthSigner,
24
+ ServiceAuthVerifier,
25
+ )
26
+
27
+ _EXPORTS: dict[str, LazyExport] = {
28
+ "ServiceAuthClaims": ("mic.auth.service_auth", "auth"),
29
+ "ServiceAuthSigner": ("mic.auth.service_auth", "auth"),
30
+ "ServiceAuthVerifier": ("mic.auth.service_auth", "auth"),
31
+ }
32
+
33
+ __getattr__ = make_lazy_getattr(package=__name__, exports=_EXPORTS)
34
+
35
+ __all__ = [
36
+ "ServiceAuthClaims",
37
+ "ServiceAuthSigner",
38
+ "ServiceAuthVerifier",
39
+ ]
@@ -0,0 +1,317 @@
1
+ """ServiceAuthSigner + ServiceAuthVerifier — JWT HS256 audience-bound.
2
+
3
+ Convention (cf. ``service_auth_convention`` mémoire +
4
+ ``runbooks/RUNBOOK_SECRETS_ROTATION.md`` sentinel) :
5
+
6
+ - Algorithme : HS256 uniquement (pas RS256 ; les services partagent
7
+ un secret commun gardé en OpenBao).
8
+ - Claims standard : ``iss`` (émetteur), ``aud`` (destinataire),
9
+ ``iat``, ``exp``, ``sub`` optionnel.
10
+ - Double-key : ``ServiceAuthVerifier`` accepte une liste ordonnée
11
+ de secrets (``current`` puis ``previous``) pour permettre une
12
+ rotation sans downtime — cf. RUNBOOK_SECRETS_ROTATION.md procédure
13
+ générique étape 2-3.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import uuid
20
+ from datetime import UTC, datetime, timedelta
21
+ from typing import Any
22
+
23
+ import jwt
24
+
25
+ from mic.contracts import StrictSchema
26
+ from mic.model.errors import DomainError
27
+
28
+ _ALGORITHM = "HS256"
29
+
30
+ _logger = logging.getLogger(__name__)
31
+
32
+ #: Claims standard que ``ServiceAuthSigner.sign`` met lui-même —
33
+ #: les overrider via ``extra_claims`` permettrait à un caller de forger
34
+ #: un ``aud`` arbitraire, faussement étendre ``exp``, falsifier ``iss``,
35
+ #: etc. On refuse explicitement (cf. ``sign(extra_claims=...)``).
36
+ _RESERVED_CLAIMS: frozenset[str] = frozenset({"iss", "aud", "iat", "exp", "sub", "jti", "nbf"})
37
+
38
+ # Message constant retourné dans tous les DomainError de verify. On NE
39
+ # leak PAS les détails PyJWT (str(last_error)) au caller — un attacker
40
+ # peut sinon distinguer "signature valide mais expirée" (= il a un
41
+ # secret valide juste périmé) vs "signature invalide" (= mauvais secret),
42
+ # oracle particulièrement utile pendant les fenêtres de rotation. Les
43
+ # détails (exc_info + issuer whitelist) sont loggés côté serveur via
44
+ # ``_logger.warning(..., exc_info=True)`` pour la diagnose ops.
45
+ _GENERIC_VERIFY_FAILURE_MESSAGE = "Token verification failed"
46
+
47
+
48
+ class ServiceAuthClaims(StrictSchema):
49
+ """Claims canoniques exposés par un service token.
50
+
51
+ ``jti`` (RFC 7519 §4.1.7) est exposé optionnellement : il est
52
+ automatiquement injecté par :meth:`ServiceAuthSigner.sign` depuis
53
+ cette version, mais reste ``None`` quand on vérifie un token
54
+ émis par une version antérieure ou par un autre service qui ne
55
+ le propage pas. C'est OK : ``jti`` est une RECOMMENDED claim, pas
56
+ un REQUIRED claim — l'absence n'est pas une erreur de validation.
57
+
58
+ Pour les consommateurs qui font de la révocation per-token
59
+ (typiquement via :class:`mic.cache.RevokeBlacklist.revoke_jti`),
60
+ vérifier ``claims.jti is not None`` avant de l'utiliser : un
61
+ ``None`` signale qu'on ne peut pas révoquer ce token
62
+ spécifiquement, seulement via la voie per-subject (revoke-epoch).
63
+
64
+ ``extra`` porte les claims additionnels embedés par le signer via
65
+ ``ServiceAuthSigner.sign(extra_claims=...)``. Use case : un service
66
+ qui propage ``profile_id`` aux côtés du ``sub`` standard pour
67
+ éviter un lookup DB aval. Dictionnaire vide quand le token n'embed
68
+ aucun claim non-standard. Les valeurs sont opaques pour mic — le
69
+ consumer décide leur sémantique.
70
+ """
71
+
72
+ iss: str
73
+ aud: str
74
+ iat: int
75
+ exp: int
76
+ sub: str | None = None
77
+ jti: str | None = None
78
+ # Pydantic copie le default per-instance via Field — ``RUF012`` ne
79
+ # s'applique pas aux BaseModel. ``noqa`` cosmétique mais explicite.
80
+ extra: dict[str, Any] = {} # noqa: RUF012
81
+
82
+
83
+ class ServiceAuthSigner:
84
+ """Signe des JWT HS256 audience-bound.
85
+
86
+ Une instance par couple (issuer, secret). L'audience est passée
87
+ à chaque appel ``sign()`` puisque le même service appelle souvent
88
+ plusieurs destinataires.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ *,
94
+ secret: str,
95
+ issuer: str,
96
+ token_ttl_seconds: int = 3600,
97
+ ) -> None:
98
+ if not secret:
99
+ raise ValueError("ServiceAuthSigner.secret must be a non-empty string")
100
+ if not issuer:
101
+ raise ValueError("ServiceAuthSigner.issuer must be a non-empty string")
102
+ if token_ttl_seconds <= 0:
103
+ raise ValueError("ServiceAuthSigner.token_ttl_seconds must be > 0")
104
+ self._secret = secret
105
+ self._issuer = issuer
106
+ self._token_ttl = token_ttl_seconds
107
+
108
+ def sign(
109
+ self,
110
+ *,
111
+ audience: str,
112
+ subject: str | None = None,
113
+ jti: str | None = None,
114
+ extra_claims: dict[str, Any] | None = None,
115
+ ) -> str:
116
+ """Build a Bearer JWT signed with the configured secret.
117
+
118
+ Returns the encoded JWT string (no "Bearer " prefix — the
119
+ client adds it).
120
+
121
+ Args:
122
+ audience: ``aud`` claim — destinataire attendu (un service
123
+ par token).
124
+ subject: ``sub`` claim — identité propagée (typiquement
125
+ user_id ou email pour les tokens user-side).
126
+ jti: ``jti`` claim (RFC 7519 §4.1.7) — identifiant unique
127
+ du token. **Auto-généré** via ``uuid.uuid4().hex`` (128
128
+ bits d'entropie) si ``None``, ce qui est le cas standard.
129
+ Override uniquement pour les tests qui ont besoin de
130
+ jtis déterministes, ou pour les use cases avancés
131
+ (ex. propager un trace-id en jti). Si fourni, doit être
132
+ non-vide.
133
+ extra_claims: claims additionnels à embedder dans le JWT.
134
+ Use case typique : propager une identité applicative
135
+ secondaire (``profile_id``, ``tenant_id``, ``role``)
136
+ aux côtés du ``sub`` standard pour éviter un lookup DB
137
+ aval. Les claims standard (``iss``, ``aud``, ``iat``,
138
+ ``exp``, ``sub``, ``jti``) ne peuvent PAS être
139
+ overridées via ce paramètre — un override silencieux
140
+ permettrait à un caller de forger un ``aud`` arbitraire
141
+ ou de faussement étendre l'``exp``. Une tentative lève
142
+ ``ValueError``.
143
+
144
+ Le ``jti`` permet aux consumers de faire de la révocation
145
+ per-token via :class:`mic.cache.RevokeBlacklist` (``revoke_jti``,
146
+ ``is_jti_revoked``) — usage typique : logout-single-device en
147
+ ajoutant l'ancien ``jti`` à la blacklist à TTL = lifetime
148
+ restant du token.
149
+ """
150
+ if not audience:
151
+ raise ValueError("ServiceAuthSigner.sign(audience=...) must be non-empty")
152
+ if jti is not None and not jti:
153
+ raise ValueError("ServiceAuthSigner.sign(jti=...) must be non-empty when provided")
154
+ if extra_claims is not None:
155
+ reserved = _RESERVED_CLAIMS & extra_claims.keys()
156
+ if reserved:
157
+ raise ValueError(
158
+ f"ServiceAuthSigner.sign(extra_claims=...) cannot override "
159
+ f"reserved claim(s): {sorted(reserved)}"
160
+ )
161
+ now = datetime.now(UTC)
162
+ payload: dict[str, Any] = {
163
+ "iss": self._issuer,
164
+ "aud": audience,
165
+ "iat": int(now.timestamp()),
166
+ "exp": int((now + timedelta(seconds=self._token_ttl)).timestamp()),
167
+ "jti": jti if jti is not None else uuid.uuid4().hex,
168
+ }
169
+ if subject is not None:
170
+ payload["sub"] = subject
171
+ if extra_claims:
172
+ payload.update(extra_claims)
173
+ return jwt.encode(payload, self._secret, algorithm=_ALGORITHM)
174
+
175
+
176
+ class ServiceAuthVerifier:
177
+ """Vérifie les JWT HS256 destinés à ce service.
178
+
179
+ Supporte la rotation à zéro-downtime via une **liste ordonnée**
180
+ de secrets : le verifier essaye le ``current`` d'abord, puis les
181
+ ``previous`` un par un. Tant qu'au moins un secret valide
182
+ correspond ET que les claims passent (audience, issuer, expiration),
183
+ le token est accepté.
184
+
185
+ L'``audience`` attendue est fixe par instance (= app name de ce
186
+ service). Les ``allowed_issuers`` est la liste des émetteurs
187
+ légitimes (whitelist).
188
+ """
189
+
190
+ def __init__(
191
+ self,
192
+ *,
193
+ secrets: tuple[str, ...],
194
+ audience: str,
195
+ allowed_issuers: tuple[str, ...],
196
+ leeway_seconds: int = 30,
197
+ ) -> None:
198
+ # leeway 30s par défaut = tolérance NTP-skew typique entre 2
199
+ # services. Sans ça, une dérive d'1s entre signer et verifier
200
+ # rejette le token (``iat`` dans le futur OU ``exp`` expirée).
201
+ # 30s = compromis : couvre 99 % des skews observés en cluster
202
+ # sans ouvrir une fenêtre de replay critique.
203
+ if not secrets:
204
+ raise ValueError("ServiceAuthVerifier.secrets must be non-empty tuple")
205
+ if any(not s for s in secrets):
206
+ raise ValueError("ServiceAuthVerifier.secrets must not contain empty strings")
207
+ if not audience:
208
+ raise ValueError("ServiceAuthVerifier.audience must be non-empty")
209
+ if not allowed_issuers:
210
+ raise ValueError("ServiceAuthVerifier.allowed_issuers must be non-empty tuple")
211
+ if leeway_seconds < 0:
212
+ raise ValueError("ServiceAuthVerifier.leeway_seconds must be >= 0")
213
+ self._secrets = secrets
214
+ self._audience = audience
215
+ self._allowed_issuers = set(allowed_issuers)
216
+ self._leeway = leeway_seconds
217
+
218
+ def verify(self, token: str) -> ServiceAuthClaims:
219
+ """Vérifie le token et retourne les claims parsés.
220
+
221
+ Raises ``DomainError`` (code ``service_auth.invalid_*``) si :
222
+
223
+ - signature ne match aucun secret (current ou previous)
224
+ - exp dépassée (au-delà du leeway)
225
+ - aud != self.audience
226
+ - iss not in allowed_issuers
227
+ - structure du token invalide
228
+
229
+ **Timing-safe iteration** : tous les secrets de la
230
+ liste sont essayés systématiquement, sans early break sur le
231
+ premier match. Sans ça, un attacker qui mesure la latence à la
232
+ μs peut deviner la **position** du secret valide (``current``
233
+ vs ``previous``) → side-channel mineur mais réel pendant la
234
+ fenêtre de rotation. Coût : 1 HMAC supplémentaire par requête
235
+ (≪ 1 μs) — négligeable.
236
+ """
237
+ if not token:
238
+ raise DomainError(
239
+ code="service_auth.invalid_token",
240
+ message="Empty token",
241
+ )
242
+
243
+ payload: dict[str, Any] | None = None
244
+ last_error: jwt.PyJWTError | None = None
245
+ for secret in self._secrets:
246
+ try:
247
+ candidate = jwt.decode(
248
+ token,
249
+ secret,
250
+ algorithms=[_ALGORITHM],
251
+ audience=self._audience,
252
+ leeway=self._leeway,
253
+ options={"require": ["iss", "aud", "iat", "exp"]},
254
+ )
255
+ except jwt.PyJWTError as exc:
256
+ last_error = exc
257
+ continue
258
+ # Garde le PREMIER payload valide trouvé, mais on continue
259
+ # d'itérer pour ne pas leak la position via timing.
260
+ if payload is None:
261
+ payload = candidate
262
+
263
+ if payload is None:
264
+ # Toutes les secrets ont échoué. On retourne au caller un
265
+ # ``code`` catégorisé (contrat public — utile pour l'UX :
266
+ # ``expired`` vs ``invalid_signature``) mais **aucun message
267
+ # détaillé ni stacktrace** : ce serait un oracle pour
268
+ # l'attacker (cf. constante ``_GENERIC_VERIFY_FAILURE_MESSAGE``).
269
+ # La cause précise (str(last_error)) est loggée côté serveur
270
+ # pour la diagnose ops.
271
+ code = _classify_error(last_error)
272
+ _logger.warning(
273
+ "ServiceAuthVerifier: token verification failed (code=%s)",
274
+ code,
275
+ exc_info=last_error,
276
+ )
277
+ raise DomainError(code=code, message=_GENERIC_VERIFY_FAILURE_MESSAGE)
278
+
279
+ issuer = payload.get("iss")
280
+ if issuer not in self._allowed_issuers:
281
+ # Ne pas leaker ``self._allowed_issuers`` au caller — c'est
282
+ # de la recon précieuse (liste des services internes connus).
283
+ # On garde le ``code`` précis pour le caller légitime, et
284
+ # on log la valeur reçue + la whitelist server-side.
285
+ _logger.warning(
286
+ "ServiceAuthVerifier: issuer rejected (received=%r, allowed=%s)",
287
+ issuer,
288
+ sorted(self._allowed_issuers),
289
+ )
290
+ raise DomainError(
291
+ code="service_auth.invalid_issuer",
292
+ message=_GENERIC_VERIFY_FAILURE_MESSAGE,
293
+ )
294
+
295
+ # Sépare les claims standard (typés sur ServiceAuthClaims) des
296
+ # claims additionnels embedés via ``ServiceAuthSigner.sign(
297
+ # extra_claims=...)``. Les non-standard atterrissent dans
298
+ # ``extra``, opaques pour mic mais accessibles au consumer.
299
+ standard_claims = {"iss", "aud", "iat", "exp", "sub", "jti"}
300
+ normalized: dict[str, Any] = {k: v for k, v in payload.items() if k in standard_claims}
301
+ normalized["extra"] = {k: v for k, v in payload.items() if k not in standard_claims}
302
+ return ServiceAuthClaims.model_validate(normalized)
303
+
304
+
305
+ def _classify_error(error: jwt.PyJWTError | None) -> str:
306
+ """Mapping fin des erreurs PyJWT vers code DomainError."""
307
+ if isinstance(error, jwt.ExpiredSignatureError):
308
+ return "service_auth.expired"
309
+ if isinstance(error, jwt.InvalidAudienceError):
310
+ return "service_auth.invalid_audience"
311
+ if isinstance(error, jwt.InvalidSignatureError):
312
+ return "service_auth.invalid_signature"
313
+ if isinstance(error, jwt.MissingRequiredClaimError):
314
+ return "service_auth.missing_claim"
315
+ if isinstance(error, jwt.DecodeError):
316
+ return "service_auth.malformed"
317
+ return "service_auth.invalid_token"
mic/cli/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """CLI canon — argparse stdlib + ``CliCommandSpec`` builder.
2
+
3
+ Cf. R12 dans ``CONTRIBUTING.md`` :
4
+
5
+ - Tout service avec opérations opérateur (drain, requeue,
6
+ reindex, query, replay, …) doit livrer un CLI via cette brique.
7
+ - Framework imposé : ``argparse`` stdlib (pas de Click/Typer).
8
+ - 1 fichier ``frameworks/cli/<groupe>_commands.py`` par bounded
9
+ context, chaque commande exposée via un ``CliCommandSpec``.
10
+
11
+ L'extra ``mic-struct[cli]`` existe pour cohérence — ``mic.cli`` n'a
12
+ aucune dépendance externe (stdlib only).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from mic.cli.builder import build_parser, dispatch
18
+ from mic.cli.spec import CliCommandSpec
19
+
20
+ __all__ = [
21
+ "CliCommandSpec",
22
+ "build_parser",
23
+ "dispatch",
24
+ ]
mic/cli/builder.py ADDED
@@ -0,0 +1,176 @@
1
+ """Builder ``ArgumentParser`` racine + dispatch robuste.
2
+
3
+ Convention :
4
+
5
+ parser = build_parser(
6
+ prog="my-service",
7
+ description="My service operator CLI.",
8
+ version="1.2.3", # optional → ajoute --version
9
+ verbose=True, # optional → ajoute -v/--verbose
10
+ commands=(
11
+ CliCommandSpec(name="health", run=health_command),
12
+ CliCommandSpec(
13
+ name="drain-once",
14
+ run=drain_command,
15
+ build_args=lambda p: p.add_argument(
16
+ "--batch-size", type=int, default=10
17
+ ),
18
+ ),
19
+ ),
20
+ )
21
+
22
+ if __name__ == "__main__":
23
+ sys.exit(dispatch(parser, sys.argv[1:]))
24
+
25
+ ``dispatch`` traite les conditions shell standard :
26
+
27
+ - ``KeyboardInterrupt`` (Ctrl-C) → exit ``130`` (= ``128 + SIGINT``)
28
+ - ``BrokenPipeError`` (pipe fermé en aval, ex. ``cmd | head``) → exit ``0``
29
+ - ``SystemExit(code)`` (argparse error, sys.exit, ...) → propagé tel quel
30
+ - Toute autre exception → propagée (laissée au caller pour traceback)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import logging
37
+ import sys
38
+ from collections.abc import Iterable, Sequence
39
+
40
+ from mic.cli.spec import CliCommandSpec
41
+
42
+ #: Code shell standard quand un process est interrompu par SIGINT.
43
+ #: 128 + signal_number (SIGINT=2). Cf. POSIX advisory.
44
+ _EXIT_SIGINT = 130
45
+
46
+ #: Seuil ``-vv``+ (count ≥ ce seuil → DEBUG, sinon INFO).
47
+ _VERBOSE_DEBUG_THRESHOLD = 2
48
+
49
+
50
+ def build_parser(
51
+ *,
52
+ prog: str,
53
+ description: str,
54
+ commands: Iterable[CliCommandSpec],
55
+ version: str | None = None,
56
+ verbose: bool = False,
57
+ ) -> argparse.ArgumentParser:
58
+ """Construit un ``ArgumentParser`` racine avec un sub-parser par
59
+ ``CliCommandSpec``.
60
+
61
+ Args:
62
+ prog: nom du programme (affiché dans ``--help``).
63
+ description: description courte (affichée dans ``--help`` racine).
64
+ commands: les ``CliCommandSpec`` à exposer (≥ 1).
65
+ version: si fourni, ajoute ``--version`` qui imprime ``f"{prog}
66
+ {version}"`` puis exit 0.
67
+ verbose: si True, ajoute le flag global ``-v/--verbose`` (action
68
+ ``count``, accumulable : ``-v``, ``-vv``, ``-vvv``).
69
+ Disponible dans tous les sub-commands via ``args.verbose``
70
+ (int ≥ 0). Le runner choisit comment l'interpréter (souvent
71
+ ``WARNING/INFO/DEBUG`` selon le count).
72
+
73
+ Lève ``ValueError`` si :
74
+
75
+ - ``commands`` est vide (= CLI inutilisable) ;
76
+ - deux specs ont le même ``name`` (collision sub-command) ;
77
+ - un alias collisionne avec un nom déjà pris.
78
+ """
79
+ specs = tuple(commands)
80
+ if not specs:
81
+ raise ValueError("build_parser(commands=...) must contain at least one CliCommandSpec")
82
+
83
+ seen: set[str] = set()
84
+ for spec in specs:
85
+ if spec.name in seen:
86
+ raise ValueError(f"duplicate CLI command name: {spec.name!r}")
87
+ seen.add(spec.name)
88
+ for alias in spec.aliases:
89
+ if alias in seen:
90
+ raise ValueError(
91
+ f"CLI alias {alias!r} (on command {spec.name!r}) collides with "
92
+ f"an existing name or alias"
93
+ )
94
+ seen.add(alias)
95
+
96
+ parser = argparse.ArgumentParser(prog=prog, description=description)
97
+ if version is not None:
98
+ parser.add_argument(
99
+ "--version",
100
+ action="version",
101
+ version=f"{prog} {version}",
102
+ )
103
+ if verbose:
104
+ parser.add_argument(
105
+ "-v",
106
+ "--verbose",
107
+ action="count",
108
+ default=0,
109
+ help="Increase verbosity (-v, -vv, -vvv for WARNING/INFO/DEBUG).",
110
+ )
111
+
112
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
113
+
114
+ for spec in specs:
115
+ sub_parser = sub.add_parser(
116
+ spec.name,
117
+ help=spec.resolved_help(),
118
+ aliases=list(spec.aliases),
119
+ )
120
+ if spec.build_args is not None:
121
+ spec.build_args(sub_parser)
122
+ sub_parser.set_defaults(_mic_run=spec.run)
123
+
124
+ return parser
125
+
126
+
127
+ def dispatch(parser: argparse.ArgumentParser, argv: Sequence[str] | None = None) -> int:
128
+ """Parse ``argv`` puis exécute la commande sélectionnée.
129
+
130
+ Codes de sortie :
131
+
132
+ - ``0`` = succès (le runner a retourné 0 OU pipe cassé en aval)
133
+ - ``130`` = ``KeyboardInterrupt`` (Ctrl-C, convention shell SIGINT)
134
+ - ce que retourne le ``run`` de la commande pour les autres cas
135
+
136
+ Les ``SystemExit`` (argparse errors, ``sys.exit(N)`` dans un runner)
137
+ sont laissés se propager — le caller décide. ``KeyboardInterrupt``
138
+ et ``BrokenPipeError`` sont catchés explicitement parce qu'ils sont
139
+ *normaux* en CLI (Ctrl-C, ``cmd | head``) et ne devraient pas
140
+ afficher de traceback.
141
+ """
142
+ args = parser.parse_args(argv)
143
+ runner = getattr(args, "_mic_run", None)
144
+ if runner is None: # pragma: no cover — argparse ``required=True`` empêche
145
+ parser.error("no command specified")
146
+
147
+ if getattr(args, "verbose", 0):
148
+ _configure_logging_from_verbose(args.verbose)
149
+
150
+ try:
151
+ return int(runner(args))
152
+ except KeyboardInterrupt:
153
+ return _EXIT_SIGINT
154
+ except BrokenPipeError:
155
+ # ``cmd | head`` ferme le pipe → on sort silencieusement comme
156
+ # le ferait n'importe quel utilitaire Unix bien élevé.
157
+ return 0
158
+
159
+
160
+ def _configure_logging_from_verbose(level_count: int) -> None:
161
+ """Map ``-v`` count → niveau logging stdlib.
162
+
163
+ Convention :
164
+ - ``-v`` → INFO
165
+ - ``-vv``+ → DEBUG
166
+ - 0 → WARNING (default Python ; on n'override pas si non demandé)
167
+ """
168
+ if level_count >= _VERBOSE_DEBUG_THRESHOLD:
169
+ log_level = logging.DEBUG
170
+ else:
171
+ log_level = logging.INFO
172
+ logging.basicConfig(
173
+ stream=sys.stderr,
174
+ level=log_level,
175
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
176
+ )