Skip to content

Commit e4ec83a

Browse files
committed
feat: ajouter le socle spec de build ARK
1 parent a72e263 commit e4ec83a

10 files changed

Lines changed: 1003 additions & 6 deletions

File tree

Core/ArkConfig/__init__.py

Lines changed: 471 additions & 0 deletions
Large diffs are not rendered by default.

Core/Locking/__init__.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Ague Samuel Amen
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import platform
8+
import sys
9+
import fnmatch
10+
from datetime import datetime
11+
from hashlib import sha256
12+
from importlib.metadata import distributions
13+
from pathlib import Path
14+
from typing import Any
15+
16+
import yaml
17+
from Core.ArkConfig import normalize_ark_config
18+
from engine_sdk.build_context import BuildContext
19+
20+
21+
class LockingError(RuntimeError):
22+
"""Raised when a lock file operation cannot satisfy the expected contract."""
23+
24+
25+
def ensure_workspace_layout(workspace: Path) -> None:
26+
for path in (
27+
workspace / ".ark" / "lock",
28+
workspace / ".ark" / "cache",
29+
workspace / ".ark" / "build",
30+
workspace / ".ark" / "logs",
31+
):
32+
path.mkdir(parents=True, exist_ok=True)
33+
34+
35+
def load_yaml_file(path: Path) -> dict[str, Any]:
36+
if not path.is_file():
37+
raise LockingError(f"File not found: {path}")
38+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
39+
if not isinstance(data, dict):
40+
raise LockingError(f"Invalid YAML object in {path}")
41+
return data
42+
43+
44+
def engine_config_path(workspace: Path, engine_id: str) -> Path:
45+
return workspace / ".ark" / "config" / engine_id / "config.json"
46+
47+
48+
def read_engine_config(workspace: Path, engine_id: str) -> dict[str, Any]:
49+
path = engine_config_path(workspace, engine_id)
50+
if not path.is_file():
51+
return {}
52+
try:
53+
data = json.loads(path.read_text(encoding="utf-8"))
54+
except Exception:
55+
return {}
56+
return data if isinstance(data, dict) else {}
57+
58+
59+
def installed_distributions_snapshot() -> dict[str, str]:
60+
items: dict[str, str] = {}
61+
for dist in distributions():
62+
try:
63+
name = str(dist.metadata["Name"] or "").strip()
64+
except Exception:
65+
name = ""
66+
if not name:
67+
continue
68+
items[name] = str(dist.version)
69+
return dict(sorted(items.items()))
70+
71+
72+
def included_workspace_files(workspace: Path, exclude_patterns: list[str]) -> list[Path]:
73+
included: list[Path] = []
74+
for path in sorted(workspace.rglob("*")):
75+
if not path.is_file():
76+
continue
77+
rel = path.relative_to(workspace).as_posix()
78+
if rel.startswith(".ark/"):
79+
continue
80+
if any(_matches_exclude_pattern(rel, pattern) for pattern in exclude_patterns):
81+
continue
82+
included.append(path)
83+
return included
84+
85+
86+
def _matches_exclude_pattern(relative_path: str, pattern: str) -> bool:
87+
pat = str(pattern or "").strip().replace("\\", "/")
88+
if not pat:
89+
return False
90+
rel = relative_path.replace("\\", "/")
91+
if pat.startswith("./"):
92+
pat = pat[2:]
93+
if pat.endswith("/"):
94+
pat = pat.rstrip("/") + "/**"
95+
if pat.endswith("/**"):
96+
prefix = pat[:-3].rstrip("/")
97+
return rel == prefix or rel.startswith(prefix + "/")
98+
return fnmatch.fnmatch(rel, pat) or Path(rel).match(pat)
99+
100+
101+
def compute_workspace_hash(workspace: Path, exclude_patterns: list[str]) -> str:
102+
digest = sha256()
103+
for path in included_workspace_files(workspace, exclude_patterns):
104+
rel = path.relative_to(workspace).as_posix().encode("utf-8")
105+
digest.update(rel)
106+
digest.update(b"\0")
107+
digest.update(path.read_bytes())
108+
digest.update(b"\0")
109+
return "sha256:" + digest.hexdigest()
110+
111+
112+
def next_build_id(lock_dir: Path) -> str:
113+
today = datetime.utcnow().strftime("%Y_%m_%d")
114+
prefix = f"ARK_{today}_"
115+
seq = 1
116+
if lock_dir.exists():
117+
for path in lock_dir.glob(f"{prefix}*.lock.yml"):
118+
suffix = path.stem.replace(prefix, "").replace(".lock", "")
119+
if suffix.isdigit():
120+
seq = max(seq, int(suffix) + 1)
121+
return f"{prefix}{seq:03d}"
122+
123+
124+
def build_lock_payload(
125+
workspace: Path,
126+
config: dict[str, Any],
127+
*,
128+
engine_id: str,
129+
engine_version: str = "unknown",
130+
dependencies: dict[str, str] | None = None,
131+
) -> dict[str, Any]:
132+
config = normalize_ark_config(config)
133+
exclude_patterns = list(((config.get("workspace") or {}).get("exclude")) or [])
134+
build = config.get("build") or {}
135+
project = config.get("project") or {}
136+
ensure_workspace_layout(workspace)
137+
lock_dir = workspace / ".ark" / "lock"
138+
build_id = next_build_id(lock_dir)
139+
return {
140+
"build_id": build_id,
141+
"project": {
142+
"name": project.get("name"),
143+
"version": project.get("version"),
144+
"entry": project.get("entry"),
145+
},
146+
"workspace": {"exclude_patterns": exclude_patterns},
147+
"build": {
148+
"output": build.get("output"),
149+
"data": list(build.get("data") or []),
150+
**({"icon": build.get("icon")} if build.get("icon") else {}),
151+
},
152+
"engine": {
153+
"name": engine_id,
154+
"version": engine_version,
155+
"config": read_engine_config(workspace, engine_id),
156+
},
157+
"platform": {
158+
"os": sys.platform,
159+
"arch": platform.machine(),
160+
"python_version": platform.python_version(),
161+
},
162+
"dependencies": dependencies or installed_distributions_snapshot(),
163+
"workspace_hash": compute_workspace_hash(workspace, exclude_patterns),
164+
}
165+
166+
167+
def write_lock_files(workspace: Path, payload: dict[str, Any]) -> dict[str, str]:
168+
lock_dir = workspace / ".ark" / "lock"
169+
ensure_workspace_layout(workspace)
170+
build_id = str(payload.get("build_id") or "ARK_UNKNOWN")
171+
target = lock_dir / f"{build_id}.lock.yml"
172+
latest = lock_dir / "latest.lock.yml"
173+
text = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False)
174+
target.write_text(text, encoding="utf-8")
175+
latest.write_text(text, encoding="utf-8")
176+
return {"lock": str(target), "latest": str(latest)}
177+
178+
179+
def cache_rebuild_lock(workspace: Path, payload: dict[str, Any]) -> str:
180+
cache_dir = workspace / ".ark" / "cache" / "rebuild.lock"
181+
cache_dir.mkdir(parents=True, exist_ok=True)
182+
build_id = str(payload.get("build_id") or "ARK_UNKNOWN")
183+
target = cache_dir / f"{build_id}.lock.yml"
184+
target.write_text(
185+
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), encoding="utf-8"
186+
)
187+
return str(target)
188+
189+
190+
def compare_lock_payloads(left: dict[str, Any], right: dict[str, Any]) -> bool:
191+
def comparable(payload: dict[str, Any]) -> dict[str, Any]:
192+
data = dict(payload)
193+
data.pop("build_id", None)
194+
return data
195+
196+
return comparable(left) == comparable(right)
197+
198+
199+
def default_lock_path(workspace: Path) -> Path:
200+
return workspace / ".ark" / "lock" / "latest.lock.yml"
201+
202+
203+
def build_context_from_ark_config(config: dict[str, Any]) -> BuildContext:
204+
config = normalize_ark_config(config)
205+
project = config.get("project") or {}
206+
build = config.get("build") or {}
207+
workspace_cfg = config.get("workspace") or {}
208+
return BuildContext(
209+
project_name=str(project.get("name") or ""),
210+
entry_point=str(project.get("entry") or ""),
211+
output_dir=str(build.get("output") or ""),
212+
exclude_patterns=list(workspace_cfg.get("exclude") or []),
213+
data_mappings=list(build.get("data") or []),
214+
icon=str(build.get("icon")) if build.get("icon") else None,
215+
)
216+
217+
218+
def build_context_from_lock(lock_payload: dict[str, Any]) -> BuildContext:
219+
project = lock_payload.get("project") or {}
220+
build = lock_payload.get("build") or {}
221+
workspace_cfg = lock_payload.get("workspace") or {}
222+
return BuildContext(
223+
project_name=str(project.get("name") or ""),
224+
entry_point=str(project.get("entry") or ""),
225+
output_dir=str(build.get("output") or ""),
226+
exclude_patterns=list(workspace_cfg.get("exclude_patterns") or []),
227+
data_mappings=list(build.get("data") or []),
228+
icon=str(build.get("icon")) if build.get("icon") else None,
229+
)
230+
231+
232+
__all__ = [
233+
"BuildContext",
234+
"LockingError",
235+
"build_context_from_ark_config",
236+
"build_context_from_lock",
237+
"build_lock_payload",
238+
"cache_rebuild_lock",
239+
"compare_lock_payloads",
240+
"compute_workspace_hash",
241+
"default_lock_path",
242+
"engine_config_path",
243+
"ensure_workspace_layout",
244+
"included_workspace_files",
245+
"installed_distributions_snapshot",
246+
"load_yaml_file",
247+
"next_build_id",
248+
"read_engine_config",
249+
"write_lock_files",
250+
]

