Skip to content

Commit 4056ca7

Browse files
committed
feat: création de la couche de services Core pour centraliser la logique métier
1 parent d5c022c commit 4056ca7

8 files changed

Lines changed: 1094 additions & 60 deletions

File tree

Core/Services/BuildService.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Ague Samuel Amen
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
from __future__ import annotations
17+
18+
import subprocess
19+
from pathlib import Path
20+
from typing import Any, List, Optional
21+
22+
from Core.Locking import BuildContext
23+
from Core.process_security import hardened_popen_kwargs, secure_command
24+
25+
class BuildService:
26+
"""Business logic for compilation and build orchestration."""
27+
28+
@staticmethod
29+
def run_engine_compile(
30+
*,
31+
workspace: Path,
32+
engine_id: str,
33+
context: BuildContext,
34+
engine_config: dict[str, Any] | None = None,
35+
) -> dict[str, Any]:
36+
import EngineLoader as engines_loader
37+
38+
entry_path = workspace / Path(context.entry_point)
39+
if not context.entry_point or not entry_path.is_file():
40+
return {
41+
"success": False,
42+
"error": f"Entrypoint missing or obsolete: {context.entry_point}",
43+
}
44+
45+
try:
46+
engine = engines_loader.create(engine_id)
47+
except Exception as exc:
48+
return {"success": False, "error": f"Unable to load engine '{engine_id}': {exc}"}
49+
50+
try:
51+
setattr(engine, "_config_overrides", dict(engine_config or {}))
52+
except Exception:
53+
pass
54+
55+
try:
56+
resolved = engine.program_and_args_from_context(context)
57+
except NotImplementedError:
58+
return {
59+
"success": False,
60+
"error": f"Engine '{engine_id}' does not support BuildContext builds",
61+
}
62+
except Exception as exc:
63+
return {
64+
"success": False,
65+
"error": f"Engine '{engine_id}' failed to build command: {exc}",
66+
}
67+
if not resolved:
68+
return {"success": False, "error": f"Engine '{engine_id}' returned no command"}
69+
70+
program, args = resolved
71+
try:
72+
safe_program, safe_args, safe_env = secure_command(
73+
program,
74+
args,
75+
{"ARK_WORKSPACE": str(workspace)},
76+
)
77+
except Exception as exc:
78+
return {"success": False, "error": f"Unsafe compile command blocked: {exc}"}
79+
80+
try:
81+
completed = subprocess.run(
82+
[safe_program] + safe_args,
83+
cwd=str(workspace),
84+
env=safe_env,
85+
capture_output=True,
86+
text=True,
87+
**hardened_popen_kwargs(),
88+
)
89+
except Exception as exc:
90+
return {"success": False, "error": f"Compilation failed to start: {exc}"}
91+
92+
return {
93+
"success": completed.returncode == 0,
94+
"return_code": completed.returncode,
95+
"command": [safe_program] + safe_args,
96+
"stdout": completed.stdout,
97+
"stderr": completed.stderr,
98+
"error": None if completed.returncode == 0 else (completed.stderr.strip() or "Build failed"),
99+
}
100+
101+
@staticmethod
102+
def run_bcasl_headless(workspace_dir: str, timeout: float = 0.0) -> int:
103+
ws_path = Path(workspace_dir)
104+
if not ws_path.exists() or not ws_path.is_dir():
105+
print(f"Invalid workspace: {workspace_dir}")
106+
return 1
107+
108+
try:
109+
from bcasl import run_pre_compile
110+
except Exception as exc:
111+
print(f"Unable to load BCASL module: {exc}")
112+
return 1
113+
114+
if timeout > 0:
115+
import os
116+
os.environ["PYCOMPILER_BCASL_PLUGIN_TIMEOUT"] = str(timeout)
117+
118+
class _CliLogger:
119+
def append(self, msg: str):
120+
if not msg.endswith("\n"):
121+
msg += "\n"
122+
print(f"[BCASL] {msg}", end="")
123+
124+
class _CliContext:
125+
def __init__(self, ws_dir):
126+
self.workspace_dir = ws_dir
127+
self.log = _CliLogger()
128+
129+
try:
130+
report = run_pre_compile(_CliContext(str(ws_path)))
131+
except Exception as exc:
132+
print(f"Failed to initialize BCASL mode: {exc}")
133+
return 1
134+
135+
if report is None:
136+
print("BCASL execution failed to start.")
137+
return 1
138+
if report.ok:
139+
print("BCASL run completed successfully.")
140+
return 0
141+
142+
# Accessing failures if it's a list-like report
143+
try:
144+
failed = sum(1 for item in report if not item.success)
145+
except Exception:
146+
failed = 1
147+
148+
print(f"BCASL run finished with failures: {failed} plugin(s) failed.")
149+
return 1

