i151-arena-sdk 0.2.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. i151_arena_sdk-0.2.0/PKG-INFO +93 -0
  2. i151_arena_sdk-0.2.0/README.md +81 -0
  3. i151_arena_sdk-0.2.0/arena_sdk/__init__.py +27 -0
  4. i151_arena_sdk-0.2.0/arena_sdk/cli.py +150 -0
  5. i151_arena_sdk-0.2.0/arena_sdk/features.py +56 -0
  6. i151_arena_sdk-0.2.0/arena_sdk/init_project.py +47 -0
  7. i151_arena_sdk-0.2.0/arena_sdk/loader.py +179 -0
  8. i151_arena_sdk-0.2.0/arena_sdk/opponents.py +132 -0
  9. i151_arena_sdk-0.2.0/arena_sdk/public_examples/basic_bot/__pycache__/bot.cpython-314.pyc +0 -0
  10. i151_arena_sdk-0.2.0/arena_sdk/public_examples/basic_bot/bot.py +51 -0
  11. i151_arena_sdk-0.2.0/arena_sdk/public_examples/starter_bot/__pycache__/bot.cpython-314.pyc +0 -0
  12. i151_arena_sdk-0.2.0/arena_sdk/public_examples/starter_bot/bot.py +7 -0
  13. i151_arena_sdk-0.2.0/arena_sdk/templates/README.md +18 -0
  14. i151_arena_sdk-0.2.0/arena_sdk/templates/bot.py +8 -0
  15. i151_arena_sdk-0.2.0/arena_sdk/templates/gitignore +9 -0
  16. i151_arena_sdk-0.2.0/arena_sdk/templates/requirements.txt +1 -0
  17. i151_arena_sdk-0.2.0/i151_arena_sdk.egg-info/PKG-INFO +93 -0
  18. i151_arena_sdk-0.2.0/i151_arena_sdk.egg-info/SOURCES.txt +32 -0
  19. i151_arena_sdk-0.2.0/i151_arena_sdk.egg-info/dependency_links.txt +1 -0
  20. i151_arena_sdk-0.2.0/i151_arena_sdk.egg-info/entry_points.txt +2 -0
  21. i151_arena_sdk-0.2.0/i151_arena_sdk.egg-info/requires.txt +7 -0
  22. i151_arena_sdk-0.2.0/i151_arena_sdk.egg-info/top_level.txt +1 -0
  23. i151_arena_sdk-0.2.0/pyproject.toml +30 -0
  24. i151_arena_sdk-0.2.0/setup.cfg +4 -0
  25. i151_arena_sdk-0.2.0/tests/test_inference.py +747 -0
  26. i151_arena_sdk-0.2.0/tests/test_init.py +55 -0
  27. i151_arena_sdk-0.2.0/tests/test_loader.py +72 -0
  28. i151_arena_sdk-0.2.0/tests/test_mcts_alphazero.py +213 -0
  29. i151_arena_sdk-0.2.0/tests/test_mcts_bot.py +127 -0
  30. i151_arena_sdk-0.2.0/tests/test_mcts_train.py +327 -0
  31. i151_arena_sdk-0.2.0/tests/test_minmax_search.py +149 -0
  32. i151_arena_sdk-0.2.0/tests/test_ppo_bot.py +221 -0
  33. i151_arena_sdk-0.2.0/tests/test_rl_policy.py +1111 -0
  34. i151_arena_sdk-0.2.0/tests/test_sdk.py +134 -0
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: i151-arena-sdk
3
+ Version: 0.2.0
4
+ Summary: SDK étudiant pour la compétition IA i151
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: i151-engine>=0.1.0
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8.0; extra == "dev"
10
+ Provides-Extra: ml
11
+ Requires-Dist: numpy>=1.26; extra == "ml"
12
+
13
+ # i151-arena-sdk — SDK étudiant
14
+
15
+ Package installable pour développer et tester un bot i151 en local, **sans cloner le dépôt complet**.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ python3 -m venv .venv && source .venv/bin/activate
21
+ pip install i151-arena-sdk
22
+ ```
23
+
24
+ En développement (monorepo) :
25
+
26
+ ```bash
27
+ cd arena/engine && pip install -e ".[dev]"
28
+ cd ../sdk && pip install -e ".[dev]"
29
+ ```
30
+
31
+ ## Démarrage rapide
32
+
33
+ ```bash
34
+ arena init mon-bot
35
+ cd mon-bot
36
+ pip install -r requirements.txt
37
+ arena test bot.py
38
+ arena match bot.py --vs random --seed 42
39
+ ```
40
+
41
+ ## Exemples publics (inclus dans le package)
42
+
43
+ | Alias CLI | Description |
44
+ |-----------|-------------|
45
+ | `starter` | Première action légale |
46
+ | `basic` | Heuristique gloutonne |
47
+
48
+ ```bash
49
+ arena test starter
50
+ arena match basic --vs random --seed 42
51
+ ```
52
+
53
+ Les bots avancés (MinMax, MCTS, RL, PPO) restent dans le monorepo pour le développement interne et ne sont **pas** publiés sur PyPI.
54
+
55
+ ## Interface bot
56
+
57
+ ```python
58
+ from arena_sdk import PlayerView, Action
59
+
60
+ def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
61
+ return legal_actions[0]
62
+ ```
63
+
64
+ ## CLI
65
+
66
+ ```bash
67
+ arena init mon-bot # créer un projet
68
+ arena test bot.py # smoke test vs random
69
+ arena match bot.py --vs random # match complet
70
+ arena match bot.py --vs basic # affronter l'exemple glouton
71
+ arena match bot.py --replay-out replay.json
72
+ ```
73
+
74
+ ## ML (local)
75
+
76
+ ```python
77
+ from arena_sdk.features import encode_view, action_from_index
78
+ ```
79
+
80
+ Voir [`../docs/TUTORIAL.md`](../docs/TUTORIAL.md) pour le guide complet.
81
+
82
+ ## Dépendance
83
+
84
+ Ce package réexporte `PlayerView`, `Action` et `play_match` depuis `i151-engine` (installé automatiquement).
85
+
86
+ ## Publication PyPI
87
+
88
+ ```bash
89
+ cd arena/engine && python -m build && twine upload dist/*
90
+ cd ../sdk && python -m build && twine upload dist/*
91
+ ```
92
+
93
+ Publier `i151-engine` avant `i151-arena-sdk`.
@@ -0,0 +1,81 @@
1
+ # i151-arena-sdk — SDK étudiant
2
+
3
+ Package installable pour développer et tester un bot i151 en local, **sans cloner le dépôt complet**.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ python3 -m venv .venv && source .venv/bin/activate
9
+ pip install i151-arena-sdk
10
+ ```
11
+
12
+ En développement (monorepo) :
13
+
14
+ ```bash
15
+ cd arena/engine && pip install -e ".[dev]"
16
+ cd ../sdk && pip install -e ".[dev]"
17
+ ```
18
+
19
+ ## Démarrage rapide
20
+
21
+ ```bash
22
+ arena init mon-bot
23
+ cd mon-bot
24
+ pip install -r requirements.txt
25
+ arena test bot.py
26
+ arena match bot.py --vs random --seed 42
27
+ ```
28
+
29
+ ## Exemples publics (inclus dans le package)
30
+
31
+ | Alias CLI | Description |
32
+ |-----------|-------------|
33
+ | `starter` | Première action légale |
34
+ | `basic` | Heuristique gloutonne |
35
+
36
+ ```bash
37
+ arena test starter
38
+ arena match basic --vs random --seed 42
39
+ ```
40
+
41
+ Les bots avancés (MinMax, MCTS, RL, PPO) restent dans le monorepo pour le développement interne et ne sont **pas** publiés sur PyPI.
42
+
43
+ ## Interface bot
44
+
45
+ ```python
46
+ from arena_sdk import PlayerView, Action
47
+
48
+ def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
49
+ return legal_actions[0]
50
+ ```
51
+
52
+ ## CLI
53
+
54
+ ```bash
55
+ arena init mon-bot # créer un projet
56
+ arena test bot.py # smoke test vs random
57
+ arena match bot.py --vs random # match complet
58
+ arena match bot.py --vs basic # affronter l'exemple glouton
59
+ arena match bot.py --replay-out replay.json
60
+ ```
61
+
62
+ ## ML (local)
63
+
64
+ ```python
65
+ from arena_sdk.features import encode_view, action_from_index
66
+ ```
67
+
68
+ Voir [`../docs/TUTORIAL.md`](../docs/TUTORIAL.md) pour le guide complet.
69
+
70
+ ## Dépendance
71
+
72
+ Ce package réexporte `PlayerView`, `Action` et `play_match` depuis `i151-engine` (installé automatiquement).
73
+
74
+ ## Publication PyPI
75
+
76
+ ```bash
77
+ cd arena/engine && python -m build && twine upload dist/*
78
+ cd ../sdk && python -m build && twine upload dist/*
79
+ ```
80
+
81
+ Publier `i151-engine` avant `i151-arena-sdk`.
@@ -0,0 +1,27 @@
1
+ """SDK étudiant — façade sur i151_engine."""
2
+
3
+ from i151_engine.models.action import Action, ActionType
4
+ from i151_engine.runner.config import MatchConfig
5
+ from i151_engine.runner.match_runner import MatchResult, play_match
6
+ from i151_engine.view.player_view import (
7
+ MatchContext,
8
+ OpponentState,
9
+ PlayerView,
10
+ TableState,
11
+ TurnState,
12
+ YouState,
13
+ )
14
+
15
+ __all__ = [
16
+ "Action",
17
+ "ActionType",
18
+ "MatchConfig",
19
+ "MatchContext",
20
+ "MatchResult",
21
+ "OpponentState",
22
+ "PlayerView",
23
+ "TableState",
24
+ "TurnState",
25
+ "YouState",
26
+ "play_match",
27
+ ]
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from i151_engine.runner.config import MatchConfig
9
+ from i151_engine.runner.match_runner import play_bots_match, play_match
10
+
11
+ from arena_sdk.init_project import InitProjectError, init_project
12
+ from arena_sdk.loader import load_submission
13
+ from arena_sdk.opponents import (
14
+ list_opponent_aliases,
15
+ resolve_opponent_arg,
16
+ resolve_submission_path,
17
+ )
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(prog="arena", description="CLI locale compétition i151")
22
+ sub = parser.add_subparsers(dest="command", required=True)
23
+
24
+ match_parser = sub.add_parser("match", help="Lancer un match bot vs adversaire")
25
+ _bot_help = f"Bot : {list_opponent_aliases()} ou chemin/bot.py"
26
+ match_parser.add_argument("bot", help=_bot_help)
27
+ match_parser.add_argument(
28
+ "--vs",
29
+ dest="opponent_id",
30
+ default="builtin:random",
31
+ help=f"Adversaire : {list_opponent_aliases()} (défaut: builtin:random)",
32
+ )
33
+ match_parser.add_argument("--seed", type=int, default=42)
34
+ match_parser.add_argument("--target-score", type=int, default=151)
35
+ match_parser.add_argument(
36
+ "--cards-per-player",
37
+ type=int,
38
+ default=8,
39
+ help="Nombre de cartes distribuées à chaque joueur en début de manche (défaut 8)",
40
+ )
41
+ match_parser.add_argument("--json", action="store_true", help="Sortie JSON (résultat + replay)")
42
+ match_parser.add_argument("--replay-out", type=Path, help="Écrire le replay JSON dans un fichier")
43
+
44
+ test_parser = sub.add_parser("test", help="Smoke test : bot vs builtin:random")
45
+ test_parser.add_argument("bot", help=_bot_help)
46
+ test_parser.add_argument("--seed", type=int, default=42)
47
+
48
+ init_parser = sub.add_parser("init", help="Créer un nouveau projet bot (bot.py + requirements.txt)")
49
+ init_parser.add_argument(
50
+ "directory",
51
+ nargs="?",
52
+ default="mon-bot",
53
+ help="Dossier cible (défaut: mon-bot)",
54
+ )
55
+ init_parser.add_argument(
56
+ "--force",
57
+ action="store_true",
58
+ help="Écraser les fichiers template existants",
59
+ )
60
+
61
+ args = parser.parse_args(argv)
62
+
63
+ if args.command == "match":
64
+ return _cmd_match(args)
65
+ if args.command == "test":
66
+ return _cmd_test(args)
67
+ if args.command == "init":
68
+ return _cmd_init(args)
69
+ return 1
70
+
71
+
72
+ def _cmd_match(args: argparse.Namespace) -> int:
73
+ bot_path = resolve_submission_path(args.bot)
74
+ bot = load_submission(bot_path)
75
+ challenger_dir = bot_path.parent
76
+ config = MatchConfig(
77
+ seed=args.seed,
78
+ target_score=args.target_score,
79
+ cards_per_player=args.cards_per_player,
80
+ )
81
+ opponent = resolve_opponent_arg(args.opponent_id)
82
+ result = play_bots_match(
83
+ bot,
84
+ opponent.bot,
85
+ config,
86
+ challenger_name=challenger_dir.name,
87
+ opponent_name=opponent.name,
88
+ opponent_id=opponent.opponent_id,
89
+ challenger_dir=challenger_dir,
90
+ opponent_dir=opponent.submission_dir,
91
+ )
92
+
93
+ if args.replay_out and result.replay is not None:
94
+ args.replay_out.write_text(result.replay.to_json(indent=2), encoding="utf-8")
95
+
96
+ if args.json:
97
+ payload = {
98
+ "winner_id": result.winner_id,
99
+ "opponent_id": result.opponent_id,
100
+ "reason": result.reason,
101
+ "final_scores": result.final_scores,
102
+ "rounds_played": result.rounds_played,
103
+ "steps_played": result.steps_played,
104
+ "replay": result.replay.to_dict() if result.replay else None,
105
+ }
106
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
107
+ else:
108
+ print(f"Winner: {result.winner_id} ({result.reason})")
109
+ print(f"Scores: {result.final_scores}")
110
+ print(f"Rounds: {result.rounds_played}, steps: {result.steps_played}")
111
+
112
+ return 0 if result.reason == "normal" else 2
113
+
114
+
115
+ def _cmd_init(args: argparse.Namespace) -> int:
116
+ try:
117
+ created = init_project(Path(args.directory), force=args.force)
118
+ except InitProjectError as exc:
119
+ print(str(exc), file=sys.stderr)
120
+ return 1
121
+ dest = Path(args.directory).resolve()
122
+ print(f"Projet créé dans {dest}")
123
+ for path in created:
124
+ print(f" + {path.name}")
125
+ print()
126
+ print("Prochaines étapes :")
127
+ print(f" cd {dest}")
128
+ print(" python3 -m venv .venv && source .venv/bin/activate")
129
+ print(" pip install -r requirements.txt")
130
+ print(" arena test bot.py")
131
+ return 0
132
+
133
+
134
+ def _cmd_test(args: argparse.Namespace) -> int:
135
+ bot = load_submission(resolve_submission_path(args.bot))
136
+ result = play_match(
137
+ bot,
138
+ "builtin:random",
139
+ MatchConfig(seed=args.seed, target_score=151, max_rounds=40),
140
+ challenger_name="test",
141
+ )
142
+ if result.reason != "normal":
143
+ print(f"Échec smoke test : {result.reason}", file=sys.stderr)
144
+ return 1
145
+ print("OK — match terminé sans forfait")
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from i151_engine.models.action import Action
4
+ from i151_engine.view.player_view import PlayerView
5
+
6
+
7
+ def encode_view(view: PlayerView, legal_actions: list[Action]) -> list[float]:
8
+ """Encodage de référence PlayerView → vecteur (usage ML local).
9
+
10
+ Schéma minimal v0 : scores, tailles de mains, état table, one-hot actions légales.
11
+ """
12
+ features: list[float] = [
13
+ float(view.you.total_score),
14
+ float(view.you.hand_size),
15
+ float(view.you.hand_points),
16
+ float(view.you.consecutive_round_wins),
17
+ 1.0 if view.you.is_chaining else 0.0,
18
+ float(view.table.bank_size),
19
+ float(view.table.played_pile_size),
20
+ 1.0 if view.table.table_empty else 0.0,
21
+ 1.0 if view.table.ace_effect_active else 0.0,
22
+ 1.0 if view.table.must_choose_suit else 0.0,
23
+ float(view.match.round_number),
24
+ float(view.turn.step_index),
25
+ ]
26
+
27
+ if view.opponents:
28
+ opp = view.opponents[0]
29
+ features.extend(
30
+ [
31
+ float(opp.total_score),
32
+ float(opp.hand_size),
33
+ float(opp.consecutive_round_wins),
34
+ ]
35
+ )
36
+ else:
37
+ features.extend([0.0, 0.0, 0.0])
38
+
39
+ for action in legal_actions:
40
+ features.append(float(hash((action.type.value, action.card, action.chosen_suit)) % 1000) / 1000.0)
41
+
42
+ return features
43
+
44
+
45
+ def action_index(legal_actions: list[Action], action: Action) -> int:
46
+ try:
47
+ return legal_actions.index(action)
48
+ except ValueError:
49
+ return 0
50
+
51
+
52
+ def action_from_index(legal_actions: list[Action], index: int) -> Action:
53
+ if not legal_actions:
54
+ raise ValueError("legal_actions vide")
55
+ clamped = max(0, min(index, len(legal_actions) - 1))
56
+ return legal_actions[clamped]
@@ -0,0 +1,47 @@
1
+ """Génération d'un projet bot étudiant via `arena init`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ from pathlib import Path
7
+
8
+ _TEMPLATE_FILES = {
9
+ "bot.py": "bot.py",
10
+ "requirements.txt": "requirements.txt",
11
+ "README.md": "README.md",
12
+ ".gitignore": "gitignore",
13
+ }
14
+
15
+
16
+ class InitProjectError(Exception):
17
+ pass
18
+
19
+
20
+ def _template_text(filename: str) -> str:
21
+ package = importlib.resources.files("arena_sdk").joinpath("templates", filename)
22
+ return package.read_text(encoding="utf-8")
23
+
24
+
25
+ def init_project(target: Path, *, force: bool = False) -> list[Path]:
26
+ """Crée un dossier de projet bot avec bot.py, requirements.txt, README.md."""
27
+ dest = target.resolve()
28
+ if dest.exists():
29
+ if not dest.is_dir():
30
+ raise InitProjectError(f"La cible existe et n'est pas un dossier : {dest}")
31
+ conflicts = [name for name in _TEMPLATE_FILES if (dest / name).exists()]
32
+ if conflicts and not force:
33
+ raise InitProjectError(
34
+ f"Fichiers déjà présents dans {dest} : {', '.join(conflicts)}. "
35
+ "Utilisez un autre dossier ou --force."
36
+ )
37
+ else:
38
+ dest.mkdir(parents=True)
39
+
40
+ created: list[Path] = []
41
+ for dest_name, template_name in _TEMPLATE_FILES.items():
42
+ out = dest / dest_name
43
+ if out.exists() and not force:
44
+ continue
45
+ out.write_text(_template_text(template_name), encoding="utf-8")
46
+ created.append(out)
47
+ return created
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import importlib.util
5
+ import sys
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ from i151_engine.bots.protocol import Bot
10
+ from i151_engine.models.action import Action
11
+ from i151_engine.view.player_view import PlayerView
12
+
13
+ _KNOWN_PACKAGES = frozenset(
14
+ {
15
+ "arena_sdk",
16
+ "i151_engine",
17
+ "numpy",
18
+ "sklearn",
19
+ "joblib",
20
+ "onnxruntime",
21
+ "torch",
22
+ "pandas",
23
+ }
24
+ )
25
+
26
+
27
+ class SubmissionBot:
28
+ """Adapte un module bot.py étudiant (setup / decide / teardown) au protocole Bot."""
29
+
30
+ def __init__(self, module: ModuleType, submission_dir: Path) -> None:
31
+ self._module = module
32
+ self._submission_dir = submission_dir
33
+
34
+ def setup(self, submission_dir: Path) -> None:
35
+ del submission_dir
36
+ setup = getattr(self._module, "setup", None)
37
+ if callable(setup):
38
+ setup(self._submission_dir)
39
+
40
+ def decide(
41
+ self,
42
+ view: PlayerView,
43
+ legal_actions: list[Action],
44
+ time_left_ms: int,
45
+ ) -> Action:
46
+ decide = self._module.decide
47
+ return decide(view, legal_actions, time_left_ms)
48
+
49
+ def teardown(self) -> None:
50
+ teardown = getattr(self._module, "teardown", None)
51
+ if callable(teardown):
52
+ teardown()
53
+
54
+
55
+ def _local_import_roots(source: str) -> set[str]:
56
+ try:
57
+ tree = ast.parse(source)
58
+ except SyntaxError as exc:
59
+ raise ImportError(f"Syntaxe bot.py invalide : {exc}") from exc
60
+
61
+ allowed = _KNOWN_PACKAGES | sys.stdlib_module_names
62
+ local: set[str] = set()
63
+ for node in ast.walk(tree):
64
+ if isinstance(node, ast.Import):
65
+ for alias in node.names:
66
+ root = alias.name.split(".")[0]
67
+ if root not in allowed:
68
+ local.add(root)
69
+ elif isinstance(node, ast.ImportFrom):
70
+ if node.level and node.level > 0:
71
+ continue
72
+ if node.module:
73
+ root = node.module.split(".")[0]
74
+ if root not in allowed:
75
+ local.add(root)
76
+ return local
77
+
78
+
79
+ def _collect_local_module_closure(
80
+ submission_dir: Path,
81
+ extract_root: Path | None,
82
+ seeds: set[str],
83
+ ) -> list[str]:
84
+ """Modules locaux transitifs, ordre de chargement (dépendances d'abord)."""
85
+ order: list[str] = []
86
+ seen: set[str] = set()
87
+
88
+ def visit(name: str) -> None:
89
+ if name in seen:
90
+ return
91
+ seen.add(name)
92
+ py_file = _find_local_module(submission_dir, extract_root, name)
93
+ if py_file is not None:
94
+ source = py_file.read_text(encoding="utf-8")
95
+ for dep in sorted(_local_import_roots(source)):
96
+ visit(dep)
97
+ order.append(name)
98
+
99
+ for seed in sorted(seeds):
100
+ visit(seed)
101
+ return order
102
+
103
+
104
+ def _find_local_module(
105
+ submission_dir: Path,
106
+ extract_root: Path | None,
107
+ module: str,
108
+ ) -> Path | None:
109
+ sibling = submission_dir / f"{module}.py"
110
+ if sibling.is_file():
111
+ return sibling
112
+ if extract_root is not None:
113
+ matches = sorted(extract_root.rglob(f"{module}.py"))
114
+ if matches:
115
+ return matches[0]
116
+ return None
117
+
118
+
119
+ def _preload_local_modules(
120
+ *,
121
+ submission_dir: Path,
122
+ extract_root: Path | None,
123
+ module_names: list[str],
124
+ ) -> None:
125
+ for mod_name in module_names:
126
+ py_file = _find_local_module(submission_dir, extract_root, mod_name)
127
+ if py_file is None:
128
+ raise ModuleNotFoundError(
129
+ f"No module named '{mod_name}' — ajoutez {mod_name}.py "
130
+ f"dans le même dossier que bot.py"
131
+ )
132
+ if mod_name in sys.modules:
133
+ continue
134
+ spec = importlib.util.spec_from_file_location(mod_name, py_file)
135
+ if spec is None or spec.loader is None:
136
+ raise ImportError(f"Impossible de charger {py_file}")
137
+ module = importlib.util.module_from_spec(spec)
138
+ sys.modules[mod_name] = module
139
+ spec.loader.exec_module(module)
140
+
141
+
142
+ def load_submission(bot_path: Path, *, extract_root: Path | None = None) -> Bot:
143
+ """Charge bot.py depuis un fichier ou un dossier de soumission."""
144
+ path = bot_path.resolve()
145
+ if path.is_dir():
146
+ submission_dir = path
147
+ module_path = path / "bot.py"
148
+ else:
149
+ submission_dir = path.parent
150
+ module_path = path
151
+
152
+ if not module_path.is_file():
153
+ raise FileNotFoundError(f"bot.py introuvable : {module_path}")
154
+
155
+ resolved_extract = extract_root.resolve() if extract_root is not None else None
156
+ bot_source = module_path.read_text(encoding="utf-8")
157
+ local_modules = _collect_local_module_closure(
158
+ submission_dir,
159
+ resolved_extract,
160
+ _local_import_roots(bot_source),
161
+ )
162
+ _preload_local_modules(
163
+ submission_dir=submission_dir,
164
+ extract_root=resolved_extract,
165
+ module_names=local_modules,
166
+ )
167
+
168
+ spec = importlib.util.spec_from_file_location("student_bot", module_path)
169
+ if spec is None or spec.loader is None:
170
+ raise ImportError(f"Impossible de charger {module_path}")
171
+
172
+ module = importlib.util.module_from_spec(spec)
173
+ sys.modules["student_bot"] = module
174
+ spec.loader.exec_module(module)
175
+
176
+ if not callable(getattr(module, "decide", None)):
177
+ raise AttributeError("bot.py doit définir une fonction decide(view, legal_actions, time_left_ms)")
178
+
179
+ return SubmissionBot(module, submission_dir)