normacare-sdk 2.0.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.
@@ -0,0 +1,39 @@
1
+ """
2
+ NormaCare SDK — paquet public.
3
+
4
+ Développez des composants d'intégration médicale réutilisables, testables
5
+ et publiables sur le NormaCare Store, exécutés par NormaCare Engine.
6
+
7
+ Usage minimal :
8
+
9
+ from normacare_sdk import NormaCareComponent, ComponentInfo, run
10
+
11
+ class MonComposant(NormaCareComponent):
12
+ info = ComponentInfo(type="mon_comp", label="Mon Composant", author="Moi")
13
+
14
+ def process(self, data, vars, params):
15
+ return {"processed": True, "data": data}
16
+
17
+ run(MonComposant)
18
+
19
+ FDevelopment LTD
20
+ """
21
+ from .component import (
22
+ NormaCareComponent,
23
+ ComponentInfo,
24
+ ComponentError,
25
+ run,
26
+ )
27
+ from .testing import ComponentTester, TestResult
28
+
29
+ __version__ = "2.0.0"
30
+
31
+ __all__ = [
32
+ "NormaCareComponent",
33
+ "ComponentInfo",
34
+ "ComponentError",
35
+ "run",
36
+ "ComponentTester",
37
+ "TestResult",
38
+ "__version__",
39
+ ]
normacare_sdk/cli.py ADDED
@@ -0,0 +1,393 @@
1
+ """
2
+ NormaCare SDK — CLI
3
+ ===================
4
+ Outil en ligne de commande pour créer, tester, valider, packager et publier
5
+ des composants NormaCare.
6
+
7
+ Commandes:
8
+ normacare-sdk new <nom> [--template T] Créer un composant depuis un template
9
+ normacare-sdk validate [dossier] Valider un composant avant publication
10
+ normacare-sdk test [dossier] Exécuter les tests locaux (test_*.py)
11
+ normacare-sdk build [dossier] Packager en bundle .ncpkg
12
+ normacare-sdk login Se connecter au Store NormaCare
13
+ normacare-sdk publish [dossier] [--price P] Publier sur le Store
14
+
15
+ FDevelopment LTD
16
+ """
17
+ from __future__ import annotations
18
+ import os
19
+ import sys
20
+ import json
21
+ import shutil
22
+ import zipfile
23
+ import hashlib
24
+ import argparse
25
+ import subprocess
26
+ from pathlib import Path
27
+ from datetime import datetime, timezone
28
+
29
+ STORE_URL = os.getenv("NORMACARE_STORE_URL", "https://store.normacare.app")
30
+ try:
31
+ from . import __version__ as SDK_VERSION
32
+ except Exception: # exécution hors paquet
33
+ SDK_VERSION = "2.0.0"
34
+ CONFIG_FILE = Path.home() / ".normacare" / "credentials.json"
35
+ # Templates embarqués dans le paquet (inclus par pip via package-data).
36
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
37
+
38
+ TEMPLATES = {
39
+ "basic": "Composant Python minimal — point de départ universel",
40
+ "hl7": "Composant spécialisé HL7 v2.x (segments MSH/PID/OBX…)",
41
+ "database": "Composant avec accès base de données",
42
+ "javascript": "Composant JavaScript / Node.js",
43
+ "http": "Composant appel API REST",
44
+ }
45
+
46
+ # ── Helpers ───────────────────────────────────────────────────────────────────
47
+
48
+ def _ok(msg): print(f" ✓ {msg}")
49
+ def _info(msg): print(f" ℹ {msg}")
50
+ def _warn(msg): print(f" ⚠ {msg}")
51
+ def _err(msg): print(f" ✕ {msg}", file=sys.stderr)
52
+
53
+ def _load_credentials():
54
+ if CONFIG_FILE.exists():
55
+ try:
56
+ return json.loads(CONFIG_FILE.read_text())
57
+ except Exception:
58
+ return None
59
+ return None
60
+
61
+ def _save_credentials(data):
62
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
63
+ CONFIG_FILE.write_text(json.dumps(data, indent=2))
64
+
65
+ def _load_manifest(folder: Path) -> dict:
66
+ cmp_files = list(folder.glob("*.cmp"))
67
+ if not cmp_files:
68
+ _err(f"Aucun fichier .cmp trouvé dans {folder}")
69
+ sys.exit(1)
70
+ return json.loads(cmp_files[0].read_text(encoding="utf-8"))
71
+
72
+ def _slug(name: str) -> str:
73
+ return name.strip().lower().replace(" ", "_").replace("-", "_")
74
+
75
+ # ── Commande : new ─────────────────────────────────────────────────────────────
76
+
77
+ def cmd_new(args):
78
+ template = args.template or "basic"
79
+ name = _slug(args.name)
80
+ target = Path(name)
81
+
82
+ if target.exists():
83
+ _err(f"Le dossier '{name}' existe déjà.")
84
+ sys.exit(1)
85
+
86
+ tpl_dir = TEMPLATES_DIR / template
87
+ if not tpl_dir.exists():
88
+ _err(f"Template '{template}' introuvable. Disponibles : {list(TEMPLATES)}")
89
+ sys.exit(1)
90
+
91
+ shutil.copytree(tpl_dir, target)
92
+
93
+ # Le template utilise le radical « component » ; on le remplace par <name>.
94
+ is_js = template == "javascript"
95
+ old_stem = "component"
96
+ ext = ".js" if is_js else ".py"
97
+
98
+ # Renomme le script principal.
99
+ old_script = target / f"{old_stem}{ext}"
100
+ new_script = target / f"{name}{ext}"
101
+ if old_script.exists():
102
+ old_script.rename(new_script)
103
+
104
+ # Renomme et réécrit le manifest.
105
+ old_cmp = target / f"{old_stem}.cmp"
106
+ manifest = json.loads(old_cmp.read_text(encoding="utf-8"))
107
+ manifest["type"] = name
108
+ manifest["label"] = name.replace("_", " ").title()
109
+ manifest["script"] = f"{name}{ext}"
110
+ new_cmp = target / f"{name}.cmp"
111
+ new_cmp.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
112
+ if old_cmp.exists() and old_cmp != new_cmp:
113
+ old_cmp.unlink()
114
+
115
+ # Renomme le test et corrige son import (from component import ... → from <name>).
116
+ old_test = target / f"test_{old_stem}.py"
117
+ if old_test.exists():
118
+ txt = old_test.read_text(encoding="utf-8").replace(
119
+ f"from {old_stem} import", f"from {name} import"
120
+ ).replace(f"import {old_stem}\n", f"import {name}\n")
121
+ new_test = target / f"test_{name}.py"
122
+ new_test.write_text(txt, encoding="utf-8")
123
+ if old_test != new_test:
124
+ old_test.unlink()
125
+
126
+ print(f"\n NormaCare SDK — composant créé : {name}/")
127
+ print(f" Template : {TEMPLATES.get(template, template)}\n")
128
+ print(" Structure :")
129
+ for f in sorted(target.rglob("*")):
130
+ if f.is_file():
131
+ print(f" {f.relative_to(target)}")
132
+ print("\n Prochaines étapes :")
133
+ print(f" 1. cd {name}")
134
+ print(f" 2. Éditez {name}{ext}")
135
+ print(" 3. normacare-sdk test .")
136
+ print(" 4. normacare-sdk build . (ou : publish . --price 0)\n")
137
+
138
+ # ── Commande : validate ───────────────────────────────────────────────────────
139
+
140
+ def cmd_validate(args):
141
+ folder = Path(args.folder)
142
+ print(f"\n Validation de : {folder.resolve()}\n")
143
+ errors, warnings = [], []
144
+
145
+ manifest = _load_manifest(folder)
146
+
147
+ for field in ("type", "label", "version", "runtime", "script"):
148
+ if not manifest.get(field):
149
+ errors.append(f"Champ requis manquant : '{field}'")
150
+ if not manifest.get("author"):
151
+ warnings.append("Champ 'author' vide (requis pour publier sur le Store)")
152
+
153
+ if manifest.get("runtime") not in {"python", "javascript", "lua", "java"}:
154
+ errors.append(f"Runtime invalide : {manifest.get('runtime')!r} (python|javascript|lua|java)")
155
+
156
+ script_path = folder / manifest.get("script", "")
157
+ if manifest.get("script") and not script_path.exists():
158
+ errors.append(f"Script '{manifest.get('script')}' introuvable")
159
+
160
+ v = str(manifest.get("version", ""))
161
+ if not v or not all(p.isdigit() for p in v.split(".")):
162
+ warnings.append(f"Version '{v}' non standard (attendu X.Y.Z)")
163
+
164
+ if float(manifest.get("price", 0) or 0) < 0:
165
+ errors.append("Le prix ne peut pas être négatif")
166
+
167
+ if not (folder / "README.md").exists():
168
+ warnings.append("README.md manquant (recommandé pour le Store)")
169
+
170
+ if not (list(folder.glob("test_*.py")) + list(folder.glob("tests/*.py"))):
171
+ warnings.append("Aucun test trouvé (recommandé)")
172
+
173
+ for e in errors: _err(e)
174
+ for w in warnings: _warn(w)
175
+
176
+ if not errors:
177
+ _ok(f"Validation réussie — {manifest['label']} v{manifest.get('version','?')}")
178
+ if warnings:
179
+ print(f"\n {len(warnings)} avertissement(s) non bloquant(s)\n")
180
+ return True
181
+ print(f"\n {len(errors)} erreur(s) — corrigez avant de publier\n")
182
+ sys.exit(1)
183
+
184
+ # ── Commande : test ────────────────────────────────────────────────────────────
185
+
186
+ def cmd_test(args):
187
+ folder = Path(args.folder)
188
+ tests = sorted(folder.glob("test_*.py")) + sorted(folder.glob("tests/test_*.py"))
189
+ if not tests:
190
+ _warn("Aucun test (test_*.py) trouvé.")
191
+ return
192
+ print(f"\n Tests de : {folder.resolve()}\n")
193
+ all_ok = True
194
+ for t in tests:
195
+ _info(f"→ {t.name}")
196
+ # cwd = dossier du composant pour que « from <name> import … » fonctionne.
197
+ r = subprocess.run([sys.executable, t.name], cwd=str(folder))
198
+ if r.returncode != 0:
199
+ all_ok = False
200
+ print()
201
+ if all_ok:
202
+ _ok("Tous les tests sont passés.")
203
+ else:
204
+ _err("Des tests ont échoué.")
205
+ sys.exit(1)
206
+
207
+ # ── Commande : build ───────────────────────────────────────────────────────────
208
+
209
+ def cmd_build(args):
210
+ folder = Path(args.folder)
211
+ manifest = _load_manifest(folder)
212
+ ctype = manifest["type"]
213
+ version = manifest.get("version", "1.0.0")
214
+
215
+ cmd_validate(argparse.Namespace(folder=str(folder)))
216
+
217
+ script_path = folder / manifest["script"]
218
+ script_code = script_path.read_text(encoding="utf-8")
219
+
220
+ if manifest.get("protected") and not getattr(args, "skip_protect", False):
221
+ try:
222
+ import pyarmor # noqa: F401
223
+ _info("Obfuscation PyArmor du script…")
224
+ result = subprocess.run(
225
+ ["pyarmor", "gen", "--output", str(folder / "_dist"), str(script_path)],
226
+ capture_output=True, text=True,
227
+ )
228
+ _ok("Script obfusqué") if result.returncode == 0 else _warn("PyArmor indisponible — script non obfusqué")
229
+ except ImportError:
230
+ _warn("PyArmor non installé (pip install pyarmor) — script non obfusqué")
231
+
232
+ manifest["sha256"] = hashlib.sha256(script_code.encode()).hexdigest()
233
+ manifest["built_at"] = datetime.now(timezone.utc).isoformat()
234
+
235
+ out_name = f"{ctype}-{version}.ncpkg"
236
+ out_path = folder.parent / out_name
237
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
238
+ zf.writestr(f"{ctype}.cmp", json.dumps(manifest, indent=2, ensure_ascii=False))
239
+ zf.write(script_path, manifest["script"])
240
+ for opt in ("README.md", "CHANGELOG.md", "icon.png", "icon.svg"):
241
+ p = folder / opt
242
+ if p.exists():
243
+ zf.write(p, opt)
244
+ samples_dir = folder / "samples"
245
+ if samples_dir.exists():
246
+ for f in samples_dir.rglob("*"):
247
+ if f.is_file():
248
+ zf.write(f, f"samples/{f.relative_to(samples_dir)}")
249
+
250
+ size_kb = out_path.stat().st_size // 1024
251
+ _ok(f"Bundle créé : {out_path.name} ({size_kb} Ko)")
252
+ print(f" → {out_path.resolve()}\n")
253
+ return out_path
254
+
255
+ # ── Commande : login ───────────────────────────────────────────────────────────
256
+
257
+ def cmd_login(args):
258
+ import getpass
259
+ print("\n Connexion au NormaCare Store\n")
260
+ email = input(" Email : ").strip()
261
+ try:
262
+ password = getpass.getpass(" Mot de passe : ")
263
+ except Exception:
264
+ password = input(" Mot de passe : ").strip()
265
+
266
+ import urllib.request
267
+ try:
268
+ req = urllib.request.Request(
269
+ f"{STORE_URL}/api/auth/login",
270
+ data=json.dumps({"email": email, "password": password}).encode(),
271
+ headers={"Content-Type": "application/json"},
272
+ method="POST",
273
+ )
274
+ with urllib.request.urlopen(req, timeout=15) as r:
275
+ data = json.loads(r.read())
276
+ _save_credentials({"email": email, "token": data["token"],
277
+ "publisher_id": data.get("publisher_id")})
278
+ _ok(f"Connecté en tant que {email}")
279
+ _info(f"Plan : {data.get('plan', '?')}")
280
+ except Exception as e:
281
+ _err(f"Erreur de connexion : {e}")
282
+ sys.exit(1)
283
+
284
+ # ── Commande : publish ─────────────────────────────────────────────────────────
285
+
286
+ def cmd_publish(args):
287
+ folder = Path(args.folder)
288
+ manifest = _load_manifest(folder)
289
+ creds = _load_credentials()
290
+
291
+ if not creds:
292
+ _err("Non connecté. Lancez d'abord : normacare-sdk login")
293
+ sys.exit(1)
294
+
295
+ if args.price is not None:
296
+ manifest["price"] = float(args.price)
297
+ if args.visibility:
298
+ manifest["protected"] = args.visibility == "paid"
299
+
300
+ if float(manifest.get("price", 0) or 0) > 0:
301
+ cur = manifest.get("currency", "EUR")
302
+ print(f"\n Publication payante : {manifest['label']} à {manifest['price']} {cur}")
303
+ print(" Commission FDevelopment LTD : 25%")
304
+ print(f" Revenu net : {manifest['price'] * 0.75:.2f} {cur} / vente")
305
+ if input("\n Confirmer ? (oui/non) : ").strip().lower() != "oui":
306
+ _info("Publication annulée.")
307
+ return
308
+
309
+ bundle_path = cmd_build(argparse.Namespace(folder=str(folder), skip_protect=False))
310
+
311
+ _info(f"Publication sur {STORE_URL}…")
312
+ import urllib.request
313
+ try:
314
+ bundle_data = Path(bundle_path).read_bytes()
315
+ metadata = json.dumps({"manifest": manifest,
316
+ "publisher_id": creds.get("publisher_id")}).encode()
317
+ boundary = "NormaCareBoundary"
318
+ body = f"--{boundary}\r\nContent-Disposition: form-data; name=\"metadata\"\r\n\r\n".encode()
319
+ body += metadata
320
+ body += (f"\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"bundle\"; "
321
+ f"filename=\"{bundle_path.name}\"\r\nContent-Type: application/octet-stream\r\n\r\n").encode()
322
+ body += bundle_data
323
+ body += f"\r\n--{boundary}--\r\n".encode()
324
+
325
+ req = urllib.request.Request(
326
+ f"{STORE_URL}/api/components/publish",
327
+ data=body,
328
+ headers={"Authorization": f"Bearer {creds['token']}",
329
+ "Content-Type": f"multipart/form-data; boundary={boundary}"},
330
+ method="POST",
331
+ )
332
+ with urllib.request.urlopen(req, timeout=60) as r:
333
+ result = json.loads(r.read())
334
+ _ok("Composant publié !")
335
+ _info(f"URL Store : {STORE_URL}/components/{manifest['type']}")
336
+ _info(f"ID : {result.get('id')}")
337
+ _info(f"Statut : {result.get('status', 'en révision')}")
338
+ except Exception as e:
339
+ _err(f"Erreur de publication : {e}")
340
+ _info(f"Le bundle local est conservé : {bundle_path}")
341
+ sys.exit(1)
342
+
343
+ # ── CLI main ──────────────────────────────────────────────────────────────────
344
+
345
+ def build_parser():
346
+ parser = argparse.ArgumentParser(
347
+ prog="normacare-sdk",
348
+ description=f"NormaCare SDK v{SDK_VERSION} — Créez et publiez des composants",
349
+ )
350
+ parser.add_argument("--version", action="version", version=f"normacare-sdk {SDK_VERSION}")
351
+ sub = parser.add_subparsers(dest="cmd", metavar="commande")
352
+
353
+ p_new = sub.add_parser("new", help="Créer un composant depuis un template")
354
+ p_new.add_argument("name", help="Nom du composant (snake_case)")
355
+ p_new.add_argument("--template", "-t", default="basic", choices=list(TEMPLATES),
356
+ help="Template à utiliser")
357
+
358
+ p_val = sub.add_parser("validate", help="Valider un composant")
359
+ p_val.add_argument("folder", nargs="?", default=".", help="Dossier du composant")
360
+
361
+ p_tst = sub.add_parser("test", help="Exécuter les tests locaux (test_*.py)")
362
+ p_tst.add_argument("folder", nargs="?", default=".", help="Dossier du composant")
363
+
364
+ p_bld = sub.add_parser("build", help="Packager le composant (.ncpkg)")
365
+ p_bld.add_argument("folder", nargs="?", default=".", help="Dossier du composant")
366
+ p_bld.add_argument("--skip-protect", action="store_true")
367
+
368
+ sub.add_parser("login", help="Se connecter au Store NormaCare")
369
+
370
+ p_pub = sub.add_parser("publish", help="Publier sur le Store NormaCare")
371
+ p_pub.add_argument("folder", nargs="?", default=".", help="Dossier du composant")
372
+ p_pub.add_argument("--price", type=float, default=None, help="Prix en EUR (0 = gratuit)")
373
+ p_pub.add_argument("--visibility", choices=["public", "paid"], default=None)
374
+ return parser
375
+
376
+ def main(argv=None):
377
+ parser = build_parser()
378
+ args = parser.parse_args(argv)
379
+ cmds = {
380
+ "new": cmd_new, "validate": cmd_validate, "test": cmd_test,
381
+ "build": cmd_build, "login": cmd_login, "publish": cmd_publish,
382
+ }
383
+ if args.cmd in cmds:
384
+ cmds[args.cmd](args)
385
+ else:
386
+ parser.print_help()
387
+ print("\n Templates disponibles :")
388
+ for k, v in TEMPLATES.items():
389
+ print(f" {k:<12} {v}")
390
+ print()
391
+
392
+ if __name__ == "__main__":
393
+ main()
@@ -0,0 +1,206 @@
1
+ """
2
+ NormaCare SDK — Base Component
3
+ ==============================
4
+ Classe de base pour créer des composants NormaCare.
5
+ Hérite de cette classe pour créer ton propre composant.
6
+
7
+ Usage:
8
+ from normacare_sdk import NormaCareComponent, ComponentInfo, run
9
+
10
+ class MyComponent(NormaCareComponent):
11
+ def process(self, data, vars, params):
12
+ # ton code ici
13
+ return {"result": data}
14
+
15
+ run(MyComponent)
16
+ """
17
+ from __future__ import annotations
18
+ import json
19
+ import sys
20
+ import os
21
+ import traceback
22
+ from dataclasses import dataclass, field, asdict
23
+ from typing import Any, Dict, List, Optional
24
+
25
+
26
+ # ── Métadonnées d'un composant ────────────────────────────────────────────────
27
+
28
+ @dataclass
29
+ class ComponentInfo:
30
+ """Décrit un composant NormaCare pour le Store et le Studio."""
31
+ type: str # identifiant unique snake_case : "hl7_enricher"
32
+ label: str # nom affiché : "HL7 Enricher"
33
+ version: str = "1.0.0"
34
+ runtime: str = "python" # python | javascript | lua
35
+ description: str = ""
36
+ author: str = ""
37
+ author_email:str = ""
38
+ company: str = ""
39
+ website: str = ""
40
+ icon: str = "🧩" # emoji ou URL
41
+ tags: List[str] = field(default_factory=list)
42
+ inputs: List[str] = field(default_factory=lambda: ["data"])
43
+ outputs: List[str] = field(default_factory=lambda: ["result"])
44
+ license: str = "proprietary" # MIT | Apache-2.0 | proprietary
45
+ min_engine: str = "2.0.0"
46
+ # Champs Store
47
+ price: float = 0.0 # 0.0 = gratuit
48
+ currency: str = "EUR"
49
+ protected: bool = False # True = code obfusqué avant publication
50
+ options_schema: Dict[str, Any] = field(default_factory=dict)
51
+
52
+ def to_manifest(self) -> dict:
53
+ """Génère le manifest .cmp"""
54
+ d = asdict(self)
55
+ d["script"] = f"{self.type}.py"
56
+ return d
57
+
58
+
59
+ # ── Classe de base ────────────────────────────────────────────────────────────
60
+
61
+ class NormaCareComponent:
62
+ """
63
+ Classe de base pour tous les composants NormaCare.
64
+
65
+ Surcharge la méthode process() pour implémenter ton traitement.
66
+ Les méthodes helper (log, get_option, fail) sont disponibles.
67
+
68
+ Exemple minimal :
69
+ class MyComp(NormaCareComponent):
70
+ info = ComponentInfo(type="my_comp", label="My Component", author="Moi")
71
+
72
+ def process(self, data, vars, params):
73
+ return {"processed": True, "data": data}
74
+ """
75
+ info: ComponentInfo = None # À définir dans chaque composant
76
+
77
+ def __init__(self):
78
+ self._logs: List[str] = []
79
+ self._config: Dict = {} # options du composant (.cmp) injectées à l'exécution
80
+
81
+ # ── À surcharger ──────────────────────────────────────────────────────────
82
+
83
+ def process(self, data: Any, vars: Dict, params: Dict) -> Any:
84
+ """
85
+ Traitement principal du composant.
86
+
87
+ Args:
88
+ data: Output du nœud parent (dict, str, list…)
89
+ vars: Variables globales du flux (_vars.NOM_VARIABLE)
90
+ params: Paramètres du job (définis à la création du job Engine)
91
+
92
+ Returns:
93
+ Le résultat à transmettre au nœud suivant.
94
+ Doit être JSON-sérialisable.
95
+
96
+ Raises:
97
+ ComponentError: si le traitement échoue (sera loggé + propagé)
98
+ """
99
+ raise NotImplementedError("Implémentez la méthode process()")
100
+
101
+ def validate_config(self, config: Dict) -> List[str]:
102
+ """
103
+ Valide la configuration du composant (appelé au chargement).
104
+ Retourne une liste d'erreurs (vide = OK).
105
+ Surcharge optionnelle.
106
+ """
107
+ return []
108
+
109
+ def on_start(self):
110
+ """Hook appelé une fois au démarrage du flux. Surcharge optionnelle."""
111
+ pass
112
+
113
+ def on_stop(self):
114
+ """Hook appelé à l'arrêt du flux. Surcharge optionnelle."""
115
+ pass
116
+
117
+ # ── Helpers disponibles dans process() ────────────────────────────────────
118
+
119
+ def log(self, message: str, level: str = "info"):
120
+ """Émet un log visible dans les traces NormaCare Studio/Engine."""
121
+ prefix = {"info": "ℹ", "warning": "⚠", "error": "✕", "debug": "◦"}.get(level, "ℹ")
122
+ entry = f"[{prefix}] {message}"
123
+ self._logs.append(entry)
124
+ print(entry, file=sys.stderr) # visible dans les traces Engine
125
+
126
+ def fail(self, message: str):
127
+ """Lève une erreur métier et arrête le traitement."""
128
+ raise ComponentError(message)
129
+
130
+ def get_option(self, config: Dict, key: str, default=None, required: bool = False):
131
+ """Récupère une option de configuration avec valeur par défaut."""
132
+ val = config.get(key, default)
133
+ if required and (val is None or val == ""):
134
+ self.fail(f"Option requise manquante : '{key}'")
135
+ return val
136
+
137
+ def parse_json(self, value: Any, default=None) -> Any:
138
+ """Parse une valeur JSON si c'est une string, sinon retourne tel quel."""
139
+ if isinstance(value, str):
140
+ try:
141
+ return json.loads(value)
142
+ except (json.JSONDecodeError, ValueError):
143
+ return default
144
+ return value
145
+
146
+ def extract_first_input(self, data: Dict) -> Any:
147
+ """Extrait la première valeur des inputs (pattern courant)."""
148
+ if isinstance(data, dict) and data:
149
+ return next(iter(data.values()))
150
+ return data
151
+
152
+ # ── Runner interne (appelé par run()) ────────────────────────────────────
153
+
154
+ def _run(self, inputs: Dict, vars: Dict, params: Dict, config: Dict) -> Any:
155
+ """Exécute le composant avec gestion d'erreur et logging."""
156
+ self._config = config or {}
157
+ try:
158
+ self.on_start()
159
+ # Passe les données du premier nœud parent
160
+ data = self.extract_first_input(inputs) if inputs else None
161
+ result = self.process(data, vars, params)
162
+ self.on_stop()
163
+ return result
164
+ except ComponentError as e:
165
+ self.log(str(e), "error")
166
+ raise
167
+ except Exception as e:
168
+ self.log(f"Erreur inattendue : {e}\n{traceback.format_exc()}", "error")
169
+ raise
170
+
171
+
172
+ class ComponentError(Exception):
173
+ """Erreur métier dans un composant NormaCare."""
174
+ pass
175
+
176
+
177
+ # ── Point d'entrée standard ───────────────────────────────────────────────────
178
+
179
+ def run(component_class: type):
180
+ """
181
+ Lance le composant en mode exécution Engine.
182
+ Lit les inputs depuis stdin (JSON) et écrit le résultat sur stdout.
183
+
184
+ Appelé automatiquement par l'Engine — ne pas appeler manuellement.
185
+ """
186
+ frame = sys._getframe(1)
187
+ g = frame.f_globals
188
+ # run() ne s'exécute que lorsque l'Engine a injecté le contexte du flux
189
+ # (_inputs/_vars/_params/_config comme variables globales du script). À
190
+ # l'import (test unitaire, introspection), c'est un no-op : cela permet de
191
+ # tester le composant avec ComponentTester sans déclencher une exécution.
192
+ if not any(k in g for k in ("_inputs", "_vars", "_params", "_config")):
193
+ return
194
+ try:
195
+ inputs = g.get("_inputs", {})
196
+ vars_ = g.get("_vars", {})
197
+ params = g.get("_params", {})
198
+ config = g.get("_config", {})
199
+
200
+ comp = component_class()
201
+ result = comp._run(inputs, vars_, params, config)
202
+ print(json.dumps(result, ensure_ascii=False, default=str))
203
+ except Exception as e:
204
+ # En cas d'erreur, on sort un JSON d'erreur sur stdout
205
+ print(json.dumps({"_error": True, "message": str(e)}, ensure_ascii=False))
206
+ sys.exit(1)
@@ -0,0 +1,8 @@
1
+ # Component
2
+
3
+ Composant NormaCare de base.
4
+
5
+ ```bash
6
+ normacare-sdk test .
7
+ normacare-sdk build .
8
+ ```
@@ -0,0 +1,34 @@
1
+ {
2
+ "type": "component",
3
+ "label": "Component",
4
+ "version": "1.0.0",
5
+ "runtime": "python",
6
+ "script": "component.py",
7
+ "description": "Composant Python de base.",
8
+ "author": "",
9
+ "author_email": "",
10
+ "company": "",
11
+ "website": "",
12
+ "icon": "🧩",
13
+ "tags": [
14
+ "exemple"
15
+ ],
16
+ "inputs": [
17
+ "data"
18
+ ],
19
+ "outputs": [
20
+ "result"
21
+ ],
22
+ "license": "proprietary",
23
+ "min_engine": "2.0.0",
24
+ "price": 0.0,
25
+ "currency": "EUR",
26
+ "protected": false,
27
+ "options_schema": {
28
+ "prefix": {
29
+ "type": "string",
30
+ "default": "",
31
+ "label": "Préfixe à ajouter"
32
+ }
33
+ }
34
+ }