Skip to content

Commit 572ee0f

Browse files
committed
Rend la decouverte des engines dynamique et durcit le registre
Supprime la dependance a un chargement implicite base sur des effets de bord en remplacant l'ancien parcours par une decouverte dynamique des packages engines sous le namespace ENGINES. Importe les modules de maniere namespacée et robuste, introspecte les sous-classes de CompilerEngine declarees dans chaque module puis les enregistre dynamiquement dans le registre actif. Aligne egalement engine_sdk.registry sur EngineLoader.registry, ajoute une resolution lazy plus sure pour eviter certains chargements Qt trop precoces, et conserve une API SDK stable cote engines. Renforce enfin bind_tabs avec des logs explicites lorsque la creation d'onglet echoue ou retourne une charge invalide, afin de faciliter le diagnostic sans casser l'initialisation du GUI. Ajoute un test cible de non-regression pour couvrir la decouverte dynamique namespacée, la synchronisation du registre SDK et la journalisation des echecs de chargement d'onglets.
1 parent 9816445 commit 572ee0f

6 files changed

Lines changed: 441 additions & 46 deletions

File tree

EngineLoader/Loader/EngineLoader.py

Lines changed: 104 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,53 +13,127 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16+
from __future__ import annotations
17+
1618
import importlib
19+
import inspect
20+
import logging
1721
import os
1822
import pkgutil
1923
import sys
24+
from types import ModuleType
25+
26+
from EngineLoader.base import CompilerEngine
27+
from EngineLoader import registry as engine_registry
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
def _iter_engine_module_names(base_path: str, namespace_package: str) -> list[str]:
33+
"""Return top-level engine package names under the configured namespace."""
34+
if not os.path.isdir(base_path):
35+
return []
36+
prefix = f"{namespace_package.rstrip('.') }."
37+
return [
38+
name
39+
for _finder, name, ispkg in pkgutil.iter_modules([base_path], prefix=prefix)
40+
if ispkg
41+
]
42+
43+
44+
def _register_engine_classes(module: ModuleType) -> list[str]:
45+
"""Register CompilerEngine subclasses declared by an imported module."""
46+
registered: list[str] = []
47+
for _name, obj in inspect.getmembers(module, inspect.isclass):
48+
if not issubclass(obj, CompilerEngine) or obj is CompilerEngine:
49+
continue
50+
try:
51+
if not str(getattr(obj, "__module__", "")).startswith(module.__name__):
52+
continue
53+
engine_registry.engine_register(obj)
54+
registered.append(getattr(obj, "id", obj.__name__))
55+
except Exception:
56+
logger.exception(
57+
"Failed to register engine class %s from module %s",
58+
getattr(obj, "__name__", repr(obj)),
59+
module.__name__,
60+
)
61+
return registered
62+
63+
64+
def _import_module_tree(module_name: str) -> list[ModuleType]:
65+
"""Import a package and its submodules, returning successfully loaded modules."""
66+
imported: list[ModuleType] = []
67+
root_module = importlib.import_module(module_name)
68+
imported.append(root_module)
69+
70+
module_path = getattr(root_module, "__path__", None)
71+
if not module_path:
72+
return imported
2073

74+
for _finder, subname, _ispkg in pkgutil.walk_packages(module_path, prefix=f"{module_name}."):
75+
try:
76+
imported.append(importlib.import_module(subname))
77+
except Exception:
78+
logger.exception("Failed to import engine submodule %s", subname)
79+
return imported
2180

