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.
- normacare_sdk/__init__.py +39 -0
- normacare_sdk/cli.py +393 -0
- normacare_sdk/component.py +206 -0
- normacare_sdk/templates/basic/README.md +8 -0
- normacare_sdk/templates/basic/component.cmp +34 -0
- normacare_sdk/templates/basic/component.py +21 -0
- normacare_sdk/templates/basic/test_component.py +15 -0
- normacare_sdk/templates/database/README.md +3 -0
- normacare_sdk/templates/database/component.cmp +41 -0
- normacare_sdk/templates/database/component.py +38 -0
- normacare_sdk/templates/database/test_component.py +15 -0
- normacare_sdk/templates/hl7/README.md +3 -0
- normacare_sdk/templates/hl7/component.cmp +35 -0
- normacare_sdk/templates/hl7/component.py +35 -0
- normacare_sdk/templates/hl7/test_component.py +17 -0
- normacare_sdk/templates/http/README.md +3 -0
- normacare_sdk/templates/http/component.cmp +40 -0
- normacare_sdk/templates/http/component.py +44 -0
- normacare_sdk/templates/http/test_component.py +15 -0
- normacare_sdk/templates/javascript/README.md +3 -0
- normacare_sdk/templates/javascript/component.cmp +34 -0
- normacare_sdk/templates/javascript/component.js +9 -0
- normacare_sdk/templates/javascript/test_component.py +15 -0
- normacare_sdk/testing.py +187 -0
- normacare_sdk-2.0.0.dist-info/METADATA +122 -0
- normacare_sdk-2.0.0.dist-info/RECORD +29 -0
- normacare_sdk-2.0.0.dist-info/WHEEL +5 -0
- normacare_sdk-2.0.0.dist-info/entry_points.txt +2 -0
- normacare_sdk-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|
+
}
|