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.
Files changed (169) hide show
  1. cogames/cli/client.py +60 -6
  2. cogames/cli/docsync/__init__.py +0 -0
  3. cogames/cli/docsync/_nb_md_directive_processing.py +180 -0
  4. cogames/cli/docsync/_nb_md_sync.py +103 -0
  5. cogames/cli/docsync/_nb_py_sync.py +122 -0
  6. cogames/cli/docsync/_three_way_sync.py +115 -0
  7. cogames/cli/docsync/_utils.py +76 -0
  8. cogames/cli/docsync/docsync.py +156 -0
  9. cogames/cli/leaderboard.py +112 -28
  10. cogames/cli/mission.py +64 -53
  11. cogames/cli/policy.py +46 -10
  12. cogames/cli/submit.py +268 -67
  13. cogames/cogs_vs_clips/cog.py +79 -0
  14. cogames/cogs_vs_clips/cogs_vs_clips_mapgen.md +19 -16
  15. cogames/cogs_vs_clips/cogsguard_reward_variants.py +153 -0
  16. cogames/cogs_vs_clips/cogsguard_tutorial.py +56 -0
  17. cogames/cogs_vs_clips/evals/README.md +10 -16
  18. cogames/cogs_vs_clips/evals/cogsguard_evals.py +81 -0
  19. cogames/cogs_vs_clips/evals/diagnostic_evals.py +49 -444
  20. cogames/cogs_vs_clips/evals/difficulty_variants.py +13 -326
  21. cogames/cogs_vs_clips/evals/integrated_evals.py +5 -45
  22. cogames/cogs_vs_clips/evals/spanning_evals.py +9 -180
  23. cogames/cogs_vs_clips/mission.py +187 -146
  24. cogames/cogs_vs_clips/missions.py +46 -137
  25. cogames/cogs_vs_clips/procedural.py +8 -8
  26. cogames/cogs_vs_clips/sites.py +107 -3
  27. cogames/cogs_vs_clips/stations.py +198 -186
  28. cogames/cogs_vs_clips/tutorial_missions.py +1 -1
  29. cogames/cogs_vs_clips/variants.py +25 -476
  30. cogames/device.py +13 -1
  31. cogames/{policy/scripted_agent/README.md → docs/SCRIPTED_AGENT.md} +82 -58
  32. cogames/evaluate.py +18 -30
  33. cogames/main.py +1434 -243
  34. cogames/maps/canidate1_1000.map +1 -1
  35. cogames/maps/canidate1_1000_stations.map +2 -2
  36. cogames/maps/canidate1_500.map +1 -1
  37. cogames/maps/canidate1_500_stations.map +2 -2
  38. cogames/maps/canidate2_1000.map +1 -1
  39. cogames/maps/canidate2_1000_stations.map +2 -2
  40. cogames/maps/canidate2_500.map +1 -1
  41. cogames/maps/canidate2_500_stations.map +2 -2
  42. cogames/maps/canidate3_1000.map +1 -1
  43. cogames/maps/canidate3_1000_stations.map +2 -2
  44. cogames/maps/canidate3_500.map +1 -1
  45. cogames/maps/canidate3_500_stations.map +2 -2
  46. cogames/maps/canidate4_500.map +1 -1
  47. cogames/maps/canidate4_500_stations.map +2 -2
  48. cogames/maps/cave_base_50.map +2 -2
  49. cogames/maps/diagnostic_evals/diagnostic_agile.map +2 -2
  50. cogames/maps/diagnostic_evals/diagnostic_agile_hard.map +2 -2
  51. cogames/maps/diagnostic_evals/diagnostic_charge_up.map +2 -2
  52. cogames/maps/diagnostic_evals/diagnostic_charge_up_hard.map +2 -2
  53. cogames/maps/diagnostic_evals/diagnostic_chest_navigation1.map +2 -2
  54. cogames/maps/diagnostic_evals/diagnostic_chest_navigation1_hard.map +2 -2
  55. cogames/maps/diagnostic_evals/diagnostic_chest_navigation2.map +2 -2
  56. cogames/maps/diagnostic_evals/diagnostic_chest_navigation2_hard.map +2 -2
  57. cogames/maps/diagnostic_evals/diagnostic_chest_navigation3.map +2 -2
  58. cogames/maps/diagnostic_evals/diagnostic_chest_navigation3_hard.map +2 -2
  59. cogames/maps/diagnostic_evals/diagnostic_chest_near.map +2 -2
  60. cogames/maps/diagnostic_evals/diagnostic_chest_search.map +2 -2
  61. cogames/maps/diagnostic_evals/diagnostic_chest_search_hard.map +2 -2
  62. cogames/maps/diagnostic_evals/diagnostic_extract_lab.map +2 -2
  63. cogames/maps/diagnostic_evals/diagnostic_extract_lab_hard.map +2 -2
  64. cogames/maps/diagnostic_evals/diagnostic_memory.map +2 -2
  65. cogames/maps/diagnostic_evals/diagnostic_memory_hard.map +2 -2
  66. cogames/maps/diagnostic_evals/diagnostic_radial.map +2 -2
  67. cogames/maps/diagnostic_evals/diagnostic_radial_hard.map +2 -2
  68. cogames/maps/diagnostic_evals/diagnostic_resource_lab.map +2 -2
  69. cogames/maps/diagnostic_evals/diagnostic_unclip.map +2 -2
  70. cogames/maps/evals/eval_balanced_spread.map +9 -5
  71. cogames/maps/evals/eval_clip_oxygen.map +9 -5
  72. cogames/maps/evals/eval_collect_resources.map +9 -5
  73. cogames/maps/evals/eval_collect_resources_hard.map +9 -5
  74. cogames/maps/evals/eval_collect_resources_medium.map +9 -5
  75. cogames/maps/evals/eval_divide_and_conquer.map +9 -5
  76. cogames/maps/evals/eval_energy_starved.map +9 -5
  77. cogames/maps/evals/eval_multi_coordinated_collect_hard.map +9 -5
  78. cogames/maps/evals/eval_oxygen_bottleneck.map +9 -5
  79. cogames/maps/evals/eval_single_use_world.map +9 -5
  80. cogames/maps/evals/extractor_hub_100x100.map +9 -5
  81. cogames/maps/evals/extractor_hub_30x30.map +9 -5
  82. cogames/maps/evals/extractor_hub_50x50.map +9 -5
  83. cogames/maps/evals/extractor_hub_70x70.map +9 -5
  84. cogames/maps/evals/extractor_hub_80x80.map +9 -5
  85. cogames/maps/machina_100_stations.map +2 -2
  86. cogames/maps/machina_200_stations.map +2 -2
  87. cogames/maps/machina_200_stations_small.map +2 -2
  88. cogames/maps/machina_eval_exp01.map +2 -2
  89. cogames/maps/machina_eval_template_large.map +2 -2
  90. cogames/maps/machinatrainer4agents.map +2 -2
  91. cogames/maps/machinatrainer4agentsbase.map +2 -2
  92. cogames/maps/machinatrainerbig.map +2 -2
  93. cogames/maps/machinatrainersmall.map +2 -2
  94. cogames/maps/planky_evals/aligner_avoid_aoe.map +28 -0
  95. cogames/maps/planky_evals/aligner_full_cycle.map +28 -0
  96. cogames/maps/planky_evals/aligner_gear.map +24 -0
  97. cogames/maps/planky_evals/aligner_hearts.map +24 -0
  98. cogames/maps/planky_evals/aligner_junction.map +26 -0
  99. cogames/maps/planky_evals/exploration_distant.map +28 -0
  100. cogames/maps/planky_evals/maze.map +32 -0
  101. cogames/maps/planky_evals/miner_best_resource.map +26 -0
  102. cogames/maps/planky_evals/miner_deposit.map +24 -0
  103. cogames/maps/planky_evals/miner_extract.map +26 -0
  104. cogames/maps/planky_evals/miner_full_cycle.map +28 -0
  105. cogames/maps/planky_evals/miner_gear.map +24 -0
  106. cogames/maps/planky_evals/multi_role.map +28 -0
  107. cogames/maps/planky_evals/resource_chain.map +30 -0
  108. cogames/maps/planky_evals/scout_explore.map +32 -0
  109. cogames/maps/planky_evals/scout_gear.map +24 -0
  110. cogames/maps/planky_evals/scrambler_full_cycle.map +28 -0
  111. cogames/maps/planky_evals/scrambler_gear.map +24 -0
  112. cogames/maps/planky_evals/scrambler_target.map +26 -0
  113. cogames/maps/planky_evals/stuck_corridor.map +32 -0
  114. cogames/maps/planky_evals/survive_retreat.map +26 -0
  115. cogames/maps/training_facility_clipped.map +2 -2
  116. cogames/maps/training_facility_open_1.map +2 -2
  117. cogames/maps/training_facility_open_2.map +2 -2
  118. cogames/maps/training_facility_open_3.map +2 -2
  119. cogames/maps/training_facility_tight_4.map +2 -2
  120. cogames/maps/training_facility_tight_5.map +2 -2
  121. cogames/maps/vanilla_large.map +2 -2
  122. cogames/maps/vanilla_small.map +2 -2
  123. cogames/pickup.py +183 -0
  124. cogames/play.py +166 -33
  125. cogames/policy/chaos_monkey.py +54 -0
  126. cogames/policy/nim_agents/__init__.py +27 -10
  127. cogames/policy/nim_agents/agents.py +121 -60
  128. cogames/policy/nim_agents/thinky_eval.py +35 -222
  129. cogames/policy/pufferlib_policy.py +67 -32
  130. cogames/policy/starter_agent.py +184 -0
  131. cogames/policy/trainable_policy_template.py +4 -1
  132. cogames/train.py +51 -13
  133. cogames/verbose.py +2 -2
  134. cogames-0.3.64.dist-info/METADATA +1842 -0
  135. cogames-0.3.64.dist-info/RECORD +159 -0
  136. cogames-0.3.64.dist-info/licenses/LICENSE +21 -0
  137. cogames-0.3.64.dist-info/top_level.txt +2 -0
  138. metta_alo/__init__.py +0 -0
  139. metta_alo/job_specs.py +17 -0
  140. metta_alo/policy.py +16 -0
  141. metta_alo/pure_single_episode_runner.py +75 -0
  142. metta_alo/py.typed +0 -0
  143. metta_alo/rollout.py +322 -0
  144. metta_alo/scoring.py +168 -0
  145. cogames/maps/diagnostic_evals/diagnostic_assembler_near.map +0 -49
  146. cogames/maps/diagnostic_evals/diagnostic_assembler_search.map +0 -49
  147. cogames/maps/diagnostic_evals/diagnostic_assembler_search_hard.map +0 -89
  148. cogames/policy/nim_agents/common.nim +0 -887
  149. cogames/policy/nim_agents/install.sh +0 -1
  150. cogames/policy/nim_agents/ladybug_agent.nim +0 -984
  151. cogames/policy/nim_agents/nim_agents.nim +0 -55
  152. cogames/policy/nim_agents/nim_agents.nims +0 -14
  153. cogames/policy/nim_agents/nimby.lock +0 -3
  154. cogames/policy/nim_agents/racecar_agents.nim +0 -884
  155. cogames/policy/nim_agents/random_agents.nim +0 -68
  156. cogames/policy/nim_agents/test_agents.py +0 -53
  157. cogames/policy/nim_agents/thinky_agents.nim +0 -717
  158. cogames/policy/scripted_agent/baseline_agent.py +0 -1049
  159. cogames/policy/scripted_agent/demo_policy.py +0 -244
  160. cogames/policy/scripted_agent/pathfinding.py +0 -126
  161. cogames/policy/scripted_agent/starter_agent.py +0 -136
  162. cogames/policy/scripted_agent/types.py +0 -235
  163. cogames/policy/scripted_agent/unclipping_agent.py +0 -476
  164. cogames/policy/scripted_agent/utils.py +0 -385
  165. cogames-0.3.49.dist-info/METADATA +0 -406
  166. cogames-0.3.49.dist-info/RECORD +0 -136
  167. cogames-0.3.49.dist-info/top_level.txt +0 -1
  168. {cogames-0.3.49.dist-info → cogames-0.3.64.dist-info}/WHEEL +0 -0
  169. {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 get_leaderboard(self, season_name: str) -> list[LeaderboardEntry]:
151
- return self._get(f"/tournament/seasons/{season_name}/leaderboard", list[LeaderboardEntry])
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(self, season_name: str, mine: bool = False) -> list[SeasonPolicyEntry]:
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={"mine": "true"} if mine else None,
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={"upload_id": upload_id, "name": name},
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