ENGINES/cx_freeze/__init__.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from typing import Optional
2929

3030
from engine_sdk import (
31+
BuildContext,
3132
CompilerEngine,
3233
add_form_checkbox,
3334
add_icon_selector,
@@ -65,6 +66,55 @@ def preflight(self, gui, file: str) -> bool:
6566
"""Preflight check - dependencies are handled automatically by required_tools."""
6667
return True
6768

69+
def build_command_from_context(self, context: BuildContext) -> list[str]:
70+
"""Build a cx_Freeze command line from a normalized build context."""
71+
cfg = getattr(self, "_config_overrides", {})
72+
if not isinstance(cfg, dict):
73+
cfg = {}
74+
75+
cmd = [sys.executable, "-m", "cx_Freeze"]
76+
77+
windowed_enabled = bool(cfg.get("windowed", False))
78+
if hasattr(self, "_cx_windowed") and self._cx_windowed is not None:
79+
windowed_enabled = bool(self._cx_windowed.isChecked())
80+
if windowed_enabled and platform.system() == "Windows":
81+
cmd.extend(["--base", "Win32GUI"])
82+
83+
output_dir = str(context.output_dir or cfg.get("output_dir") or "").strip()
84+
if output_dir:
85+
cmd.extend(["--target-dir", output_dir])
86+
87+
icon_path = str(context.icon or cfg.get("selected_icon") or "").strip()
88+
if not icon_path and hasattr(self, "_selected_icon") and self._selected_icon:
89+
icon_path = str(self._selected_icon).strip()
90+
if icon_path:
91+
cmd.extend(["--icon", icon_path])
92+
93+
target_name = str(cfg.get("target_name") or context.project_name or "").strip()
94+
if target_name:
95+
cmd.extend(["--target-name", target_name])
96+
97+
debug_enabled = bool(cfg.get("debug", False))
98+
if hasattr(self, "_cx_debug") and self._cx_debug is not None:
99+
debug_enabled = bool(self._cx_debug.isChecked())
100+
if debug_enabled:
101+
cmd.append("--debug")
102+
103+
verbose_enabled = bool(cfg.get("verbose", False))
104+
if hasattr(self, "_cx_verbose") and self._cx_verbose is not None:
105+
verbose_enabled = bool(self._cx_verbose.isChecked())
106+
if verbose_enabled:
107+
cmd.append("--verbose")
108+
109+
for mapping in context.data_mappings:
110+
source = str((mapping or {}).get("source") or "").strip()
111+
destination = str((mapping or {}).get("destination") or "").strip()
112+
if source and destination:
113+
cmd.extend(["--include-files", f"{source}={destination}"])
114+
115+
cmd.extend(["--script", context.entry_point])
116+
return cmd
117+
68118
def build_command(self, gui, file: str) -> list[str]:
69119
"""Build the CX_Freeze command line."""
70120
try:

ENGINES/nuitka/__init__.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from typing import Optional
2828

2929
from engine_sdk import (
30+
BuildContext,
3031
CompilerEngine,
3132
add_icon_selector,
3233
add_output_dir,
@@ -71,6 +72,66 @@ def preflight(self, gui, file: str) -> bool:
7172
"""Preflight check - dependencies are handled automatically by required_tools."""
7273
return True
7374

75+
def build_command_from_context(self, context: BuildContext) -> list[str]:
76+
"""Build a Nuitka command line from a normalized build context."""
77+
cfg = getattr(self, "_config_overrides", {})
78+
if not isinstance(cfg, dict):
79+
cfg = {}
80+
81+
cmd = [sys.executable, "-m", "nuitka"]
82+
83+
standalone_enabled = bool(cfg.get("standalone", False))
84+
if hasattr(self, "_nuitka_standalone"):
85+
standalone_enabled = bool(self._nuitka_standalone.isChecked())
86+
if standalone_enabled:
87+
cmd.append("--standalone")
88+
89+
onefile_enabled = bool(cfg.get("onefile", False))
90+
if hasattr(self, "_nuitka_onefile"):
91+
onefile_enabled = bool(self._nuitka_onefile.isChecked())
92+
if onefile_enabled:
93+
cmd.append("--onefile")
94+
95+
disable_console = bool(cfg.get("disable_console", False))
96+
if hasattr(self, "_nuitka_disable_console"):
97+
disable_console = bool(self._nuitka_disable_console.isChecked())
98+
if disable_console:
99+
cmd.append("--windows-disable-console")
100+
101+
output_dir = str(context.output_dir or cfg.get("output_dir") or "").strip()
102+
if output_dir:
103+
cmd.append(f"--output-dir={output_dir}")
104+
105+
output_name = str(context.project_name or "").strip()
106+
if output_name:
107+
cmd.append(f"--output-filename={output_name}")
108+
109+
icon_path = str(context.icon or cfg.get("selected_icon") or "").strip()
110+
if not icon_path:
111+
icon_path = str(getattr(self, "_nuitka_selected_icon", "") or "").strip()
112+
if icon_path:
113+
cmd.append(f"--windows-icon-from-ico={icon_path}")
114+
115+
for pattern in context.exclude_patterns:
116+
module = (
117+
str(pattern)
118+
.replace("/**/*", "")
119+
.replace("**/*", "")
120+
.replace("/**", "")
121+
.strip("/")
122+
)
123+
if module and "*" not in module:
124+
cmd.append(f"--nofollow-import-to={module.replace('/', '.')}")
125+
126+
for mapping in context.data_mappings:
127+
source = str((mapping or {}).get("source") or "").strip()
128+
destination = str((mapping or {}).get("destination") or "").strip()
129+
if source and destination:
130+
cmd.append(f"--include-data-dir={source}={destination}")
131+
132+
cmd.append(context.entry_point)
133+
return cmd
134+
74135
def build_command(self, gui, file: str) -> list[str]:
75136
"""Build the Nuitka command line."""
76137
try:

0 commit comments

Comments
 (0)