Skip to content

Commit ad966a1

Browse files
committed
Renforce la CLI pour les pipelines CI/CD
Ajoute des commandes et conventions headless plus stables pour l'automatisation et les checks de pipeline. Introduit des codes de sortie CI explicites pour les prechecks, workspaces invalides et engines introuvables sur les commandes structurees qui exposent des sorties JSON. Ajoute des variantes --strict sur les commandes doctor, compatibilite engine, inspection workspace et doctor BCASL afin de permettre un echec propre des jobs CI lorsque les verifications detectent un probleme. Expose la configuration engine en mode headless avec engine config show et engine config path pour permettre l'inspection des fichiers de configuration persistants sans passer par l'interface graphique. Remplace le JSON minimal delegue de BCASL par de vrais payloads headless pour bcasl list et bcasl doctor, avec enumeration des plugins charges, erreurs de decouverte et checks de base sur le repertoire plugins et le workspace. Ajoute la commande ci smoke qui agrege les checks engines, BCASL et workspace dans un payload unique, avec prise en charge de --strict et --require-entrypoint pour les pipelines qui veulent un gate simple et stable. Corrige l'ecriture des sorties JSON pour eviter toute corruption par wrapping console et garantit un flux machine-readable propre pour les usages CI. Corrige aussi la detection du bootstrap Qt pour que les commandes gui ... soient bien reconnues comme chemins GUI reels. Etend les tests CLI et runtime pour verrouiller les nouveaux codes de sortie, la config engine headless, le doctor BCASL, la commande ci smoke et la detection Qt du groupe gui. Met enfin a jour le README, la checklist de smoke release et le guide de contribution pour documenter la commande ci smoke, les exemples d'integration headless et les codes de sortie stables a utiliser dans les pipelines.
1 parent 822bd36 commit ad966a1

8 files changed