22-
def _discover_external_plugins(base_path: str) -> None:
23-
"""Import all top-level modules and packages under base_path (ENGINES/),
24-
recursively importing subpackages to trigger engine registration side-effects.
25-
"""
81+
82+
def _sync_engine_sdk_registry() -> None:
83+
"""Keep engine_sdk.registry aligned with the active EngineLoader registry module."""
84+
try:
85+
import engine_sdk
86+
87+
engine_sdk.registry = engine_registry
88+
except Exception:
89+
logger.exception("Failed to synchronize engine_sdk.registry with EngineLoader.registry")
90+
91+
92+
def _discover_external_plugins(base_path: str, namespace_package: str = "ENGINES") -> None:
93+
"""Import namespaced engine packages and register CompilerEngine classes dynamically."""
2694
try:
2795
if not os.path.isdir(base_path):
2896
return
29-
if base_path not in sys.path:
30-
sys.path.insert(0, base_path)
31-
# Import only top-level PACKAGES (directories with __init__.py). Skip bare modules.
32-
for _finder, name, ispkg in pkgutil.iter_modules([base_path]):
33-
if not ispkg:
34-
# Enforce package-only engines
35-
continue
97+
98+
parent_dir = os.path.abspath(os.path.dirname(base_path))
99+
if parent_dir not in sys.path:
100+
sys.path.insert(0, parent_dir)
101+
102+
for module_name in _iter_engine_module_names(base_path, namespace_package):
36103
try:
37-
# Import the package so its __init__ can self-register the engine
38-
importlib.import_module(name)
39-
# Import submodules to trigger additional registrations if needed
40-
pkg_path = os.path.join(base_path, name)
41-
for __f, subname, __ispkg in pkgutil.walk_packages(
42-
[pkg_path], prefix=f"{name}."
43-
):
44-
try:
45-
importlib.import_module(subname)
46-
except Exception:
47-
# Ignore broken submodules to avoid crashing global discovery
48-
pass
104+
imported_modules = _import_module_tree(module_name)
49105
except Exception:
50-
# Ignore broken plugins; do not crash host discovery
51-
pass
106+
logger.exception("Failed to import engine package %s", module_name)
107+
continue
108+
109+
registered_any = False
110+
for module in imported_modules:
111+
try:
112+
if _register_engine_classes(module):
113+
registered_any = True
114+
except Exception:
115+
logger.exception(
116+
"Failed while introspecting engine module %s",
117+
getattr(module, "__name__", module_name),
118+
)
119+
if not registered_any:
120+
logger.warning(
121+
"No CompilerEngine subclasses were discovered in engine package %s",
122+
module_name,
123+
)
52124
except Exception:
53-
# Never let discovery break the host application
54-
pass
125+
logger.exception("Engine discovery failed for base path %s", base_path)
126+
finally:
127+
_sync_engine_sdk_registry()
55128

56129

57130
def _auto_discover() -> None:
58-
"""Discover and register external engine plugins ONLY from the 'ENGINES' folder at project root."""
131+
"""Discover and register external engine plugins from the project ENGINES folder."""
59132
base_dir = os.path.dirname(__file__)
60133
try:
61134
project_root = os.path.abspath(os.path.join(base_dir, os.pardir, os.pardir))
62135
external_dir = os.path.join(project_root, "ENGINES")
63-
_discover_external_plugins(external_dir)
136+
_discover_external_plugins(external_dir, namespace_package="ENGINES")
64137
except Exception:
65-
pass
138+
logger.exception("Automatic engine discovery failed")
139+
_sync_engine_sdk_registry()

EngineLoader/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ def on_success(self, gui, file: str) -> None:
104104
"""Hook called when a build is successful."""
105105
pass
106106

