cogames 0.3.49__py3-none-any.whl → 0.3.64__py3-none-any.whl
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.
- cogames/cli/client.py +60 -6
- cogames/cli/docsync/__init__.py +0 -0
- cogames/cli/docsync/_nb_md_directive_processing.py +180 -0
- cogames/cli/docsync/_nb_md_sync.py +103 -0
- cogames/cli/docsync/_nb_py_sync.py +122 -0
- cogames/cli/docsync/_three_way_sync.py +115 -0
- cogames/cli/docsync/_utils.py +76 -0
- cogames/cli/docsync/docsync.py +156 -0
- cogames/cli/leaderboard.py +112 -28
- cogames/cli/mission.py +64 -53
- cogames/cli/policy.py +46 -10
- cogames/cli/submit.py +268 -67
- cogames/cogs_vs_clips/cog.py +79 -0
- cogames/cogs_vs_clips/cogs_vs_clips_mapgen.md +19 -16
- cogames/cogs_vs_clips/cogsguard_reward_variants.py +153 -0
- cogames/cogs_vs_clips/cogsguard_tutorial.py +56 -0
- cogames/cogs_vs_clips/evals/README.md +10 -16
- cogames/cogs_vs_clips/evals/cogsguard_evals.py +81 -0
- cogames/cogs_vs_clips/evals/diagnostic_evals.py +49 -444
- cogames/cogs_vs_clips/evals/difficulty_variants.py +13 -326
- cogames/cogs_vs_clips/evals/integrated_evals.py +5 -45
- cogames/cogs_vs_clips/evals/spanning_evals.py +9 -180
- cogames/cogs_vs_clips/mission.py +187 -146
- cogames/cogs_vs_clips/missions.py +46 -137
- cogames/cogs_vs_clips/procedural.py +8 -8
- cogames/cogs_vs_clips/sites.py +107 -3
- cogames/cogs_vs_clips/stations.py +198 -186
- cogames/cogs_vs_clips/tutorial_missions.py +1 -1
- cogames/cogs_vs_clips/variants.py +25 -476
- cogames/device.py +13 -1
- cogames/{policy/scripted_agent/README.md → docs/SCRIPTED_AGENT.md} +82 -58
- cogames/evaluate.py +18 -30
- cogames/main.py +1434 -243
- cogames/maps/canidate1_1000.map +1 -1
- cogames/maps/canidate1_1000_stations.map +2 -2
- cogames/maps/canidate1_500.map +1 -1
- cogames/maps/canidate1_500_stations.map +2 -2
- cogames/maps/canidate2_1000.map +1 -1
- cogames/maps/canidate2_1000_stations.map +2 -2
- cogames/maps/canidate2_500.map +1 -1
- cogames/maps/canidate2_500_stations.map +2 -2
- cogames/maps/canidate3_1000.map +1 -1
- cogames/maps/canidate3_1000_stations.map +2 -2
- cogames/maps/canidate3_500.map +1 -1
- cogames/maps/canidate3_500_stations.map +2 -2
- cogames/maps/canidate4_500.map +1 -1
- cogames/maps/canidate4_500_stations.map +2 -2
- cogames/maps/cave_base_50.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_agile.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_agile_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_charge_up.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_charge_up_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_navigation1.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_navigation1_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_navigation2.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_navigation2_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_navigation3.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_navigation3_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_near.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_search.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_chest_search_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_extract_lab.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_extract_lab_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_memory.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_memory_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_radial.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_radial_hard.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_resource_lab.map +2 -2
- cogames/maps/diagnostic_evals/diagnostic_unclip.map +2 -2
- cogames/maps/evals/eval_balanced_spread.map +9 -5
- cogames/maps/evals/eval_clip_oxygen.map +9 -5
- cogames/maps/evals/eval_collect_resources.map +9 -5
- cogames/maps/evals/eval_collect_resources_hard.map +9 -5
- cogames/maps/evals/eval_collect_resources_medium.map +9 -5
- cogames/maps/evals/eval_divide_and_conquer.map +9 -5
- cogames/maps/evals/eval_energy_starved.map +9 -5
- cogames/maps/evals/eval_multi_coordinated_collect_hard.map +9 -5
- cogames/maps/evals/eval_oxygen_bottleneck.map +9 -5
- cogames/maps/evals/eval_single_use_world.map +9 -5
- cogames/maps/evals/extractor_hub_100x100.map +9 -5
- cogames/maps/evals/extractor_hub_30x30.map +9 -5
- cogames/maps/evals/extractor_hub_50x50.map +9 -5
- cogames/maps/evals/extractor_hub_70x70.map +9 -5
- cogames/maps/evals/extractor_hub_80x80.map +9 -5
- cogames/maps/machina_100_stations.map +2 -2
- cogames/maps/machina_200_stations.map +2 -2
- cogames/maps/machina_200_stations_small.map +2 -2
- cogames/maps/machina_eval_exp01.map +2 -2
- cogames/maps/machina_eval_template_large.map +2 -2
- cogames/maps/machinatrainer4agents.map +2 -2
- cogames/maps/machinatrainer4agentsbase.map +2 -2
- cogames/maps/machinatrainerbig.map +2 -2
- cogames/maps/machinatrainersmall.map +2 -2
- cogames/maps/planky_evals/aligner_avoid_aoe.map +28 -0
- cogames/maps/planky_evals/aligner_full_cycle.map +28 -0
- cogames/maps/planky_evals/aligner_gear.map +24 -0
- cogames/maps/planky_evals/aligner_hearts.map +24 -0
- cogames/maps/planky_evals/aligner_junction.map +26 -0
- cogames/maps/planky_evals/exploration_distant.map +28 -0
- cogames/maps/planky_evals/maze.map +32 -0
- cogames/maps/planky_evals/miner_best_resource.map +26 -0
- cogames/maps/planky_evals/miner_deposit.map +24 -0
- cogames/maps/planky_evals/miner_extract.map +26 -0
- cogames/maps/planky_evals/miner_full_cycle.map +28 -0
- cogames/maps/planky_evals/miner_gear.map +24 -0
- cogames/maps/planky_evals/multi_role.map +28 -0
- cogames/maps/planky_evals/resource_chain.map +30 -0
- cogames/maps/planky_evals/scout_explore.map +32 -0
- cogames/maps/planky_evals/scout_gear.map +24 -0
- cogames/maps/planky_evals/scrambler_full_cycle.map +28 -0
- cogames/maps/planky_evals/scrambler_gear.map +24 -0
- cogames/maps/planky_evals/scrambler_target.map +26 -0
- cogames/maps/planky_evals/stuck_corridor.map +32 -0
- cogames/maps/planky_evals/survive_retreat.map +26 -0
- cogames/maps/training_facility_clipped.map +2 -2
- cogames/maps/training_facility_open_1.map +2 -2
- cogames/maps/training_facility_open_2.map +2 -2
- cogames/maps/training_facility_open_3.map +2 -2
- cogames/maps/training_facility_tight_4.map +2 -2
- cogames/maps/training_facility_tight_5.map +2 -2
- cogames/maps/vanilla_large.map +2 -2
- cogames/maps/vanilla_small.map +2 -2
- cogames/pickup.py +183 -0
- cogames/play.py +166 -33
- cogames/policy/chaos_monkey.py +54 -0
- cogames/policy/nim_agents/__init__.py +27 -10
- cogames/policy/nim_agents/agents.py +121 -60
- cogames/policy/nim_agents/thinky_eval.py +35 -222
- cogames/policy/pufferlib_policy.py +67 -32
- cogames/policy/starter_agent.py +184 -0
- cogames/policy/trainable_policy_template.py +4 -1
- cogames/train.py +51 -13
- cogames/verbose.py +2 -2
- cogames-0.3.64.dist-info/METADATA +1842 -0
- cogames-0.3.64.dist-info/RECORD +159 -0
- cogames-0.3.64.dist-info/licenses/LICENSE +21 -0
- cogames-0.3.64.dist-info/top_level.txt +2 -0
- metta_alo/__init__.py +0 -0
- metta_alo/job_specs.py +17 -0
- metta_alo/policy.py +16 -0
- metta_alo/pure_single_episode_runner.py +75 -0
- metta_alo/py.typed +0 -0
- metta_alo/rollout.py +322 -0
- metta_alo/scoring.py +168 -0
- cogames/maps/diagnostic_evals/diagnostic_assembler_near.map +0 -49
- cogames/maps/diagnostic_evals/diagnostic_assembler_search.map +0 -49
- cogames/maps/diagnostic_evals/diagnostic_assembler_search_hard.map +0 -89
- cogames/policy/nim_agents/common.nim +0 -887
- cogames/policy/nim_agents/install.sh +0 -1
- cogames/policy/nim_agents/ladybug_agent.nim +0 -984
- cogames/policy/nim_agents/nim_agents.nim +0 -55
- cogames/policy/nim_agents/nim_agents.nims +0 -14
- cogames/policy/nim_agents/nimby.lock +0 -3
- cogames/policy/nim_agents/racecar_agents.nim +0 -884
- cogames/policy/nim_agents/random_agents.nim +0 -68
- cogames/policy/nim_agents/test_agents.py +0 -53
- cogames/policy/nim_agents/thinky_agents.nim +0 -717
- cogames/policy/scripted_agent/baseline_agent.py +0 -1049
- cogames/policy/scripted_agent/demo_policy.py +0 -244
- cogames/policy/scripted_agent/pathfinding.py +0 -126
- cogames/policy/scripted_agent/starter_agent.py +0 -136
- cogames/policy/scripted_agent/types.py +0 -235
- cogames/policy/scripted_agent/unclipping_agent.py +0 -476
- cogames/policy/scripted_agent/utils.py +0 -385
- cogames-0.3.49.dist-info/METADATA +0 -406
- cogames-0.3.49.dist-info/RECORD +0 -136
- cogames-0.3.49.dist-info/top_level.txt +0 -1
- {cogames-0.3.49.dist-info → cogames-0.3.64.dist-info}/WHEEL +0 -0
- {cogames-0.3.49.dist-info → cogames-0.3.64.dist-info}/entry_points.txt +0 -0
cogames/cli/client.py
CHANGED
|
@@ -13,6 +13,26 @@ from cogames.cli.login import CoGamesAuthenticator
|
|
|
13
13
|
T = TypeVar("T")
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def fetch_default_season(server_url: str) -> SeasonInfo:
|
|
17
|
+
with httpx.Client(base_url=server_url, timeout=10.0) as client:
|
|
18
|
+
resp = client.get("/tournament/seasons")
|
|
19
|
+
resp.raise_for_status()
|
|
20
|
+
seasons = resp.json()
|
|
21
|
+
for s in seasons:
|
|
22
|
+
if s.get("is_default"):
|
|
23
|
+
return SeasonInfo.model_validate(s)
|
|
24
|
+
if seasons:
|
|
25
|
+
return SeasonInfo.model_validate(seasons[0])
|
|
26
|
+
raise RuntimeError("No seasons available from server")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fetch_season_info(server_url: str, season_name: str) -> SeasonInfo:
|
|
30
|
+
with httpx.Client(base_url=server_url, timeout=10.0) as client:
|
|
31
|
+
resp = client.get(f"/tournament/seasons/{season_name}")
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
return SeasonInfo.model_validate(resp.json())
|
|
34
|
+
|
|
35
|
+
|
|
16
36
|
class PolicyVersionInfo(BaseModel):
|
|
17
37
|
id: uuid.UUID
|
|
18
38
|
policy_id: uuid.UUID
|
|
@@ -26,14 +46,27 @@ class PolicyVersionInfo(BaseModel):
|
|
|
26
46
|
class PoolInfo(BaseModel):
|
|
27
47
|
name: str
|
|
28
48
|
description: str
|
|
49
|
+
config_id: str | None = None
|
|
29
50
|
|
|
30
51
|
|
|
31
52
|
class SeasonInfo(BaseModel):
|
|
32
53
|
name: str
|
|
54
|
+
version: int
|
|
55
|
+
canonical: bool
|
|
33
56
|
summary: str
|
|
57
|
+
entry_pool: str | None = None
|
|
58
|
+
leaderboard_pool: str | None = None
|
|
59
|
+
is_default: bool = False
|
|
34
60
|
pools: list[PoolInfo]
|
|
35
61
|
|
|
36
62
|
|
|
63
|
+
class SeasonVersionInfo(BaseModel):
|
|
64
|
+
version: int
|
|
65
|
+
canonical: bool
|
|
66
|
+
disabled_at: str | None
|
|
67
|
+
created_at: str
|
|
68
|
+
|
|
69
|
+
|
|
37
70
|
class LeaderboardEntry(BaseModel):
|
|
38
71
|
rank: int
|
|
39
72
|
policy: PolicyVersionSummary
|
|
@@ -147,8 +180,18 @@ class TournamentServerClient:
|
|
|
147
180
|
def get_seasons(self) -> list[SeasonInfo]:
|
|
148
181
|
return self._get("/tournament/seasons", list[SeasonInfo])
|
|
149
182
|
|
|
150
|
-
def
|
|
151
|
-
return self._get(f"/tournament/
|
|
183
|
+
def get_config(self, config_id: str) -> dict[str, Any]:
|
|
184
|
+
return self._get(f"/tournament/configs/{config_id}")
|
|
185
|
+
|
|
186
|
+
def get_season_versions(self, season_name: str) -> list[SeasonVersionInfo]:
|
|
187
|
+
return self._get(f"/tournament/seasons/{season_name}/versions", list[SeasonVersionInfo])
|
|
188
|
+
|
|
189
|
+
def get_leaderboard(self, season_name: str, include_hidden_seasons: bool = False) -> list[LeaderboardEntry]:
|
|
190
|
+
return self._get(
|
|
191
|
+
f"/tournament/seasons/{season_name}/leaderboard",
|
|
192
|
+
list[LeaderboardEntry],
|
|
193
|
+
params={"include_hidden": "true"} if include_hidden_seasons else None,
|
|
194
|
+
)
|
|
152
195
|
|
|
153
196
|
def get_my_policy_versions(
|
|
154
197
|
self,
|
|
@@ -182,21 +225,32 @@ class TournamentServerClient:
|
|
|
182
225
|
json={"policy_version_id": str(policy_version_id)},
|
|
183
226
|
)
|
|
184
227
|
|
|
185
|
-
def get_season_policies(
|
|
228
|
+
def get_season_policies(
|
|
229
|
+
self, season_name: str, mine: bool = False, include_hidden_seasons: bool = False
|
|
230
|
+
) -> list[SeasonPolicyEntry]:
|
|
231
|
+
params: dict[str, str] = {}
|
|
232
|
+
if mine:
|
|
233
|
+
params["mine"] = "true"
|
|
234
|
+
if include_hidden_seasons:
|
|
235
|
+
params["include_hidden"] = "true"
|
|
236
|
+
|
|
186
237
|
return self._get(
|
|
187
238
|
f"/tournament/seasons/{season_name}/policies",
|
|
188
239
|
list[SeasonPolicyEntry],
|
|
189
|
-
params=
|
|
240
|
+
params=params if params else None,
|
|
190
241
|
)
|
|
191
242
|
|
|
192
243
|
def get_presigned_upload_url(self) -> dict[str, Any]:
|
|
193
244
|
return self._post("/stats/policies/submit/presigned-url", timeout=60.0)
|
|
194
245
|
|
|
195
|
-
def complete_policy_upload(self, upload_id: str, name: str) -> dict[str, Any]:
|
|
246
|
+
def complete_policy_upload(self, upload_id: str, name: str, season: str | None = None) -> dict[str, Any]:
|
|
247
|
+
payload: dict[str, Any] = {"upload_id": upload_id, "name": name}
|
|
248
|
+
if season is not None:
|
|
249
|
+
payload["season"] = season
|
|
196
250
|
return self._post(
|
|
197
251
|
"/stats/policies/submit/complete",
|
|
198
252
|
timeout=120.0,
|
|
199
|
-
json=
|
|
253
|
+
json=payload,
|
|
200
254
|
)
|
|
201
255
|
|
|
202
256
|
def update_policy_version_tags(self, policy_version_id: uuid.UUID, tags: dict[str, str]) -> dict[str, Any]:
|
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert notebook to markdown using nbconvert.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import nbformat
|
|
6
|
+
from nbconvert.preprocessors import Preprocessor
|
|
7
|
+
|
|
8
|
+
_COLAB_URL_BASE = "https://colab.research.google.com/github/Metta-AI/cogames/blob/main"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DirectivePreprocessor(Preprocessor):
|
|
12
|
+
"""Process directives in code cells."""
|
|
13
|
+
|
|
14
|
+
def preprocess_cell(self, cell, resources, index):
|
|
15
|
+
del index # unused
|
|
16
|
+
|
|
17
|
+
# Handle <<colab-link>> placeholder in markdown cells
|
|
18
|
+
if cell.cell_type == "markdown" and "<<colab-link>>" in cell.source:
|
|
19
|
+
notebook_relpath = resources["notebook_relpath"]
|
|
20
|
+
colab_url = f"{_COLAB_URL_BASE}/{notebook_relpath}"
|
|
21
|
+
cell.source = cell.source.replace("<<colab-link>>", colab_url)
|
|
22
|
+
return cell, resources
|
|
23
|
+
|
|
24
|
+
if cell.cell_type != "code":
|
|
25
|
+
return cell, resources
|
|
26
|
+
|
|
27
|
+
source = cell.source
|
|
28
|
+
if not source.strip():
|
|
29
|
+
return cell, resources
|
|
30
|
+
|
|
31
|
+
lines = source.split("\n")
|
|
32
|
+
first_line = lines[0].strip()
|
|
33
|
+
|
|
34
|
+
if not (first_line.startswith("# <<") and ">>" in first_line):
|
|
35
|
+
return cell, resources
|
|
36
|
+
|
|
37
|
+
# Extract directive from "# <<directive>>" format
|
|
38
|
+
directive = first_line[4 : first_line.index(">>")].strip()
|
|
39
|
+
rest_source = "\n".join(lines[1:])
|
|
40
|
+
|
|
41
|
+
if directive == "hide":
|
|
42
|
+
# Mark cell for removal
|
|
43
|
+
cell.source = ""
|
|
44
|
+
cell.outputs = []
|
|
45
|
+
cell.metadata["remove_cell"] = True
|
|
46
|
+
|
|
47
|
+
elif directive == "hide-input":
|
|
48
|
+
# Keep outputs, hide code using TagRemovePreprocessor
|
|
49
|
+
cell.source = rest_source
|
|
50
|
+
cell.metadata.setdefault("tags", []).append("remove_input")
|
|
51
|
+
|
|
52
|
+
elif directive == "hide-output":
|
|
53
|
+
# Keep code, hide outputs
|
|
54
|
+
cell.source = rest_source
|
|
55
|
+
cell.outputs = []
|
|
56
|
+
|
|
57
|
+
elif directive == "collapse-input":
|
|
58
|
+
# Will be handled in post-processing
|
|
59
|
+
cell.source = rest_source
|
|
60
|
+
cell.metadata["collapse_input"] = True
|
|
61
|
+
|
|
62
|
+
elif directive == "collapse-output":
|
|
63
|
+
# Will be handled in post-processing
|
|
64
|
+
cell.source = rest_source
|
|
65
|
+
cell.metadata["collapse_output"] = True
|
|
66
|
+
|
|
67
|
+
return cell, resources
|
|
68
|
+
|
|
69
|
+
def preprocess(self, nb, resources):
|
|
70
|
+
# First pass: process directives
|
|
71
|
+
nb, resources = super().preprocess(nb=nb, resources=resources)
|
|
72
|
+
|
|
73
|
+
# Second pass: remove cells marked for removal
|
|
74
|
+
nb.cells = [cell for cell in nb.cells if not cell.get("metadata", {}).get("remove_cell", False)]
|
|
75
|
+
|
|
76
|
+
# Also remove empty code cells (from hide-input with no output)
|
|
77
|
+
nb.cells = [
|
|
78
|
+
cell for cell in nb.cells if not (cell.cell_type == "code" and not cell.source.strip() and not cell.outputs)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
return nb, resources
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def directive_post_process(*, md: str, nb: nbformat.NotebookNode) -> str:
|
|
85
|
+
"""Wrap collapsed cells in <details> tags."""
|
|
86
|
+
|
|
87
|
+
# Build list of (collapse_input, collapse_output) for each code cell that has visible code.
|
|
88
|
+
# Skip cells with "remove_input" tag since their code block is removed from markdown.
|
|
89
|
+
collapse_info = []
|
|
90
|
+
for cell in nb.cells:
|
|
91
|
+
if cell.cell_type == "code" and cell.source.strip():
|
|
92
|
+
meta = cell.get("metadata", {})
|
|
93
|
+
tags = meta.get("tags", [])
|
|
94
|
+
# Skip cells whose input is removed (no code block in markdown)
|
|
95
|
+
if "remove_input" in tags:
|
|
96
|
+
continue
|
|
97
|
+
collapse_info.append(
|
|
98
|
+
(
|
|
99
|
+
meta.get("collapse_input", False),
|
|
100
|
+
meta.get("collapse_output", False),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if not any(collapse_input or collapse_output for collapse_input, collapse_output in collapse_info):
|
|
105
|
+
return md
|
|
106
|
+
|
|
107
|
+
# Process markdown line by line
|
|
108
|
+
# nbconvert outputs: ```python ... ``` for code, then indented lines for output
|
|
109
|
+
lines = md.split("\n")
|
|
110
|
+
result = []
|
|
111
|
+
code_cell_idx = 0
|
|
112
|
+
i = 0
|
|
113
|
+
|
|
114
|
+
while i < len(lines):
|
|
115
|
+
line = lines[i]
|
|
116
|
+
|
|
117
|
+
# Detect start of code block
|
|
118
|
+
if line.startswith("```python"):
|
|
119
|
+
code_block_lines = [line]
|
|
120
|
+
i += 1
|
|
121
|
+
|
|
122
|
+
# Collect code block
|
|
123
|
+
while i < len(lines) and not (lines[i].startswith("```") and len(lines[i].strip()) == 3):
|
|
124
|
+
code_block_lines.append(lines[i])
|
|
125
|
+
i += 1
|
|
126
|
+
if i < len(lines):
|
|
127
|
+
code_block_lines.append(lines[i]) # closing ```
|
|
128
|
+
i += 1
|
|
129
|
+
|
|
130
|
+
# Check for collapse_input
|
|
131
|
+
collapse_input = False
|
|
132
|
+
collapse_output = False
|
|
133
|
+
if code_cell_idx < len(collapse_info):
|
|
134
|
+
collapse_input, collapse_output = collapse_info[code_cell_idx]
|
|
135
|
+
|
|
136
|
+
# Output code block (possibly wrapped)
|
|
137
|
+
if collapse_input:
|
|
138
|
+
result.append("<details>")
|
|
139
|
+
result.append("<summary>Code</summary>")
|
|
140
|
+
result.append("")
|
|
141
|
+
result.extend(code_block_lines)
|
|
142
|
+
result.append("")
|
|
143
|
+
result.append("</details>")
|
|
144
|
+
else:
|
|
145
|
+
result.extend(code_block_lines)
|
|
146
|
+
|
|
147
|
+
# Collect output (indented lines or empty lines between code and next content)
|
|
148
|
+
output_lines = []
|
|
149
|
+
while i < len(lines):
|
|
150
|
+
# Output lines are typically indented with 4 spaces
|
|
151
|
+
if lines[i].startswith(" ") or lines[i] == "":
|
|
152
|
+
output_lines.append(lines[i])
|
|
153
|
+
i += 1
|
|
154
|
+
else:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
# Strip trailing empty lines from output
|
|
158
|
+
while output_lines and output_lines[-1] == "":
|
|
159
|
+
output_lines.pop()
|
|
160
|
+
|
|
161
|
+
# Output the output (possibly wrapped)
|
|
162
|
+
if output_lines:
|
|
163
|
+
if collapse_output:
|
|
164
|
+
result.append("")
|
|
165
|
+
result.append("<details>")
|
|
166
|
+
result.append("<summary>Output</summary>")
|
|
167
|
+
result.append("")
|
|
168
|
+
result.extend(output_lines)
|
|
169
|
+
result.append("")
|
|
170
|
+
result.append("</details>")
|
|
171
|
+
else:
|
|
172
|
+
result.extend(output_lines)
|
|
173
|
+
|
|
174
|
+
result.append("") # blank line after cell
|
|
175
|
+
code_cell_idx += 1
|
|
176
|
+
else:
|
|
177
|
+
result.append(line)
|
|
178
|
+
i += 1
|
|
179
|
+
|
|
180
|
+
return "\n".join(result)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert notebook to markdown using nbconvert.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
import nbformat
|
|
9
|
+
from nbconvert import MarkdownExporter
|
|
10
|
+
from nbconvert.preprocessors import TagRemovePreprocessor
|
|
11
|
+
|
|
12
|
+
from cogames.cli.docsync._nb_md_directive_processing import DirectivePreprocessor, directive_post_process
|
|
13
|
+
from cogames.cli.docsync._utils import clean_notebook_metadata, run_notebook
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotebookResources(TypedDict):
|
|
17
|
+
"""Resources returned by nbconvert."""
|
|
18
|
+
|
|
19
|
+
outputs: dict[str, bytes]
|
|
20
|
+
output_files_dir: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def convert_nb_to_md_in_memory(
|
|
24
|
+
*, nb_path: Path, cogames_root: Path, notebook_relpath: Path | None = None
|
|
25
|
+
) -> tuple[str, NotebookResources]:
|
|
26
|
+
"""Convert .ipynb to .md and resource files using nbconvert.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
nb_path: Path to the notebook file to read.
|
|
30
|
+
cogames_root: Root path for cogames package (used to compute relative path if notebook_relpath not provided).
|
|
31
|
+
notebook_relpath: Optional override for the relative path used in Colab URL generation.
|
|
32
|
+
Useful when the notebook is in a temp directory but we need the original relative path.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# This tells nbconvert where we'll write output files later, so it can reference them now.
|
|
36
|
+
output_files_dir = f"{nb_path.stem}_files"
|
|
37
|
+
|
|
38
|
+
exporter = MarkdownExporter()
|
|
39
|
+
exporter.register_preprocessor(DirectivePreprocessor, enabled=True)
|
|
40
|
+
|
|
41
|
+
# Register TagRemovePreprocessor to properly hide input for # <<hide-input>> cells.
|
|
42
|
+
# This removes the code block entirely rather than leaving an empty code block.
|
|
43
|
+
tag_remove_preprocessor = TagRemovePreprocessor()
|
|
44
|
+
tag_remove_preprocessor.remove_input_tags = {"remove_input"}
|
|
45
|
+
exporter.register_preprocessor(tag_remove_preprocessor, enabled=True)
|
|
46
|
+
|
|
47
|
+
nb = nbformat.read(nb_path, as_version=4)
|
|
48
|
+
if notebook_relpath is None:
|
|
49
|
+
notebook_relpath = nb_path.relative_to(cogames_root)
|
|
50
|
+
md, resources = exporter.from_notebook_node(
|
|
51
|
+
nb=nb,
|
|
52
|
+
resources={
|
|
53
|
+
"output_files_dir": output_files_dir,
|
|
54
|
+
"notebook_relpath": notebook_relpath,
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Run preprocessor again on nb to set collapse metadata (nbconvert modifies a copy internally).
|
|
59
|
+
DirectivePreprocessor().preprocess(nb=nb, resources={"notebook_relpath": notebook_relpath})
|
|
60
|
+
md = directive_post_process(md=md, nb=nb)
|
|
61
|
+
|
|
62
|
+
return md, resources
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_notebook_output(*, to_dir: Path, name: str, md: str, resources: NotebookResources) -> None:
|
|
66
|
+
"""Write notebook output to the given directory."""
|
|
67
|
+
|
|
68
|
+
# Write resources (images, etc.)
|
|
69
|
+
# Note: filename keys already include the subdirectory (e.g., "test_files/output_0_0.png")
|
|
70
|
+
output_files = resources["outputs"]
|
|
71
|
+
output_files_dir = resources["output_files_dir"]
|
|
72
|
+
if len(output_files) > 0:
|
|
73
|
+
files_dir = to_dir / output_files_dir
|
|
74
|
+
files_dir.mkdir(exist_ok=True)
|
|
75
|
+
for filename, data in output_files.items():
|
|
76
|
+
(to_dir / filename).write_bytes(data)
|
|
77
|
+
|
|
78
|
+
# Write markdown.
|
|
79
|
+
output_path = to_dir / f"{name}.md"
|
|
80
|
+
output_path.write_text(md)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def convert_nb_to_md(*, nb_path: Path, should_rerun: bool, cogames_root: Path) -> None:
|
|
84
|
+
"""Export a notebook to markdown in the same directory.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
nb_path: Path to the notebook file.
|
|
88
|
+
should_rerun: Whether to re-run the notebook before exporting.
|
|
89
|
+
cogames_root: Root path for cogames package, used to compute the relative
|
|
90
|
+
path for Colab URL generation.
|
|
91
|
+
"""
|
|
92
|
+
# Resolve to absolute paths for consistent handling (macOS symlinks /var -> /private/var)
|
|
93
|
+
nb_path = nb_path.resolve()
|
|
94
|
+
cogames_root = cogames_root.resolve()
|
|
95
|
+
|
|
96
|
+
if should_rerun:
|
|
97
|
+
run_notebook(nb_path=nb_path)
|
|
98
|
+
|
|
99
|
+
md, resources = convert_nb_to_md_in_memory(nb_path=nb_path, cogames_root=cogames_root)
|
|
100
|
+
|
|
101
|
+
_write_notebook_output(to_dir=nb_path.parent, name=nb_path.stem, md=md, resources=resources)
|
|
102
|
+
|
|
103
|
+
clean_notebook_metadata(nb_path=nb_path)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Convert notebook to/from Python using jupytext."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import jupytext
|
|
8
|
+
import questionary
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from cogames.cli.docsync._utils import clean_notebook_metadata, lint_py_file, py_nb_content_equal, run_notebook
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def convert_nb_to_py(*, nb_path: Path, py_path: Path) -> None:
|
|
15
|
+
"""Convert .ipynb to .py percent format using jupytext."""
|
|
16
|
+
nb = jupytext.read(nb_path)
|
|
17
|
+
jupytext.write(nb, py_path, fmt="py:percent")
|
|
18
|
+
lint_py_file(py_path)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def convert_py_to_nb(*, py_path: Path, nb_path: Path, should_rerun: bool) -> None:
|
|
22
|
+
"""Convert .py to .ipynb using jupytext, optionally execute."""
|
|
23
|
+
nb = jupytext.read(py_path)
|
|
24
|
+
jupytext.write(nb, nb_path)
|
|
25
|
+
|
|
26
|
+
if should_rerun:
|
|
27
|
+
run_notebook(nb_path=nb_path)
|
|
28
|
+
|
|
29
|
+
clean_notebook_metadata(nb_path=nb_path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _format_mtime(path: Path) -> str:
|
|
33
|
+
"""Format file mtime with millisecond precision."""
|
|
34
|
+
mtime = path.stat().st_mtime
|
|
35
|
+
dt = datetime.fromtimestamp(mtime)
|
|
36
|
+
ms = int((mtime % 1) * 1000)
|
|
37
|
+
return f"{dt.strftime('%Y-%m-%d %H:%M:%S')}.{ms:03d}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prompt_sync_direction(*, py_path: Path, nb_path: Path) -> Literal["py", "nb", "skip"]:
|
|
41
|
+
"""Prompt user to choose sync direction. Returns 'py', 'nb', or 'skip'."""
|
|
42
|
+
nb_mtime = nb_path.stat().st_mtime
|
|
43
|
+
py_mtime = py_path.stat().st_mtime
|
|
44
|
+
py_newer = py_mtime > nb_mtime
|
|
45
|
+
|
|
46
|
+
# Format: ".ipynb → .py (newer) 2026-01-21 10:30:15.456"
|
|
47
|
+
# " .py → .ipynb 2026-01-22 15:45:32.123"
|
|
48
|
+
newer_tag = "(newer)"
|
|
49
|
+
blank_pad = " " * len(newer_tag)
|
|
50
|
+
nb_label = f".ipynb → .py {newer_tag if not py_newer else blank_pad} {_format_mtime(nb_path)}"
|
|
51
|
+
py_label = f" .py → .ipynb {newer_tag if py_newer else blank_pad} {_format_mtime(py_path)}"
|
|
52
|
+
|
|
53
|
+
choice = questionary.select(
|
|
54
|
+
f"{py_path.stem}.py and {py_path.stem}.ipynb are out of sync. Which should be source of truth?",
|
|
55
|
+
choices=[nb_label, py_label, "Skip"],
|
|
56
|
+
).ask()
|
|
57
|
+
|
|
58
|
+
if choice is None:
|
|
59
|
+
return "skip"
|
|
60
|
+
elif ".py →" in choice:
|
|
61
|
+
return "py"
|
|
62
|
+
elif ".ipynb →" in choice:
|
|
63
|
+
return "nb"
|
|
64
|
+
else:
|
|
65
|
+
return "skip"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def sync_nb_and_py_by_user_choice(*, py_path: Path, nb_path: Path, should_rerun: bool) -> bool:
|
|
69
|
+
"""Sync .py/.ipynb with each other based on user selection. Returns whether notebook was re-run.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
py_path: Path to .py file
|
|
73
|
+
nb_path: Path to .ipynb file
|
|
74
|
+
should_rerun: Whether to re-run notebook after syncing py->nb
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
if py_path.exists() and nb_path.exists():
|
|
78
|
+
# Check if content is already equal (no sync needed)
|
|
79
|
+
if py_nb_content_equal(py_path=py_path, nb_path=nb_path):
|
|
80
|
+
typer.echo(f" {py_path.name} and {nb_path.name} are already in sync")
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
# Prompt user to choose sync direction
|
|
84
|
+
choice = _prompt_sync_direction(py_path=py_path, nb_path=nb_path)
|
|
85
|
+
|
|
86
|
+
# Perform sync based on user choice
|
|
87
|
+
match choice:
|
|
88
|
+
case "py":
|
|
89
|
+
typer.echo(" Syncing .py → .ipynb...")
|
|
90
|
+
convert_py_to_nb(py_path=py_path, nb_path=nb_path, should_rerun=should_rerun)
|
|
91
|
+
typer.echo(f" Synced {py_path.name} → {nb_path.name}")
|
|
92
|
+
py_path.touch() # Touch source so it stays "newer"
|
|
93
|
+
return should_rerun
|
|
94
|
+
case "nb":
|
|
95
|
+
typer.echo(" Syncing .ipynb → .py...")
|
|
96
|
+
convert_nb_to_py(nb_path=nb_path, py_path=py_path)
|
|
97
|
+
typer.echo(f" Synced {nb_path.name} → {py_path.name}")
|
|
98
|
+
nb_path.touch() # Touch source so it stays "newer"
|
|
99
|
+
return False
|
|
100
|
+
case "skip":
|
|
101
|
+
typer.echo(f" Skipping {py_path.stem}")
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
elif py_path.exists():
|
|
105
|
+
typer.echo(f" Only {py_path.name} exists, creating .ipynb...")
|
|
106
|
+
convert_py_to_nb(py_path=py_path, nb_path=nb_path, should_rerun=should_rerun)
|
|
107
|
+
typer.echo(f" Created {nb_path.name}")
|
|
108
|
+
# Touch the original file (not the created one)
|
|
109
|
+
py_path.touch()
|
|
110
|
+
return should_rerun
|
|
111
|
+
|
|
112
|
+
elif nb_path.exists():
|
|
113
|
+
typer.echo(f" Only {nb_path.name} exists, creating .py...")
|
|
114
|
+
convert_nb_to_py(nb_path=nb_path, py_path=py_path)
|
|
115
|
+
typer.echo(f" Created {py_path.name}")
|
|
116
|
+
# Touch the original file (not the created one)
|
|
117
|
+
nb_path.touch()
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
else:
|
|
121
|
+
typer.echo(f" Error: neither {py_path.name} nor {nb_path.name} exist", err=True)
|
|
122
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Three-way sync between .py, .ipynb, and .md files."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from cogames.cli.docsync._nb_md_sync import convert_nb_to_md, convert_nb_to_md_in_memory
|
|
11
|
+
from cogames.cli.docsync._nb_py_sync import convert_nb_to_py, sync_nb_and_py_by_user_choice
|
|
12
|
+
from cogames.cli.docsync._utils import files_equal, lint_py_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_stem_paths(*, cogames_root: Path) -> list[Path]:
|
|
16
|
+
"""Get all notebook stem paths (README + tutorials/*)."""
|
|
17
|
+
stem_paths: list[Path] = []
|
|
18
|
+
|
|
19
|
+
# README notebook at root
|
|
20
|
+
readme_nb_path = cogames_root / "README.ipynb"
|
|
21
|
+
if readme_nb_path.exists():
|
|
22
|
+
stem_paths.append(readme_nb_path.with_suffix(""))
|
|
23
|
+
|
|
24
|
+
# Notebooks in tutorials directory
|
|
25
|
+
tutorials_dir = cogames_root / "tutorials"
|
|
26
|
+
if tutorials_dir.exists():
|
|
27
|
+
stem_paths.extend(sorted({nb_path.with_suffix("") for nb_path in tutorials_dir.glob("*.ipynb")}))
|
|
28
|
+
|
|
29
|
+
return stem_paths
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sync_stem_three_way(*, stem_path: Path, should_rerun: bool, cogames_root: Path) -> None:
|
|
33
|
+
"""Sync .py and .ipynb with each other based on mtime, then export to .md."""
|
|
34
|
+
|
|
35
|
+
py_path = stem_path.with_suffix(".py")
|
|
36
|
+
nb_path = stem_path.with_suffix(".ipynb")
|
|
37
|
+
|
|
38
|
+
did_rerun = sync_nb_and_py_by_user_choice(
|
|
39
|
+
py_path=py_path,
|
|
40
|
+
nb_path=nb_path,
|
|
41
|
+
should_rerun=should_rerun,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
typer.echo(f" Exporting {nb_path.name} to markdown...")
|
|
45
|
+
still_should_rerun = should_rerun and not did_rerun
|
|
46
|
+
convert_nb_to_md(nb_path=nb_path, should_rerun=still_should_rerun, cogames_root=cogames_root)
|
|
47
|
+
typer.echo(f" Exported {nb_path.name} → {nb_path.stem}.md")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_stem_in_sync_three_way(*, stem_path: Path, should_rerun: bool, cogames_root: Path) -> list[str]:
|
|
51
|
+
"""Check if .py, .ipynb, .md for a stem are in sync. Returns list of errors."""
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
py_path = stem_path.with_suffix(".py")
|
|
54
|
+
nb_path = stem_path.with_suffix(".ipynb")
|
|
55
|
+
md_path = stem_path.with_suffix(".md")
|
|
56
|
+
|
|
57
|
+
# All three files must exist
|
|
58
|
+
if not py_path.exists():
|
|
59
|
+
errors.append(f"{py_path.name} is missing")
|
|
60
|
+
if not md_path.exists():
|
|
61
|
+
errors.append(f"{md_path.name} is missing")
|
|
62
|
+
if not nb_path.exists():
|
|
63
|
+
errors.append(f"{nb_path.name} is missing")
|
|
64
|
+
return errors # Can't check further without .ipynb
|
|
65
|
+
|
|
66
|
+
# Work with a temp copy of the notebook to avoid modifying original
|
|
67
|
+
# Maintain directory structure so relative_to(cogames_root) works
|
|
68
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
69
|
+
notebook_relpath = nb_path.relative_to(cogames_root)
|
|
70
|
+
tmp_nb_path = Path(tmpdir) / notebook_relpath
|
|
71
|
+
tmp_nb_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
shutil.copy(nb_path, tmp_nb_path)
|
|
73
|
+
|
|
74
|
+
# Optionally re-run the notebook (in original working dir so paths work)
|
|
75
|
+
if should_rerun:
|
|
76
|
+
typer.echo(f" Re-running {nb_path.name}...")
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
["jupyter", "execute", str(tmp_nb_path), "--inplace"],
|
|
79
|
+
cwd=nb_path.parent, # Use original working dir for relative paths
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
)
|
|
83
|
+
if result.returncode != 0:
|
|
84
|
+
errors.append(f"{nb_path.name} failed to execute: {result.stderr}")
|
|
85
|
+
return errors
|
|
86
|
+
|
|
87
|
+
# Convert temp .ipynb -> temp .py, compare with existing .py
|
|
88
|
+
if py_path.exists():
|
|
89
|
+
tmp_py_path = Path(tmpdir) / py_path.name
|
|
90
|
+
convert_nb_to_py(nb_path=tmp_nb_path, py_path=tmp_py_path)
|
|
91
|
+
# Copy and format existing .py for fair comparison
|
|
92
|
+
tmp_existing_py = Path(tmpdir) / f"existing_{py_path.name}"
|
|
93
|
+
shutil.copy(py_path, tmp_existing_py)
|
|
94
|
+
lint_py_file(tmp_existing_py)
|
|
95
|
+
if not files_equal(tmp_py_path, tmp_existing_py):
|
|
96
|
+
errors.append(f"{py_path.name} doesn't match {nb_path.name}")
|
|
97
|
+
|
|
98
|
+
# Convert temp .ipynb -> temp .md, compare with existing .md and resources
|
|
99
|
+
if md_path.exists():
|
|
100
|
+
md_content, resources = convert_nb_to_md_in_memory(nb_path=tmp_nb_path, cogames_root=Path(tmpdir))
|
|
101
|
+
tmp_md_path = Path(tmpdir) / md_path.name
|
|
102
|
+
tmp_md_path.write_text(md_content)
|
|
103
|
+
if not files_equal(tmp_md_path, md_path):
|
|
104
|
+
errors.append(f"{md_path.name} doesn't match {nb_path.name}")
|
|
105
|
+
|
|
106
|
+
# Compare resources (images, etc.)
|
|
107
|
+
output_files = resources["outputs"]
|
|
108
|
+
for filename, generated_data in output_files.items():
|
|
109
|
+
existing_path = nb_path.parent / filename
|
|
110
|
+
if not existing_path.exists():
|
|
111
|
+
errors.append(f"{filename} is missing")
|
|
112
|
+
elif existing_path.read_bytes() != generated_data:
|
|
113
|
+
errors.append(f"{filename} doesn't match {nb_path.name}")
|
|
114
|
+
|
|
115
|
+
return errors
|