Core/Services/ConfigService.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Ague Samuel Amen
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
from __future__ import annotations
17+
18+
import os
19+
from pathlib import Path
20+
21+
CONFIG_KEYS = {
22+
"user-engine-dir": "user_engine_dir",
23+
"user-plugin-dir": "user_plugin_dir",
24+
"dev-engine-dir": "dev_engine_dir",
25+
"dev-plugin-dir": "dev_plugin_dir",
26+
}
27+
28+
DEFAULT_USER_DIRS = {
29+
"user-engine-dir": ("ark_user", "engines"),
30+
"user-plugin-dir": ("ark_user", "plugins"),
31+
}
32+
33+
class ConfigService:
34+
"""Business logic for global ARK configuration."""
35+
36+
@staticmethod
37+
def config_home() -> Path:
38+
override = os.environ.get("ARK_CONFIG_HOME")
39+
if override:
40+
return Path(override).expanduser()
41+
return Path.home() / ".arkconf"
42+
43+
@staticmethod
44+
def ensure_config_home(*, create: bool = True) -> Path:
45+
root = ConfigService.config_home()
46+
if create:
47+
root.mkdir(parents=True, exist_ok=True)
48+
return root
49+
50+
@staticmethod
51+
def config_file_for(key: str, *, create_root: bool = True) -> Path:
52+
if key not in CONFIG_KEYS:
53+
raise ValueError(f"Unknown config key: {key}")
54+
return ConfigService.ensure_config_home(create=create_root) / CONFIG_KEYS[key]
55+
56+
@staticmethod
57+
def resolve_config_value(key: str, *, create_default: bool = True) -> str | None:
58+
path = ConfigService.config_file_for(key, create_root=False)
59+
if path.exists():
60+
value = path.read_text(encoding="utf-8").strip()
61+
return value or None
62+
63+
default_parts = DEFAULT_USER_DIRS.get(key)
64+
if not default_parts:
65+
return None
66+
67+
default_path = Path.home().joinpath(*default_parts)
68+
if create_default:
69+
try:
70+
default_path.mkdir(parents=True, exist_ok=True)
71+
except OSError:
72+
pass
73+
return str(default_path)
74+
75+
@staticmethod
76+
def set_config_value(key: str, value: str) -> str:
77+
path = ConfigService.config_file_for(key, create_root=True)
78+
target = str(Path(value).expanduser().resolve())
79+
path.write_text(target + "\n", encoding="utf-8")
80+
return target
81+
82+
@staticmethod
83+
def unset_config_value(key: str) -> bool:
84+
path = ConfigService.config_file_for(key)
85+
if not path.exists():
86+
return False
87+
path.unlink()
88+
return True
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 Ague Samuel Amen
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""
17+
EngineConfigService
18+
19+
Business logic for persisting engine configuration per workspace at:
20+
<workspace>/.ark/<engine_id>/config.json
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import os
27+
import re
28+
from datetime import datetime, timezone
29+
from typing import Any, Optional
30+
31+
ENGINE_CONFIG_DIRNAME = ".ark"
32+
ENGINE_CONFIG_BASENAME = "config.json"
33+
ENGINE_CONFIG_VERSION = 1
34+
35+
class EngineConfigService:
36+
@staticmethod
37+
def _safe_engine_id(engine_id: str) -> str:
38+
try:
39+
raw = str(engine_id or "").strip()
40+
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", raw)
41+
return safe or "engine"
42+
except Exception:
43+
return "engine"
44+
45+
@staticmethod
46+
def _engine_config_dir(workspace_dir: str, engine_id: str) -> str:
47+
return os.path.join(
48+
workspace_dir, ENGINE_CONFIG_DIRNAME, EngineConfigService._safe_engine_id(engine_id)
49+
)
50+
51+
@staticmethod
52+
def _engine_config_path(workspace_dir: str, engine_id: str) -> str:
53+
return os.path.join(
54+
EngineConfigService._engine_config_dir(workspace_dir, engine_id), ENGINE_CONFIG_BASENAME
55+
)
56+
57+
@staticmethod
58+
def _atomic_write_json(path: str, data: dict) -> None:
59+
tmp = path + ".tmp"
60+
with open(tmp, "w", encoding="utf-8") as f:
61+
json.dump(data, f, indent=4)
62+
os.replace(tmp, path)
63+
64+
@staticmethod
65+
def load_engine_config(workspace_dir: str, engine_id: str) -> dict[str, Any]:
66+
if not workspace_dir or not engine_id:
67+
return {}
68+
path = EngineConfigService._engine_config_path(workspace_dir, engine_id)
69+
if not os.path.isfile(path):
70+
return {}
71+
try:
72+
with open(path, encoding="utf-8") as f:
73+
data = json.load(f)
74+
return data if isinstance(data, dict) else {}
75+
except Exception:
76+
return {}
77+
78+
@staticmethod
79+
def save_engine_config(
80+
workspace_dir: str,
81+
engine_id: str,
82+
options: Optional[dict],
83+
engine_version: Optional[str] = None,
84+
) -> bool:
85+
if not workspace_dir or not engine_id:
86+
return False
87+
try:
88+
cfg_dir = EngineConfigService._engine_config_dir(workspace_dir, engine_id)
89+
os.makedirs(cfg_dir, exist_ok=True)
90+
payload = {
91+
"meta": {
92+
"engine_id": str(engine_id),
93+
"version": ENGINE_CONFIG_VERSION,
94+
"updated_at": datetime.now(timezone.utc).isoformat(),
95+
},
96+
"options": options if isinstance(options, dict) else {},
97+
}
98+
if engine_version:
99+
payload["meta"]["engine_version"] = str(engine_version)
100+
EngineConfigService._atomic_write_json(EngineConfigService._engine_config_path(workspace_dir, engine_id), payload)
101+
return True
102+
except Exception:
103+
return False

0 commit comments

Comments
 (0)