normacare-sdk 2.0.0__tar.gz

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 (34) hide show
  1. normacare_sdk-2.0.0/PKG-INFO +122 -0
  2. normacare_sdk-2.0.0/README.md +104 -0
  3. normacare_sdk-2.0.0/normacare_sdk/__init__.py +39 -0
  4. normacare_sdk-2.0.0/normacare_sdk/cli.py +393 -0
  5. normacare_sdk-2.0.0/normacare_sdk/component.py +206 -0
  6. normacare_sdk-2.0.0/normacare_sdk/templates/basic/README.md +8 -0
  7. normacare_sdk-2.0.0/normacare_sdk/templates/basic/component.cmp +34 -0
  8. normacare_sdk-2.0.0/normacare_sdk/templates/basic/component.py +21 -0
  9. normacare_sdk-2.0.0/normacare_sdk/templates/basic/test_component.py +15 -0
  10. normacare_sdk-2.0.0/normacare_sdk/templates/database/README.md +3 -0
  11. normacare_sdk-2.0.0/normacare_sdk/templates/database/component.cmp +41 -0
  12. normacare_sdk-2.0.0/normacare_sdk/templates/database/component.py +38 -0
  13. normacare_sdk-2.0.0/normacare_sdk/templates/database/test_component.py +15 -0
  14. normacare_sdk-2.0.0/normacare_sdk/templates/hl7/README.md +3 -0
  15. normacare_sdk-2.0.0/normacare_sdk/templates/hl7/component.cmp +35 -0
  16. normacare_sdk-2.0.0/normacare_sdk/templates/hl7/component.py +35 -0
  17. normacare_sdk-2.0.0/normacare_sdk/templates/hl7/test_component.py +17 -0
  18. normacare_sdk-2.0.0/normacare_sdk/templates/http/README.md +3 -0
  19. normacare_sdk-2.0.0/normacare_sdk/templates/http/component.cmp +40 -0
  20. normacare_sdk-2.0.0/normacare_sdk/templates/http/component.py +44 -0
  21. normacare_sdk-2.0.0/normacare_sdk/templates/http/test_component.py +15 -0
  22. normacare_sdk-2.0.0/normacare_sdk/templates/javascript/README.md +3 -0
  23. normacare_sdk-2.0.0/normacare_sdk/templates/javascript/component.cmp +34 -0
  24. normacare_sdk-2.0.0/normacare_sdk/templates/javascript/component.js +9 -0
  25. normacare_sdk-2.0.0/normacare_sdk/templates/javascript/test_component.py +15 -0
  26. normacare_sdk-2.0.0/normacare_sdk/testing.py +187 -0
  27. normacare_sdk-2.0.0/normacare_sdk.egg-info/PKG-INFO +122 -0
  28. normacare_sdk-2.0.0/normacare_sdk.egg-info/SOURCES.txt +32 -0
  29. normacare_sdk-2.0.0/normacare_sdk.egg-info/dependency_links.txt +1 -0
  30. normacare_sdk-2.0.0/normacare_sdk.egg-info/entry_points.txt +2 -0
  31. normacare_sdk-2.0.0/normacare_sdk.egg-info/requires.txt +3 -0
  32. normacare_sdk-2.0.0/normacare_sdk.egg-info/top_level.txt +1 -0
  33. normacare_sdk-2.0.0/pyproject.toml +41 -0
  34. normacare_sdk-2.0.0/setup.cfg +4 -0
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: normacare-sdk
3
+ Version: 2.0.0
4
+ Summary: SDK officiel pour développer, tester et publier des composants NormaCare.
5
+ Author-email: FDevelopment LTD <sdk@fdevelopment.eu>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://normacare.app
8
+ Project-URL: Store, https://store.normacare.app
9
+ Project-URL: Documentation, https://docs.normacare.app/sdk
10
+ Keywords: normacare,hl7,fhir,interoperability,healthcare,integration
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Intended Audience :: Healthcare Industry
13
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Provides-Extra: protect
17
+ Requires-Dist: pyarmor>=8; extra == "protect"
18
+
19
+ # NormaCare SDK
20
+
21
+ Le SDK officiel pour développer, tester et publier des **composants** d'intégration
22
+ médicale, utilisables dans **NormaCare Studio** et exécutés par **NormaCare Engine**,
23
+ et distribuables sur le **NormaCare Store**.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install normacare-sdk
29
+ ```
30
+
31
+ Développement local (depuis ce dossier) :
32
+
33
+ ```bash
34
+ pip install -e .
35
+ ```
36
+
37
+ ## Démarrage rapide (5 minutes)
38
+
39
+ ```bash
40
+ normacare-sdk new mon_composant_hl7 --template hl7
41
+ cd mon_composant_hl7
42
+ normacare-sdk test . # exécute test_mon_composant_hl7.py
43
+ normacare-sdk validate . # vérifie le manifest .cmp
44
+ normacare-sdk build . # produit mon_composant_hl7-1.0.0.ncpkg
45
+ normacare-sdk login # connexion au Store
46
+ normacare-sdk publish . --price 0
47
+ ```
48
+
49
+ ## Templates
50
+
51
+ | Template | Description |
52
+ |---|---|
53
+ | `basic` | Composant Python minimal |
54
+ | `hl7` | Composant HL7 v2.x (extraction de segments) |
55
+ | `database` | Composant avec accès base de données |
56
+ | `javascript` | Composant JavaScript / Node.js |
57
+ | `http` | Composant appel API REST |
58
+
59
+ ## Écrire un composant
60
+
61
+ ```python
62
+ from normacare_sdk import NormaCareComponent, ComponentInfo, run
63
+
64
+ class Component(NormaCareComponent):
65
+ info = ComponentInfo(type="mon_comp", label="Mon Composant", author="Moi")
66
+
67
+ def process(self, data, vars, params):
68
+ # self._config = options du composant (définies dans le .cmp / le Studio)
69
+ seuil = self.get_option(self._config, "seuil", default=10)
70
+ self.log("Traitement en cours")
71
+ return {"result": data, "seuil": seuil}
72
+
73
+ run(Component)
74
+ ```
75
+
76
+ À l'exécution, l'Engine injecte le contexte du flux :
77
+
78
+ | Variable | Contenu |
79
+ |---|---|
80
+ | `_inputs` | sorties des nœuds parents |
81
+ | `_vars` | variables globales du flux |
82
+ | `_params` | paramètres du job Engine |
83
+ | `_config` | options du composant (du manifest `.cmp`) |
84
+
85
+ ## Tester
86
+
87
+ ```python
88
+ from normacare_sdk.testing import ComponentTester
89
+ from mon_comp import Component
90
+
91
+ t = ComponentTester(Component)
92
+ t.run(data={"raw": "MSH|..."}, config={"seuil": 5})
93
+ t.assert_success()
94
+ t.assert_output_key("result")
95
+ t.print_report()
96
+ ```
97
+
98
+ ## Structure d'un composant
99
+
100
+ ```
101
+ mon_composant/
102
+ ├── mon_composant.cmp Manifest JSON (métadonnées, options, prix)
103
+ ├── mon_composant.py Script (votre logique)
104
+ ├── test_mon_composant.py Tests locaux
105
+ └── README.md Documentation (affichée sur le Store)
106
+ ```
107
+
108
+ ## Niveaux de protection
109
+
110
+ | Niveau | Protection |
111
+ |---|---|
112
+ | Open source | aucune |
113
+ | `"protected": true` | obfuscation PyArmor (`pip install normacare-sdk[protect]`) |
114
+ | Payant | jeton d'achat requis (abonnement Publisher) |
115
+
116
+ ## Support
117
+
118
+ - Documentation : https://docs.normacare.app/sdk
119
+ - Store : https://store.normacare.app
120
+ - Email : sdk@fdevelopment.eu
121
+
122
+ © FDevelopment LTD
@@ -0,0 +1,104 @@
1
+ # NormaCare SDK
2
+
3
+ Le SDK officiel pour développer, tester et publier des **composants** d'intégration
4
+ médicale, utilisables dans **NormaCare Studio** et exécutés par **NormaCare Engine**,
5
+ et distribuables sur le **NormaCare Store**.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install normacare-sdk
11
+ ```
12
+
13
+ Développement local (depuis ce dossier) :
14
+
15
+ ```bash
16
+ pip install -e .
17
+ ```
18
+
19
+ ## Démarrage rapide (5 minutes)
20
+
21
+ ```bash
22
+ normacare-sdk new mon_composant_hl7 --template hl7
23
+ cd mon_composant_hl7
24
+ normacare-sdk test . # exécute test_mon_composant_hl7.py
25
+ normacare-sdk validate . # vérifie le manifest .cmp
26
+ normacare-sdk build . # produit mon_composant_hl7-1.0.0.ncpkg
27
+ normacare-sdk login # connexion au Store
28
+ normacare-sdk publish . --price 0
29
+ ```
30
+
31
+ ## Templates
32
+
33
+ | Template | Description |
34
+ |---|---|
35
+ | `basic` | Composant Python minimal |
36
+ | `hl7` | Composant HL7 v2.x (extraction de segments) |
37
+ | `database` | Composant avec accès base de données |
38
+ | `javascript` | Composant JavaScript / Node.js |
39
+ | `http` | Composant appel API REST |
40
+
41
+ ## Écrire un composant
42
+
43
+ ```python
44
+ from normacare_sdk import NormaCareComponent, ComponentInfo, run
45
+
46
+ class Component(NormaCareComponent):
47
+ info = ComponentInfo(type="mon_comp", label="Mon Composant", author="Moi")
48
+
49
+ def process(self, data, vars, params):
50
+ # self._config = options du composant (définies dans le .cmp / le Studio)
51
+ seuil = self.get_option(self._config, "seuil", default=10)
52
+ self.log("Traitement en cours")
53
+ return {"result": data, "seuil": seuil}
54
+
55
+ run(Component)
56
+ ```
57
+
58
+ À l'exécution, l'Engine injecte le contexte du flux :
59
+
60
+ | Variable | Contenu |
61
+ |---|---|
62
+ | `_inputs` | sorties des nœuds parents |
63
+ | `_vars` | variables globales du flux |
64
+ | `_params` | paramètres du job Engine |
65
+ | `_config` | options du composant (du manifest `.cmp`) |
66
+
67
+ ## Tester
68
+
69
+ ```python
70
+ from normacare_sdk.testing import ComponentTester
71
+ from mon_comp import Component
72
+
73
+ t = ComponentTester(Component)
74
+ t.run(data={"raw": "MSH|..."}, config={"seuil": 5})
75
+ t.assert_success()
76
+ t.assert_output_key("result")
77
+ t.print_report()
78
+ ```
79
+
80
+ ## Structure d'un composant
81
+
82
+ ```
83
+ mon_composant/
84
+ ├── mon_composant.cmp Manifest JSON (métadonnées, options, prix)
85
+ ├── mon_composant.py Script (votre logique)
86
+ ├── test_mon_composant.py Tests locaux
87
+ └── README.md Documentation (affichée sur le Store)
88
+ ```
89
+
90
+ ## Niveaux de protection
91
+
92
+ | Niveau | Protection |
93
+ |---|---|
94
+ | Open source | aucune |
95
+ | `"protected": true` | obfuscation PyArmor (`pip install normacare-sdk[protect]`) |
96
+ | Payant | jeton d'achat requis (abonnement Publisher) |
97
+
98
+ ## Support
99
+
100
+ - Documentation : https://docs.normacare.app/sdk
101
+ - Store : https://store.normacare.app
102
+ - Email : sdk@fdevelopment.eu
103
+
104
+ © FDevelopment LTD
@@ -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
+ ]
@@ -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()