Lines changed: 471 additions & 18 deletions

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ python pycompiler_ark.py engine list --json
9191
python pycompiler_ark.py engine doctor nuitka src/main.py --json
9292
python pycompiler_ark.py workspace inspect . --json
9393
python pycompiler_ark.py doctor --json
94+
python pycompiler_ark.py ci smoke . --json --strict --require-entrypoint
9495
python pycompiler_ark.py scaffold engine demo_engine --json
9596
python pycompiler_ark.py unload --json
9697
```
@@ -103,6 +104,7 @@ python pycompiler_ark.py unload --json
103104
- `workspace`: inspect the current workspace and resolved entrypoint
104105
- `doctor`: global diagnostics snapshot
105106
- `scaffold`: generate starter templates for engines and plugins
107+
- `ci`: run stable smoke checks and preflight gates for automation
106108

107109
### Headless note
108110

@@ -120,6 +122,15 @@ The CLI bootstrap no longer forces Qt for purely headless commands such as:
120122

121123
This makes scripting and CI friendlier on machines where the GUI stack is unavailable or intentionally not used.
122124

125+
### CI-friendly exit codes
126+
127+
- `0`: success
128+
- `3`: precheck or smoke failure (`--strict`)
129+
- `4`: invalid or missing workspace
130+
- `5`: engine not found
131+
132+
These are the stable codes to key on in CI for the structured headless commands.
133+
123134
### Dedicated CLI quick commands
124135

125136
```text
@@ -204,6 +215,7 @@ python -m py_compile pycompiler_ark.py
204215
python -m pycompiler_ark --help
205216
python -m pycompiler_ark workspace inspect . --json
206217
python -m pycompiler_ark engine list --json
218+
python -m pycompiler_ark ci smoke . --json --strict --require-entrypoint
207219
```
208220

209221
Quality status:

cli/click_app.py

Lines changed: 155 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@
88

99
from .dedicated import _run_bcasl_headless, run_dedicated_cli
1010
from .headless_ops import (
11+
bcasl_doctor_payload,
12+
bcasl_list_payload,
13+
ci_smoke_payload,
1114
doctor_payload,
1215
emit_json,
16+
engine_config_path_payload,
17+
engine_config_show_payload,
1318
engine_doctor_payload,
1419
engine_info_payload,
1520
engine_list_payload,
@@ -33,20 +38,37 @@
3338
click = None
3439

3540

41+
EXIT_OK = 0
42+
EXIT_RUNTIME_ERROR = 1
43+
EXIT_USAGE_ERROR = 2
44+
EXIT_PRECHECK_FAILED = 3
45+
EXIT_WORKSPACE_INVALID = 4
46+
EXIT_ENGINE_NOT_FOUND = 5
47+
48+
3649
def has_click() -> bool:
3750
return click is not None
3851

3952

4053
def _echo_payload(payload, as_json: bool = False) -> None:
4154
if as_json:
42-
plain(emit_json(payload))
55+
text = emit_json(payload)
56+
sys.stdout.write(text)
57+
if not text.endswith("\n"):
58+
sys.stdout.write("\n")
59+
sys.stdout.flush()
4360
return
4461
if isinstance(payload, str):
4562
plain(payload)
4663
else:
4764
plain(str(payload))
4865

4966

67+
def _emit_and_exit(payload, code: int, as_json: bool = False) -> None:
68+
_echo_payload(payload, as_json=as_json)
69+
raise click.exceptions.Exit(code)
70+
71+
5072
def _resolve_workspace_path(workspace: str | None) -> str | None:
5173
if not workspace:
5274
return None
@@ -158,6 +180,7 @@ def cli(
158180
plain(" workspace Inspect workspace state")
159181
plain(" doctor Global diagnostics")
160182
plain(" scaffold Generate starter templates")
183+
plain(" ci Run CI-friendly smoke checks")
161184
ctx.exit(0)
162185

163186
if ctx.invoked_subcommand is None:
@@ -220,6 +243,8 @@ def engine_list(workspace, as_json):
220243
def engine_info(engine_id, workspace, as_json):
221244
payload = engine_info_payload(engine_id, workspace=_resolve_workspace_path(workspace))
222245
if as_json:
246+
if not payload.get("found"):
247+
_emit_and_exit(payload, EXIT_ENGINE_NOT_FOUND, as_json=True)
223248
_echo_payload(payload, as_json=True)
224249
return
225250
if not payload.get("found"):
@@ -238,9 +263,14 @@ def engine_info(engine_id, workspace, as_json):
238263
@click.argument("engine_id")
239264
@click.option("-w", "--workspace", type=click.Path(exists=False))
240265
@click.option("--json", "as_json", is_flag=True)
241-
def engine_compat(engine_id, workspace, as_json):
266+
@click.option("--strict", is_flag=True, help="Exit non-zero when compatibility checks fail")
267+
def engine_compat(engine_id, workspace, as_json, strict):
242268
payload = engine_info_payload(engine_id, workspace=_resolve_workspace_path(workspace))
243269
if as_json:
270+
if not payload.get("found"):
271+
_emit_and_exit(payload, EXIT_ENGINE_NOT_FOUND, as_json=True)
272+
if strict and not payload["engine"]["compatible"]:
273+
_emit_and_exit(payload, EXIT_PRECHECK_FAILED, as_json=True)
244274
_echo_payload(payload, as_json=True)
245275
return
246276
if not payload.get("found"):
@@ -252,19 +282,26 @@ def engine_compat(engine_id, workspace, as_json):
252282
error(f"Engine not compatible: {engine_id}")
253283
if eng.get("message"):
254284
warn(f"Reason: {eng['message']}")
285+
if strict:
286+
raise click.exceptions.Exit(EXIT_PRECHECK_FAILED)
255287

256288
@engine.command("doctor")
257289
@click.argument("engine_id")
258290
@click.argument("file_path", required=False, type=click.Path(exists=False))
259291
@click.option("-w", "--workspace", type=click.Path(exists=False))
260292
@click.option("--json", "as_json", is_flag=True)
261-
def engine_doctor(engine_id, file_path, workspace, as_json):
293+
@click.option("--strict", is_flag=True, help="Exit non-zero when doctor checks fail")
294+
def engine_doctor(engine_id, file_path, workspace, as_json, strict):
262295
payload = engine_doctor_payload(
263296
engine_id,
264297
workspace=_resolve_workspace_path(workspace),
265298
file_path=_resolve_workspace_path(file_path),
266299
)
267300
if as_json:
301+
if "checks" not in payload:
302+
_emit_and_exit(payload, EXIT_ENGINE_NOT_FOUND, as_json=True)
303+
if strict and any(not check["ok"] for check in payload["checks"]):
304+
_emit_and_exit(payload, EXIT_PRECHECK_FAILED, as_json=True)
268305
_echo_payload(payload, as_json=True)
269306
return
270307
if "checks" not in payload:
@@ -273,6 +310,54 @@ def engine_doctor(engine_id, file_path, workspace, as_json):
273310
for check in payload["checks"]:
274311
status = "OK" if check["ok"] else "FAIL"
275312
plain(f" [{status}] {check['name']}: {check.get('message') or ''}")
313+
if strict and any(not check["ok"] for check in payload["checks"]):
314+
raise click.exceptions.Exit(EXIT_PRECHECK_FAILED)
315+
316+
@engine.group("config")
317+
def engine_config():
318+
"""Inspect persisted per-workspace engine configuration."""
319+
320+
@engine_config.command("show")
321+
@click.argument("engine_id")
322+
@click.option("-w", "--workspace", required=True, type=click.Path(exists=False))
323+
@click.option("--json", "as_json", is_flag=True)
324+
def engine_config_show(engine_id, workspace, as_json):
325+
payload = engine_config_show_payload(
326+
engine_id, workspace=_resolve_workspace_path(workspace)
327+
)
328+
if as_json:
329+
if not payload.get("found", True):
330+
_emit_and_exit(payload, EXIT_ENGINE_NOT_FOUND, as_json=True)
331+
if payload.get("error") == "workspace is required":
332+
_emit_and_exit(payload, EXIT_WORKSPACE_INVALID, as_json=True)
333+
_echo_payload(payload, as_json=True)
334+
return
335+
if payload.get("error") == "workspace is required":
336+
raise click.ClickException("Workspace is required")
337+
if not payload.get("found", True):
338+
raise click.ClickException(f"Engine not found: {engine_id}")
339+
plain(f"Engine config: {engine_id}")
340+
plain(f" Workspace: {payload.get('workspace')}")
341+
plain(f" Path: {payload.get('path')}")
342+
plain(f" Exists: {'yes' if payload.get('exists') else 'no'}")
343+
plain(emit_json(payload.get("config", {})))
344+
345+
@engine_config.command("path")
346+
@click.argument("engine_id")
347+
@click.option("-w", "--workspace", required=True, type=click.Path(exists=False))
348+
@click.option("--json", "as_json", is_flag=True)
349+
def engine_config_path(engine_id, workspace, as_json):
350+
payload = engine_config_path_payload(
351+
engine_id, workspace=_resolve_workspace_path(workspace)
352+
)
353+
if as_json:
354+
if payload.get("error") == "workspace is required":
355+
_emit_and_exit(payload, EXIT_WORKSPACE_INVALID, as_json=True)
356+
_echo_payload(payload, as_json=True)
357+
return
358+
if payload.get("error") == "workspace is required":
359+
raise click.ClickException("Workspace is required")
360+
plain(payload.get("path", ""))
276361

277362
@engine.command("dry-run")
278363
@click.argument("engine_id")
@@ -331,9 +416,12 @@ def workspace():
331416
@workspace.command("inspect")
332417
@click.argument("path", required=False, type=click.Path(exists=False))
333418
@click.option("--json", "as_json", is_flag=True)
334-
def workspace_inspect(path, as_json):
419+
@click.option("--strict", is_flag=True, help="Exit non-zero when the workspace is invalid")
420+
def workspace_inspect(path, as_json, strict):
335421
payload = workspace_inspect_payload(_resolve_workspace_path(path or "."))
336422
if as_json:
423+
if strict and not payload.get("exists"):
424+
_emit_and_exit(payload, EXIT_WORKSPACE_INVALID, as_json=True)
337425
_echo_payload(payload, as_json=True)
338426
return
339427
if not payload.get("exists"):
@@ -349,13 +437,18 @@ def workspace_inspect(path, as_json):
349437
@workspace.command("entrypoint")
350438
@click.argument("path", required=False, type=click.Path(exists=False))
351439
@click.option("--json", "as_json", is_flag=True)
352-
def workspace_entrypoint(path, as_json):
440+
@click.option("--strict", is_flag=True, help="Exit non-zero when no entrypoint is resolved")
441+
def workspace_entrypoint(path, as_json, strict):
353442
payload = workspace_inspect_payload(_resolve_workspace_path(path or "."))
354443
result = {"workspace": payload.get("workspace"), "entrypoint": payload.get("entrypoint")}
355444
if as_json:
445+
if strict and not result.get("entrypoint"):
446+
_emit_and_exit(result, EXIT_PRECHECK_FAILED, as_json=True)
356447
_echo_payload(result, as_json=True)
357448
return
358449
plain(result["entrypoint"] or "")
450+
if strict and not result.get("entrypoint"):
451+
raise click.exceptions.Exit(EXIT_PRECHECK_FAILED)
359452

360453
@workspace.command("files")
361454
@click.argument("path", required=False, type=click.Path(exists=False))
@@ -377,9 +470,20 @@ def workspace_files(path, as_json):
377470
@cli.command("doctor")
378471
@click.argument("workspace", required=False, type=click.Path(exists=False))
379472
@click.option("--json", "as_json", is_flag=True)
380-
def doctor(workspace, as_json):
473+
@click.option("--strict", is_flag=True, help="Exit non-zero when diagnostics detect issues")
474+
def doctor(workspace, as_json, strict):
381475
payload = doctor_payload(workspace=_resolve_workspace_path(workspace))
476+
if strict:
477+
workspace_payload = payload.get("workspace")
478+
has_workspace_issue = isinstance(workspace_payload, dict) and not workspace_payload.get("exists", True)
479+
has_engine_issue = payload["engines"]["compatible_count"] != payload["engines"]["count"]
480+
has_qt_issue = not payload.get("qt_available", False)
481+
strict_failed = bool(has_workspace_issue or has_engine_issue or has_qt_issue)
482+
else:
483+
strict_failed = False
382484
if as_json:
485+
if strict_failed:
486+
_emit_and_exit(payload, EXIT_PRECHECK_FAILED, as_json=True)
383487
_echo_payload(payload, as_json=True)
384488
return
385489
plain("PyCompiler ARK Doctor")
@@ -389,6 +493,38 @@ def doctor(workspace, as_json):
389493
plain(
390494
f" Engines: {payload['engines']['compatible_count']}/{payload['engines']['count']} compatible"
391495
)
496+
if strict_failed:
497+
raise click.exceptions.Exit(EXIT_PRECHECK_FAILED)
498+
499+
@cli.group()
500+
def ci():
501+
"""Run CI-friendly smoke checks."""
502+
503+
@ci.command("smoke")
504+
@click.argument("workspace", required=False, type=click.Path(exists=False))
505+
@click.option("--json", "as_json", is_flag=True)
506+
@click.option("--strict", is_flag=True, help="Exit non-zero when smoke checks fail")
507+
@click.option(
508+
"--require-entrypoint",
509+
is_flag=True,
510+
help="Fail when the workspace has no configured entrypoint",
511+
)
512+
def ci_smoke(workspace, as_json, strict, require_entrypoint):
513+
payload = ci_smoke_payload(
514+
workspace=_resolve_workspace_path(workspace),
515+
require_entrypoint=require_entrypoint,
516+
)
517+
if as_json:
518+
if strict and not payload.get("ok"):
519+
_emit_and_exit(payload, EXIT_PRECHECK_FAILED, as_json=True)
520+
_echo_payload(payload, as_json=True)
521+
return
522+
plain("PyCompiler ARK CI Smoke")
523+
for check in payload.get("checks", []):
524+
status = "OK" if check["ok"] else "FAIL"
525+
plain(f" [{status}] {check['name']}: {check.get('message') or ''}")
526+
if strict and not payload.get("ok"):
527+
raise click.exceptions.Exit(EXIT_PRECHECK_FAILED)
392528

393529
@cli.group()
394530
def scaffold():
@@ -437,12 +573,10 @@ def bcasl_gui_cmd(workspace):
437573
@bcasl.command("list")
438574
@click.option("--json", "as_json", is_flag=True)
439575
def bcasl_list(as_json):
440-
code = _run_bcasl_headless(["list"])
441576
if as_json:
442-
# Fallback minimal JSON until BCASL headless metadata is fully exposed.
443-
_echo_payload({"status": "delegated", "command": "bcasl list", "exit_code": code}, as_json=True)
577+
_echo_payload(bcasl_list_payload(), as_json=True)
444578
return
445-
sys.exit(code)
579+
sys.exit(_run_bcasl_headless(["list"]))
446580

447581
@bcasl.command("run")
448582
@click.argument("workspace", type=click.Path(exists=False))
@@ -456,16 +590,20 @@ def bcasl_run(workspace, timeout):
456590
@bcasl.command("doctor")
457591
@click.argument("workspace", required=False, type=click.Path(exists=False))
458592
@click.option("--json", "as_json", is_flag=True)
459-
def bcasl_doctor(workspace, as_json):
460-
payload = {
461-
"workspace": _resolve_workspace_path(workspace),
462-
"available": True,
463-
"note": "BCASL doctor is currently delegated to the headless BCASL runner.",
464-
}
593+
@click.option("--strict", is_flag=True, help="Exit non-zero when BCASL checks fail")
594+
def bcasl_doctor(workspace, as_json, strict):
595+
payload = bcasl_doctor_payload(workspace=_resolve_workspace_path(workspace))
465596
if as_json:
597+
if strict and any(not check["ok"] for check in payload.get("checks", [])):
598+
_emit_and_exit(payload, EXIT_PRECHECK_FAILED, as_json=True)
466599
_echo_payload(payload, as_json=True)
467600
return
468-
plain(payload["note"])
601+
plain("BCASL Doctor")
602+
for check in payload.get("checks", []):
603+
status = "OK" if check["ok"] else "FAIL"
604+
plain(f" [{status}] {check['name']}: {check.get('message') or ''}")
605+
if strict and any(not check["ok"] for check in payload.get("checks", [])):
606+
raise click.exceptions.Exit(EXIT_PRECHECK_FAILED)
469607

470608
@cli.command("info")
471609
@click.option("--json", "as_json", is_flag=True)

0 commit comments

Comments
 (0)