107+
def engine_translate(self, key: str, default: Optional[str] = None) -> str:
108+
"""Translate an engine-local key using the shared engine i18n registry."""
109+
try:
110+
from .registry import engine_translate as _engine_translate
111+
112+
return _engine_translate(self, key, default)
113+
except Exception:
114+
return default if default is not None else str(key)
115+
107116
def create_tab(self, gui):
108117
"""
109118
Optionally create and return a QWidget tab and its label for the GUI.

EngineLoader/registry.py

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515

1616
from __future__ import annotations
1717

18+
import logging
1819
import os
1920
from typing import Optional, Any
2021

21-
from .base import CompilerEngine
22+
from .base import CompilerEngine, log_i18n_level
23+
24+
logger = logging.getLogger(__name__)
2225

2326
_REGISTRY: dict[str, type[CompilerEngine]] = {}
2427
_ORDER: list[str] = []
@@ -45,6 +48,9 @@
4548
"zh_cn": "zh-CN",
4649
"zh-cn": "zh-CN",
4750
}
51+
_GLOBAL_TR: dict[str, Any] = {}
52+
_GLOBAL_LANG: str = "en"
53+
_ENGINE_TR: dict[str, dict[str, Any]] = {}
4854

4955

5056
def normalize_language_code(code: Optional[str]) -> str:
@@ -113,6 +119,112 @@ def resolve_language_code(gui, tr: Optional[dict]) -> str:
113119
return normalize_language_code(code)
114120

115121

122+
def set_translations(gui, tr: Optional[dict]) -> None:
123+
"""Store host translations and the resolved active language for engine i18n."""
124+
global _GLOBAL_TR, _GLOBAL_LANG
125+
try:
126+
_GLOBAL_TR = dict(tr) if isinstance(tr, dict) else {}
127+
except Exception:
128+
_GLOBAL_TR = {}
129+
try:
130+
_GLOBAL_LANG = resolve_language_code(gui, tr if isinstance(tr, dict) else None)
131+
except Exception:
132+
_GLOBAL_LANG = "en"
133+
134+
135+
def get_translations() -> dict[str, Any]:
136+
return dict(_GLOBAL_TR) if isinstance(_GLOBAL_TR, dict) else {}
137+
138+
139+
def get_language_code() -> str:
140+
return _GLOBAL_LANG or "en"
141+
142+
143+
def register_engine_translations(engine_id: str, tr: dict) -> None:
144+
"""Register or refresh translations for a specific engine id."""
145+
if not engine_id:
146+
return
147+
try:
148+
if isinstance(tr, dict):
149+
payload = dict(tr)
150+
else:
151+
payload = {}
152+
_ENGINE_TR[str(engine_id)] = payload
153+
_ENGINE_TR[str(engine_id).lower()] = payload
154+
except Exception:
155+
pass
156+
157+
158+
def _engine_package_for_class(engine_cls: type[CompilerEngine]) -> str | None:
159+
try:
160+
module_name = str(getattr(engine_cls, "__module__", "") or "")
161+
if not module_name:
162+
return None
163+
parts = module_name.split(".")
164+
if len(parts) >= 2:
165+
return ".".join(parts[:2])
166+
return module_name
167+
except Exception:
168+
return None
169+
170+
171+
def _refresh_engine_translations_for_ids(engine_ids: list[str], code: str) -> None:
172+
"""Load and cache language files for selected engine ids."""
173+
for eid in engine_ids:
174+
try:
175+
engine_cls = get_engine(eid)
176+
if engine_cls is None:
177+
continue
178+
engine_package = _engine_package_for_class(engine_cls)
179+
if not engine_package:
180+
continue
181+
data = load_engine_language_file(engine_package, code)
182+
register_engine_translations(eid, data if isinstance(data, dict) else {})
183+
except Exception:
184+
continue
185+
186+
187+
def _refresh_all_engine_translations(code: str) -> None:
188+
"""Reload translations for all registered engines for the active language."""
189+
try:
190+
_ENGINE_TR.clear()
191+
except Exception:
192+
pass
193+
_refresh_engine_translations_for_ids(list(_ORDER), code)
194+
195+
196+
def engine_translate(engine_or_id: Any, key: str, default: Optional[str] = None) -> str:
197+
"""Translate an engine-local key with fallback to host translations and defaults."""
198+
engine_id = None
199+
try:
200+
if isinstance(engine_or_id, str):
201+
engine_id = engine_or_id
202+
else:
203+
engine_id = getattr(engine_or_id, "id", None)
204+
except Exception:
205+
engine_id = None
206+
207+
try:
208+
if engine_id:
209+
for candidate in (str(engine_id), str(engine_id).lower()):
210+
payload = _ENGINE_TR.get(candidate)
211+
if isinstance(payload, dict):
212+
value = payload.get(key)
213+
if isinstance(value, str):
214+
return value
215+
except Exception:
216+
pass
217+
218+
try:
219+
fallback = _GLOBAL_TR.get(key)
220+
if isinstance(fallback, str):
221+
return fallback
222+
except Exception:
223+
pass
224+
225+
return default if default is not None else str(key)
226+
227+
116228
def unregister(eid: str) -> None:
117229
"""Unregister an engine id and its tab mapping if present."""
118230
try:
@@ -219,6 +331,19 @@ def bind_tabs(gui) -> None:
219331
# Track if any engine created a tab
220332
any_engine_tab_created = False
221333

334+
def _log_tab_load_issue(eid: str, fr: str, en: str, exc: Exception | None = None) -> None:
335+
try:
336+
log_i18n_level(gui, "warning", fr, en)
337+
except Exception:
338+
pass
339+
try:
340+
if exc is None:
341+
logger.warning("%s", en)
342+
else:
343+
logger.warning("%s: %s", en, exc, exc_info=exc)
344+
except Exception:
345+
pass
346+
222347
def _wrap_tab_scroll(widget):
223348
try:
224349
from PySide6.QtCore import Qt
@@ -261,9 +386,25 @@ def _wrap_tab_scroll(widget):
261386
res = getattr(engine, "create_tab", None)
262387
if not callable(res):
263388
continue
264-
pair = res(gui)
389+
try:
390+
pair = res(gui)
391+
except Exception as exc:
392+
_log_tab_load_issue(
393+
eid,
394+
f"Echec du chargement de l'onglet moteur '{eid}'",
395+
f"Failed to load engine tab '{eid}'",
396+
exc,
397+
)
398+
continue
265399
if not pair:
266400
continue
401+
if not isinstance(pair, tuple) or len(pair) != 2:
402+
_log_tab_load_issue(
403+
eid,
404+
f"Onglet invalide retourne par le moteur '{eid}'",
405+
f"Engine '{eid}' returned an invalid tab payload",
406+
)
407+
continue
267408
any_engine_tab_created = True
268409
widget, label = pair
269410
widget = _wrap_tab_scroll(widget)
@@ -280,12 +421,20 @@ def _wrap_tab_scroll(widget):
280421
try:
281422
tr = getattr(gui, "_tr", None)
282423
fn = getattr(engine, "apply_i18n", None)
424+
if isinstance(tr, dict):
425+
set_translations(gui, tr)
426+
_refresh_engine_translations_for_ids([eid], get_language_code())
283427
if callable(fn) and isinstance(tr, dict):
284428
fn(gui, tr)
285429
except Exception:
286430
pass
287-
except Exception:
288-
# keep UI responsive even if a plugin tab fails
431+
except Exception as exc:
432+
_log_tab_load_issue(
433+
eid,
434+
f"Echec inattendu lors du chargement de l'onglet moteur '{eid}'",
435+
f"Unexpected failure while loading engine tab '{eid}'",
436+
exc,
437+
)
289438
continue
290439

291440
# Hide the Hello tab if any engine has created a tab
@@ -321,6 +470,8 @@ def show_hello_tab(gui) -> None:
321470
def apply_translations(gui, tr: dict) -> None:
322471
"""Propagate i18n translations to all engines that expose 'apply_i18n(gui, tr)'."""
323472
try:
473+
set_translations(gui, tr)
474+
_refresh_all_engine_translations(get_language_code())
324475
for eid, inst in list(_INSTANCES.items()):
325476
try:
326477
fn = getattr(inst, "apply_i18n", None)

0 commit comments

Comments
 (0)