castform 0.1__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 (191) hide show
  1. benchmax/bundle.py +377 -0
  2. benchmax/cli/__init__.py +71 -0
  3. benchmax/cli/_auth.py +65 -0
  4. benchmax/cli/_client.py +49 -0
  5. benchmax/cli/_output.py +134 -0
  6. benchmax/cli/_project.py +138 -0
  7. benchmax/cli/_providers.py +60 -0
  8. benchmax/cli/control.py +28 -0
  9. benchmax/cli/corpus.py +230 -0
  10. benchmax/cli/data.py +441 -0
  11. benchmax/cli/help.py +233 -0
  12. benchmax/cli/launch.py +247 -0
  13. benchmax/cli/runs.py +187 -0
  14. benchmax/cli/scaffold/CLAUDE.md +120 -0
  15. benchmax/cli/scaffold/STARTER.md +93 -0
  16. benchmax/cli/scaffold/__init__.py +0 -0
  17. benchmax/cli/scaffold/rag_run.py +72 -0
  18. benchmax/cli/scaffold/skills/design-environment/SKILL.md +322 -0
  19. benchmax/cli/scaffold/skills/generate-data/SKILL.md +189 -0
  20. benchmax/cli/scaffold/skills/launch-run/SKILL.md +74 -0
  21. benchmax/cli/scaffold/skills/verify-environment/SKILL.md +199 -0
  22. benchmax/cli/scaffold/skills/view-progress/SKILL.md +63 -0
  23. benchmax/cli/setup.py +286 -0
  24. benchmax/cli/validate.py +448 -0
  25. benchmax/config.py +55 -0
  26. benchmax/envs/__init__.py +0 -0
  27. benchmax/envs/base_env.py +229 -0
  28. benchmax/envs/crm/crm_env.py +107 -0
  29. benchmax/envs/crm/workdir/reward_fn.py +167 -0
  30. benchmax/envs/example_id.py +149 -0
  31. benchmax/envs/excel/data_utils.py +28 -0
  32. benchmax/envs/excel/excel_env.py +230 -0
  33. benchmax/envs/excel/workdir/__init__.py +0 -0
  34. benchmax/envs/excel/workdir/excel_code_runner_mcp.py +60 -0
  35. benchmax/envs/excel/workdir/excel_utils.py +254 -0
  36. benchmax/envs/excel/workdir/reward_fn.py +56 -0
  37. benchmax/envs/logging.py +122 -0
  38. benchmax/envs/math/math_env.py +72 -0
  39. benchmax/envs/math/workdir/reward_fn.py +46 -0
  40. benchmax/envs/mcp/__init__.py +12 -0
  41. benchmax/envs/mcp/example_workdir/demo_mcp_server.py +131 -0
  42. benchmax/envs/mcp/example_workdir/reward_fn.py +135 -0
  43. benchmax/envs/mcp/parallel_mcp_env.py +604 -0
  44. benchmax/envs/mcp/provisioners/__init__.py +20 -0
  45. benchmax/envs/mcp/provisioners/base_provisioner.py +50 -0
  46. benchmax/envs/mcp/provisioners/local_provisioner.py +319 -0
  47. benchmax/envs/mcp/provisioners/manual_provisioner.py +70 -0
  48. benchmax/envs/mcp/provisioners/skypilot_provisioner.py +222 -0
  49. benchmax/envs/mcp/provisioners/utils.py +106 -0
  50. benchmax/envs/mcp/proxy_server.py +471 -0
  51. benchmax/envs/mcp/server_pool.py +493 -0
  52. benchmax/envs/mcp/utils.py +217 -0
  53. benchmax/envs/postgres_search/__init__.py +0 -0
  54. benchmax/envs/postgres_search/linker_env.py +237 -0
  55. benchmax/envs/postgres_search/search_env.py +541 -0
  56. benchmax/envs/reward_helpers.py +215 -0
  57. benchmax/envs/telestich/example.py +664 -0
  58. benchmax/envs/telestich/telestich_env.py +1320 -0
  59. benchmax/envs/types.py +137 -0
  60. benchmax/envs/wikipedia/utils.py +95 -0
  61. benchmax/envs/wikipedia/wiki_env.py +274 -0
  62. benchmax/multi_model/__init__.py +0 -0
  63. benchmax/multi_model/caller.py +504 -0
  64. benchmax/multi_model/clients.py +144 -0
  65. benchmax/multi_model/example_usage.py +325 -0
  66. benchmax/multi_model/inspector.py +140 -0
  67. benchmax/multi_model/models.py +108 -0
  68. benchmax/multi_model/pricing.py +67 -0
  69. benchmax/platform/__init__.py +25 -0
  70. benchmax/platform/client.py +1485 -0
  71. benchmax/platform/credentials.py +379 -0
  72. benchmax/platform/device_auth.py +81 -0
  73. benchmax/platform/exceptions.py +46 -0
  74. benchmax/platform/login.py +92 -0
  75. benchmax/platform/training_run.py +174 -0
  76. benchmax/platform/validation.py +904 -0
  77. benchmax/prompts/__init__.py +0 -0
  78. benchmax/prompts/tools.py +92 -0
  79. benchmax/rag/chunkers/__init__.py +0 -0
  80. benchmax/rag/chunkers/email.py +793 -0
  81. benchmax/rag/chunkers/inspector.py +230 -0
  82. benchmax/rag/chunkers/markdown.py +347 -0
  83. benchmax/rag/chunkers/models.py +253 -0
  84. benchmax/rag/chunkers/storage.py +78 -0
  85. benchmax/rag/corpus/__init__.py +1 -0
  86. benchmax/rag/corpus/chroma/__init__.py +0 -0
  87. benchmax/rag/corpus/chroma/client.py +536 -0
  88. benchmax/rag/corpus/chroma/files.py +162 -0
  89. benchmax/rag/corpus/chroma/filter_mapper.py +149 -0
  90. benchmax/rag/corpus/chroma/search.py +207 -0
  91. benchmax/rag/corpus/chroma/source.py +792 -0
  92. benchmax/rag/corpus/embed.py +54 -0
  93. benchmax/rag/corpus/pinecone/__init__.py +0 -0
  94. benchmax/rag/corpus/pinecone/files.py +159 -0
  95. benchmax/rag/corpus/pinecone/filter_mapper.py +194 -0
  96. benchmax/rag/corpus/pinecone/index_client.py +460 -0
  97. benchmax/rag/corpus/pinecone/search.py +128 -0
  98. benchmax/rag/corpus/pinecone/source.py +559 -0
  99. benchmax/rag/corpus/postgres/__init__.py +0 -0
  100. benchmax/rag/corpus/postgres/client.py +574 -0
  101. benchmax/rag/corpus/postgres/exceptions.py +53 -0
  102. benchmax/rag/corpus/postgres/filter_mapper.py +119 -0
  103. benchmax/rag/corpus/postgres/models.py +63 -0
  104. benchmax/rag/corpus/postgres/search.py +113 -0
  105. benchmax/rag/corpus/postgres/source.py +422 -0
  106. benchmax/rag/corpus/search_client.py +62 -0
  107. benchmax/rag/corpus/search_schema/__init__.py +0 -0
  108. benchmax/rag/corpus/search_schema/builders.py +58 -0
  109. benchmax/rag/corpus/search_schema/dsl_parser.py +47 -0
  110. benchmax/rag/corpus/search_schema/search_exceptions.py +63 -0
  111. benchmax/rag/corpus/search_schema/search_types.py +176 -0
  112. benchmax/rag/corpus/source.py +127 -0
  113. benchmax/rag/corpus/turbopuffer/__init__.py +0 -0
  114. benchmax/rag/corpus/turbopuffer/files.py +175 -0
  115. benchmax/rag/corpus/turbopuffer/filter_mapper.py +139 -0
  116. benchmax/rag/corpus/turbopuffer/namespace.py +344 -0
  117. benchmax/rag/corpus/turbopuffer/search.py +215 -0
  118. benchmax/rag/corpus/turbopuffer/source.py +722 -0
  119. benchmax/rag/preprocess/__init__.py +0 -0
  120. benchmax/rag/preprocess/email/__init__.py +0 -0
  121. benchmax/rag/preprocess/email/clean_bodies.py +513 -0
  122. benchmax/rag/preprocess/email/dedupe.py +799 -0
  123. benchmax/rag/preprocess/email/filter_automated_email_qas.py +320 -0
  124. benchmax/rag/preprocess/email/filter_automated_emails.py +560 -0
  125. benchmax/rag/preprocess/email/mbox.py +257 -0
  126. benchmax/rag/preprocess/email/schema.py +180 -0
  127. benchmax/rag/qa_generation/__init__.py +64 -0
  128. benchmax/rag/qa_generation/anchor_selector.py +17 -0
  129. benchmax/rag/qa_generation/auto_tune.py +255 -0
  130. benchmax/rag/qa_generation/batch_processor.py +374 -0
  131. benchmax/rag/qa_generation/checkpoint.py +294 -0
  132. benchmax/rag/qa_generation/corpus_capabilities.py +94 -0
  133. benchmax/rag/qa_generation/corpus_profile.py +1688 -0
  134. benchmax/rag/qa_generation/filters/__init__.py +21 -0
  135. benchmax/rag/qa_generation/filters/deterministic_guards.py +225 -0
  136. benchmax/rag/qa_generation/filters/env_rollout.py +288 -0
  137. benchmax/rag/qa_generation/filters/grounding_llm.py +506 -0
  138. benchmax/rag/qa_generation/filters/hop_count_validity.py +993 -0
  139. benchmax/rag/qa_generation/filters/quality_gate.py +243 -0
  140. benchmax/rag/qa_generation/filters/retrieval_llm.py +809 -0
  141. benchmax/rag/qa_generation/formatters/__init__.py +5 -0
  142. benchmax/rag/qa_generation/formatters/train_eval.py +123 -0
  143. benchmax/rag/qa_generation/generated_qa.py +125 -0
  144. benchmax/rag/qa_generation/generators/__init__.py +5 -0
  145. benchmax/rag/qa_generation/generators/direct_llm.py +685 -0
  146. benchmax/rag/qa_generation/helpers.py +133 -0
  147. benchmax/rag/qa_generation/metadata_linker.py +771 -0
  148. benchmax/rag/qa_generation/metrics.py +95 -0
  149. benchmax/rag/qa_generation/models.py +36 -0
  150. benchmax/rag/qa_generation/pipeline.py +2656 -0
  151. benchmax/rag/qa_generation/pipeline_config.py +1115 -0
  152. benchmax/rag/qa_generation/protocols.py +66 -0
  153. benchmax/rag/qa_generation/query_rewriter.py +149 -0
  154. benchmax/rag/qa_generation/response_parsers.py +63 -0
  155. benchmax/rag/qa_generation/retrieval_query.py +76 -0
  156. benchmax/rag/qa_generation/scoring.py +114 -0
  157. benchmax/rag/qa_generation/search_agent_linker.py +609 -0
  158. benchmax/rag/qa_generation/storage.py +142 -0
  159. benchmax/rag/qa_generation/style_controls.py +230 -0
  160. benchmax/rag/qa_generation/transformers/__init__.py +7 -0
  161. benchmax/rag/qa_generation/transformers/base.py +125 -0
  162. benchmax/rag/qa_generation/transformers/dedup.py +195 -0
  163. benchmax/rag/qa_generation/wiki_builder.py +539 -0
  164. benchmax/rag/qa_generation/wiki_chunk_linker.py +303 -0
  165. benchmax/rewards/__init__.py +0 -0
  166. benchmax/rewards/diversity.py +305 -0
  167. benchmax/rubrics/__init__.py +17 -0
  168. benchmax/rubrics/_utils.py +51 -0
  169. benchmax/rubrics/adaptive.py +137 -0
  170. benchmax/rubrics/cache.py +178 -0
  171. benchmax/rubrics/prompts.py +178 -0
  172. benchmax/rubrics/reward_fns.py +349 -0
  173. benchmax/rubrics/rubric.py +463 -0
  174. benchmax/traces/__init__.py +8 -0
  175. benchmax/traces/adapter.py +351 -0
  176. benchmax/traces/braintrust/__init__.py +0 -0
  177. benchmax/traces/braintrust/adapter.py +322 -0
  178. benchmax/traces/braintrust/message_extraction.py +245 -0
  179. benchmax/traces/http.py +90 -0
  180. benchmax/traces/pipeline.py +278 -0
  181. benchmax/traces/pivot.py +664 -0
  182. benchmax/traces/processing.py +776 -0
  183. benchmax/traces/registry.py +32 -0
  184. benchmax/utils/__init__.py +14 -0
  185. benchmax/utils/checkpoint.py +87 -0
  186. castform-0.1.dist-info/METADATA +77 -0
  187. castform-0.1.dist-info/RECORD +191 -0
  188. castform-0.1.dist-info/WHEEL +5 -0
  189. castform-0.1.dist-info/entry_points.txt +2 -0
  190. castform-0.1.dist-info/licenses/LICENSE +201 -0
  191. castform-0.1.dist-info/top_level.txt +1 -0
benchmax/bundle.py ADDED
@@ -0,0 +1,377 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import inspect
5
+ import io
6
+ import json
7
+ import logging
8
+ import pickle
9
+ import sys
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from importlib.metadata import PackageNotFoundError, distribution, packages_distributions
13
+ from types import ModuleType
14
+ from typing import Any
15
+
16
+ import cloudpickle
17
+
18
+ from benchmax.envs.base_env import BaseEnv
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class BundlingError(Exception):
24
+ """Cloudpickle serialization or class-shape failure."""
25
+
26
+
27
+ class IncompatiblePythonError(Exception):
28
+ """Loader's interpreter doesn't match the bundle's python_version."""
29
+
30
+
31
+ # register_pickle_by_value mutates process-global state; serialize against races.
32
+ _BUNDLE_LOCK = threading.Lock()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class BundleMetadata:
37
+ """Readable without unpickling: install deps, version checks, UI source."""
38
+
39
+ pip_dependencies: list[str]
40
+ python_version: str
41
+ benchmax_version: str
42
+ env_class_source: str | None
43
+
44
+ def to_json_bytes(self) -> bytes:
45
+ return json.dumps(
46
+ {
47
+ "pip_dependencies": self.pip_dependencies,
48
+ "python_version": self.python_version,
49
+ "benchmax_version": self.benchmax_version,
50
+ "env_class_source": self.env_class_source,
51
+ }
52
+ ).encode("utf-8")
53
+
54
+ @classmethod
55
+ def from_json_bytes(cls, data: bytes) -> "BundleMetadata":
56
+ d = json.loads(data.decode("utf-8"))
57
+ return cls(
58
+ pip_dependencies=list(d["pip_dependencies"]),
59
+ python_version=d["python_version"],
60
+ benchmax_version=d["benchmax_version"],
61
+ env_class_source=d.get("env_class_source"),
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Bundle:
67
+ """Serialized env. ``pickled`` is ``cloudpickle.dumps((env_class, constructor_args))``."""
68
+
69
+ pickled: bytes
70
+ metadata: BundleMetadata
71
+
72
+
73
+ def dump_bundle(
74
+ env_class: type[BaseEnv],
75
+ *,
76
+ constructor_args: dict[str, Any] | None = None,
77
+ pip_dependencies: list[str] | None = None,
78
+ local_modules: list[ModuleType] | None = None,
79
+ env_class_source: str | None = None,
80
+ auto_local_modules: bool = True,
81
+ ) -> Bundle:
82
+ """Pickle ``(env_class, constructor_args)`` and stamp metadata.
83
+
84
+ Args:
85
+ env_class: A concrete BaseEnv subclass.
86
+ constructor_args: kwargs for ``env_class(**...)`` on load.
87
+ pip_dependencies: Recorded in metadata. NOT installed by this call.
88
+ local_modules: Modules to pickle by-value. Required when the env class
89
+ (or anything it references) lives in a local ``.py``.
90
+ env_class_source: Override for the recorded source. Pass this when the
91
+ caller already holds the source and ``inspect.getsource`` can't
92
+ recover it — e.g. a class produced by ``exec()`` into an in-memory
93
+ namespace, which has no source file on disk. When ``None``
94
+ (default), source is introspected from ``env_class``.
95
+ auto_local_modules: When True (default), any local module the pickle
96
+ references but that wasn't passed in ``local_modules`` is imported
97
+ and pickled by value automatically (a warning names them). When
98
+ False, such a reference raises ``BundlingError`` instead.
99
+
100
+ Raises:
101
+ BundlingError: bad env_class, cloudpickle failure, or pickle references
102
+ non-installed modules absent from ``local_modules``.
103
+ """
104
+ _ensure_safe_python_version()
105
+ _check_env_class(env_class)
106
+
107
+ constructor_args = constructor_args or {}
108
+ pip_dependencies = pip_dependencies or []
109
+ local_modules = local_modules or []
110
+
111
+ with _BUNDLE_LOCK:
112
+ for mod in local_modules:
113
+ if not isinstance(mod, ModuleType):
114
+ raise BundlingError(
115
+ f"local_modules must contain module objects, got "
116
+ f"{type(mod).__name__}: {mod!r}"
117
+ )
118
+ cloudpickle.register_pickle_by_value(mod)
119
+ try:
120
+ try:
121
+ pickled = cloudpickle.dumps((env_class, constructor_args))
122
+ except Exception as e:
123
+ raise BundlingError(
124
+ f"Failed to serialize {env_class.__name__}: {e}"
125
+ ) from e
126
+ finally:
127
+ for mod in local_modules:
128
+ try:
129
+ cloudpickle.unregister_pickle_by_value(mod)
130
+ except Exception:
131
+ pass
132
+
133
+ if auto_local_modules and _unregistered_local_refs(pickled):
134
+ # Import each referenced local module and re-dump with it pickled by
135
+ # value. Loop because a by-value module can surface further local refs;
136
+ # registrations accumulate (and are torn down once at the end) so an
137
+ # earlier module stays by-value while we resolve the ones it pulled in.
138
+ seen: set[str] = {m.__name__ for m in local_modules}
139
+ registered: list[ModuleType] = []
140
+ with _BUNDLE_LOCK:
141
+ try:
142
+ for _ in range(10):
143
+ pending = [
144
+ m for m in _unregistered_local_refs(pickled) if m not in seen
145
+ ]
146
+ if not pending:
147
+ break
148
+ new_mods: list[ModuleType] = []
149
+ for name in pending:
150
+ seen.add(name) # unimportable names fall through to the guard
151
+ try:
152
+ new_mods.append(importlib.import_module(name))
153
+ except Exception:
154
+ pass
155
+ if not new_mods:
156
+ break
157
+ logger.warning(
158
+ "[bundle] %s: auto-bundling local module(s): %s ",
159
+ env_class.__name__,
160
+ ", ".join(sorted(m.__name__ for m in new_mods)),
161
+ )
162
+ for mod in new_mods:
163
+ cloudpickle.register_pickle_by_value(mod)
164
+ registered.append(mod)
165
+ pickled = cloudpickle.dumps((env_class, constructor_args))
166
+ finally:
167
+ for mod in registered:
168
+ try:
169
+ cloudpickle.unregister_pickle_by_value(mod)
170
+ except Exception:
171
+ pass
172
+
173
+ risky = _unregistered_local_refs(pickled)
174
+ if risky:
175
+ msg = (
176
+ f"{env_class.__name__}: missing "
177
+ f"local_modules=[{', '.join(risky)}]"
178
+ )
179
+ if local_modules:
180
+ already = ", ".join(sorted(m.__name__ for m in local_modules))
181
+ msg += f" (already registered: [{already}])"
182
+ raise BundlingError(msg)
183
+
184
+ metadata = BundleMetadata(
185
+ pip_dependencies=pip_dependencies,
186
+ python_version=f"{sys.version_info.major}.{sys.version_info.minor}",
187
+ benchmax_version=_benchmax_version(),
188
+ env_class_source=(
189
+ env_class_source
190
+ if env_class_source is not None
191
+ else _get_source(env_class)
192
+ ),
193
+ )
194
+
195
+ logger.info(
196
+ "[bundle] dumped %s: %.1f KB pickled, %d pip deps",
197
+ env_class.__name__,
198
+ len(pickled) / 1024,
199
+ len(pip_dependencies),
200
+ )
201
+ return Bundle(pickled=pickled, metadata=metadata)
202
+
203
+
204
+ def load_bundle(
205
+ bundle: Bundle,
206
+ *,
207
+ instantiate: bool = True,
208
+ ) -> BaseEnv | tuple[type[BaseEnv], dict[str, Any]]:
209
+ """Unpickle and (optionally) instantiate.
210
+
211
+ Verifies ``python_version`` matches. Never installs pip deps — image must.
212
+
213
+ Args:
214
+ bundle: The Bundle to load.
215
+ instantiate: If True (default), return ``env_class(**constructor_args)``.
216
+ If False, return ``(env_class, constructor_args)``.
217
+
218
+ Raises:
219
+ IncompatiblePythonError: bundle's python_version != current.
220
+ BundlingError: corrupt bytes or non-BaseEnv payload.
221
+ """
222
+ current = f"{sys.version_info.major}.{sys.version_info.minor}"
223
+ if bundle.metadata.python_version != current:
224
+ raise IncompatiblePythonError(
225
+ f"Bundle was packaged with Python {bundle.metadata.python_version} "
226
+ f"but this machine runs Python {current}."
227
+ )
228
+
229
+ try:
230
+ payload = cloudpickle.loads(bundle.pickled)
231
+ except Exception as e:
232
+ raise BundlingError(f"Failed to unpickle bundle: {e}") from e
233
+
234
+ if not (isinstance(payload, tuple) and len(payload) == 2):
235
+ raise BundlingError(
236
+ f"Unpickled payload is {type(payload).__name__}, expected "
237
+ "(env_class, constructor_args) tuple."
238
+ )
239
+ env_class, constructor_args = payload
240
+ if not (isinstance(env_class, type) and issubclass(env_class, BaseEnv)):
241
+ raise BundlingError(
242
+ f"Unpickled class is {type(env_class).__name__}, not a BaseEnv subclass."
243
+ )
244
+ if not isinstance(constructor_args, dict):
245
+ raise BundlingError(
246
+ f"Unpickled constructor_args is {type(constructor_args).__name__}, expected dict."
247
+ )
248
+
249
+ if instantiate:
250
+ logger.info("[bundle] instantiating %s", env_class.__name__)
251
+ return env_class(**constructor_args)
252
+ return env_class, constructor_args
253
+
254
+
255
+ def _check_env_class(env_class: type[BaseEnv]) -> None:
256
+ if not (isinstance(env_class, type) and issubclass(env_class, BaseEnv)):
257
+ raise BundlingError(f"{env_class!r} is not a BaseEnv subclass.")
258
+ if env_class is BaseEnv:
259
+ raise BundlingError("Cannot bundle BaseEnv directly; provide a concrete subclass.")
260
+ abstract = getattr(env_class, "__abstractmethods__", frozenset())
261
+ if abstract:
262
+ raise BundlingError(
263
+ f"{env_class.__name__} has unimplemented abstract methods: "
264
+ f"{', '.join(sorted(abstract))}"
265
+ )
266
+
267
+
268
+ def _get_source(env_class: type[BaseEnv]) -> str | None:
269
+ try:
270
+ return inspect.getsource(env_class)
271
+ except (OSError, TypeError) as e:
272
+ logger.debug("[bundle] no source for %s: %s", env_class.__name__, e)
273
+ return None
274
+
275
+
276
+ def _benchmax_version() -> str:
277
+ try:
278
+ from importlib.metadata import version
279
+
280
+ return version("benchmax")
281
+ except Exception:
282
+ return "unknown"
283
+
284
+
285
+ def _ensure_safe_python_version() -> None:
286
+ # 3.13 has a pathlib.Path pickle incompatibility that breaks cross-version unpickling.
287
+ v = sys.version_info
288
+ if (v.major, v.minor) == (3, 13):
289
+ raise BundlingError(
290
+ f"Python {v.major}.{v.minor}.{v.micro} is unsupported. "
291
+ "Use Python 3.12 or >= 3.14."
292
+ )
293
+
294
+
295
+ def unregistered_local_refs(pickled: bytes) -> list[str]:
296
+ """Modules this pickle would import that aren't installed locally."""
297
+ return _unregistered_local_refs(pickled)
298
+
299
+
300
+ def _unregistered_local_refs(pickled: bytes) -> list[str]:
301
+ return sorted(m for m in _referenced_modules(pickled) if not _looks_like_installed(m))
302
+
303
+
304
+ def _referenced_modules(pickled: bytes) -> set[str]:
305
+ # Hooks find_class so we see every (module, name) the unpickler would import —
306
+ # i.e. exactly what'd raise ModuleNotFoundError on a fresh interpreter. The stub
307
+ # lets unpickling proceed past missing classes so we collect every ref.
308
+ #
309
+ # find_class alone has a blind spot: a bare ``import foo`` that leaves a
310
+ # module *object* in the env's globals is pickled as
311
+ # ``cloudpickle.subimport("foo")`` — the module name is a REDUCE argument,
312
+ # not a find_class path, so we'd only see ``cloudpickle.cloudpickle`` (which
313
+ # looks installed) and miss ``foo``. We shim subimport to record its arg and
314
+ # return a stub instead of importing, so a missing module is captured rather
315
+ # than aborting the whole load early. (``dynamic_subimport`` is by-value /
316
+ # self-contained — leave it to the real find_class so we don't flag it.)
317
+ refs: set[str] = set()
318
+
319
+ class _Stub:
320
+ def __init__(self, *a: Any, **kw: Any) -> None:
321
+ pass
322
+
323
+ def __call__(self, *a: Any, **kw: Any) -> "_Stub":
324
+ return self
325
+
326
+ def __reduce__(self) -> tuple:
327
+ return (type(self), ())
328
+
329
+ def _recording_subimport(name: str, *a: Any, **kw: Any) -> ModuleType:
330
+ refs.add(name)
331
+ return ModuleType(str(name))
332
+
333
+ def _noop_setstate(obj: Any, *a: Any, **kw: Any) -> Any:
334
+ # cloudpickle's _make_skeleton_class resolves the class_tracker_id back
335
+ # to the *live* class (it was tracked when env_class was dumped), so the
336
+ # real ``_class_setstate``/``_function_setstate`` would setattr the
337
+ # reconstructed (stub-globals) members onto the live class/function —
338
+ # mutating the caller's class mid-bundle and poisoning any later dump.
339
+ # We only need the refs from ``state``, which are already recorded while
340
+ # it's unpickled; the setter itself is a no-op here.
341
+ return obj
342
+
343
+ class _Recorder(pickle.Unpickler):
344
+ def find_class(self, module: str, name: str) -> Any:
345
+ refs.add(module)
346
+ if module.startswith("cloudpickle"):
347
+ if name == "subimport":
348
+ return _recording_subimport
349
+ if name in ("_class_setstate", "_function_setstate"):
350
+ return _noop_setstate
351
+ try:
352
+ return super().find_class(module, name)
353
+ except Exception:
354
+ return _Stub
355
+
356
+ try:
357
+ _Recorder(io.BytesIO(pickled)).load()
358
+ except Exception:
359
+ pass # later REDUCE failures against stubs are expected
360
+ return refs
361
+
362
+
363
+ def _looks_like_installed(mod_name: str) -> bool:
364
+ top = mod_name.split(".")[0]
365
+ if top in sys.stdlib_module_names:
366
+ return True
367
+ try:
368
+ distribution(top)
369
+ return True
370
+ except PackageNotFoundError:
371
+ pass
372
+ try:
373
+ if packages_distributions().get(top):
374
+ return True
375
+ except Exception:
376
+ pass
377
+ return False
@@ -0,0 +1,71 @@
1
+ """``castform`` CLI — a single argparse tree assembled from command groups.
2
+
3
+ Each command group lives in its own module exposing ``register(sub)``;
4
+ ``build_parser`` wires them onto the top-level subparsers and ``main`` dispatches
5
+ to the selected handler's ``func``. Bundled with the benchmax SDK — entry point
6
+ ``benchmax.cli:main`` (``pyproject.toml``). Argparse (not typer) is deliberate:
7
+ bundled packaging means a CLI dep would land in the training-engine closure; see
8
+ ``docs/plans/castform-cli-rl-workflow.md`` slice 1.1.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+
16
+ from benchmax.cli import (
17
+ _auth,
18
+ control,
19
+ corpus,
20
+ data,
21
+ help,
22
+ launch,
23
+ runs,
24
+ setup,
25
+ validate,
26
+ )
27
+
28
+ # Re-export auth handlers — tests/unit/test_cli.py imports them as cli._cmd_*.
29
+ from benchmax.cli._auth import _cmd_login, _cmd_logout, _cmd_whoami
30
+
31
+ __all__ = ["build_parser", "main", "_cmd_login", "_cmd_logout", "_cmd_whoami"]
32
+
33
+
34
+ def build_parser() -> argparse.ArgumentParser:
35
+ """Build the full castform parser. Tests snapshot its ``format_help()``."""
36
+ parser = argparse.ArgumentParser(prog="castform", description="Castform CLI")
37
+ sub = parser.add_subparsers(dest="command", required=True, metavar="<command>")
38
+ _auth.register(sub)
39
+ runs.register(sub)
40
+ control.register(sub)
41
+ validate.register(sub)
42
+ launch.register(sub)
43
+ data.register(sub)
44
+ corpus.register(sub)
45
+ setup.register(sub)
46
+
47
+ # `guide` renders the getting-started walkthrough (the renderer lives in
48
+ # ``benchmax.cli.help``). Named `guide`, not `quickstart`, because `setup`
49
+ # is itself the quickstart flow — keep the two from blurring together.
50
+ gp = sub.add_parser("guide", help="Walk through your first run")
51
+ gp.set_defaults(func=help._cmd_help)
52
+
53
+ # `help` mirrors `-h`: just list the commands. Users reach for it by habit;
54
+ # the walkthrough is `castform guide`, not here.
55
+ def _list_commands(_args: argparse.Namespace) -> int:
56
+ parser.print_help()
57
+ return 0
58
+
59
+ hp = sub.add_parser("help", help="List the available commands")
60
+ hp.set_defaults(func=_list_commands)
61
+ return parser
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ parser = build_parser()
66
+ args = parser.parse_args(argv)
67
+ return args.func(args)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ sys.exit(main())
benchmax/cli/_auth.py ADDED
@@ -0,0 +1,65 @@
1
+ """castform auth commands: ``login`` / ``logout`` / ``whoami``.
2
+
3
+ The device-auth flow + the reusable ``ensure_session`` live in
4
+ :mod:`benchmax.platform.login`; these handlers are thin argparse wrappers. After
5
+ ``castform login`` the SDK resolves its bearer from ``~/.castform`` automatically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from benchmax import config
14
+ from benchmax.platform import credentials
15
+ from benchmax.platform.device_auth import DeviceAuthError
16
+ from benchmax.platform.login import _login
17
+
18
+
19
+ def _cmd_login(_args: argparse.Namespace) -> int:
20
+ try:
21
+ _login()
22
+ except DeviceAuthError as exc:
23
+ print(f"Login failed: {exc}", file=sys.stderr)
24
+ return 1
25
+ print(f"\n✓ Logged in to {config.base_domain()}.")
26
+ return 0
27
+
28
+
29
+ def _cmd_logout(_args: argparse.Namespace) -> int:
30
+ credentials.clear_castform_session()
31
+ print("✓ Logged out.")
32
+ return 0
33
+
34
+
35
+ def _cmd_whoami(_args: argparse.Namespace) -> int:
36
+ session = credentials.read_castform_session()
37
+ if not session:
38
+ print("Not logged in. Run `castform login`.", file=sys.stderr)
39
+ return 1
40
+ jwt = credentials._session_jwt() # None if invalid/expired/offline
41
+ if not jwt:
42
+ print(
43
+ "Session present, but couldn't reach auth-service to verify it "
44
+ "(offline, or the session expired). If this persists, run "
45
+ "`castform login` again.",
46
+ file=sys.stderr,
47
+ )
48
+ return 1
49
+ claims = credentials._jwt_claims(jwt)
50
+ who = claims.get("email") or claims.get("sub", "<unknown>")
51
+ print(f"Logged in as {who} ({config.base_domain()}).")
52
+ return 0
53
+
54
+
55
+ def register(sub: argparse._SubParsersAction) -> None:
56
+ """Attach login/logout/whoami to the top-level subparsers."""
57
+ sub.add_parser("login", help="Sign in via your browser").set_defaults(
58
+ func=_cmd_login
59
+ )
60
+ sub.add_parser("logout", help="Clear the cached session").set_defaults(
61
+ func=_cmd_logout
62
+ )
63
+ sub.add_parser("whoami", help="Show the current login").set_defaults(
64
+ func=_cmd_whoami
65
+ )
@@ -0,0 +1,49 @@
1
+ """Shared platform-client wiring + error handling for CLI command groups.
2
+
3
+ Read/control commands resolve their bearer through the credential seam
4
+ (:func:`benchmax.platform.credentials.platform_bearer`) — ``ACT_AS_TOKEN_PATH``
5
+ → ``PLATFORM_API_KEY`` → cached ``~/.castform`` session — against the host from
6
+ :mod:`benchmax.config`. ``handle_errors`` keeps tracebacks out of normal failures.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import sys
13
+ from collections.abc import Callable
14
+
15
+ import httpx
16
+
17
+ from benchmax.platform.client import TrainerClient
18
+ from benchmax.platform.exceptions import AuthenticationError, TrainerError
19
+
20
+
21
+ def trainer_client() -> TrainerClient:
22
+ """A TrainerClient bound to the configured platform host + bearer seam."""
23
+ return TrainerClient()
24
+
25
+
26
+ def handle_errors(func: Callable) -> Callable:
27
+ """Turn client/credential/network failures into a clean stderr line + exit 1."""
28
+
29
+ @functools.wraps(func)
30
+ def wrapper(args):
31
+ try:
32
+ return func(args)
33
+ except AuthenticationError:
34
+ print(
35
+ "Not logged in (or session expired). Run `castform login`.",
36
+ file=sys.stderr,
37
+ )
38
+ return 1
39
+ except TrainerError as exc:
40
+ print(f"Error: {exc.message}", file=sys.stderr)
41
+ return 1
42
+ except RuntimeError as exc: # platform_bearer with no resolvable credential
43
+ print(f"Error: {exc}", file=sys.stderr)
44
+ return 1
45
+ except httpx.HTTPError as exc:
46
+ print(f"Network error: {exc}", file=sys.stderr)
47
+ return 1
48
+
49
+ return wrapper
@@ -0,0 +1,134 @@
1
+ """Small, dependency-free output helpers shared by CLI command groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import sys
10
+ from typing import Any
11
+
12
+
13
+ # Castform brand palette (web-app diagram tokens: blue #3b76f6, orange #f97316),
14
+ # softened for the terminal and tuned per background. We can't query the terminal
15
+ # background synchronously, but many emulators export COLORFGBG ("fg;bg"); when bg
16
+ # reads light we pick deeper, higher-contrast tones, otherwise softer pastels that
17
+ # don't glare on black. Falls back to the dark (pastel) set. Rendered as truecolor.
18
+ def _is_light_terminal() -> bool:
19
+ parts = os.environ.get("COLORFGBG", "").split(";")
20
+ if parts and parts[-1].strip().isdigit():
21
+ return int(parts[-1].strip()) >= 7 # 7/15 = light background
22
+ return False
23
+
24
+
25
+ if _is_light_terminal():
26
+ BLUE = (37, 99, 235) # #2563eb — deeper, reads on white
27
+ ORANGE = (194, 87, 28) # #c2571c — terracotta
28
+ else:
29
+ BLUE = (125, 166, 232) # #7da6e8 — soft sky
30
+ ORANGE = (230, 166, 99) # #e6a663 — soft amber
31
+
32
+ _GREY = (140, 140, 140)
33
+
34
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
35
+
36
+
37
+ def color_enabled() -> bool:
38
+ """True when we should emit ANSI — a real TTY and ``NO_COLOR`` unset."""
39
+ return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
40
+
41
+
42
+ def paint(
43
+ text: str,
44
+ rgb: tuple[int, int, int] | None = None,
45
+ *,
46
+ bold: bool = False,
47
+ italic: bool = False,
48
+ dim: bool = False,
49
+ ) -> str:
50
+ """Wrap ``text`` in ANSI styling, or return it unchanged when color is off."""
51
+ if not color_enabled():
52
+ return text
53
+ codes: list[str] = []
54
+ if bold:
55
+ codes.append("1")
56
+ if dim:
57
+ codes.append("2")
58
+ if italic:
59
+ codes.append("3")
60
+ if rgb is not None:
61
+ codes.append(f"38;2;{rgb[0]};{rgb[1]};{rgb[2]}")
62
+ if not codes:
63
+ return text
64
+ return f"\x1b[{';'.join(codes)}m{text}\x1b[0m"
65
+
66
+
67
+ def _visible_len(s: str) -> int:
68
+ """Length of ``s`` ignoring ANSI escapes — for padding pre-colored text."""
69
+ return len(_ANSI_RE.sub("", s))
70
+
71
+
72
+ def term_width(default: int = 80) -> int:
73
+ return shutil.get_terminal_size((default, 24)).columns
74
+
75
+
76
+ def rule_label(text: str, color: tuple[int, int, int], width: int) -> str:
77
+ """The standard section divider: a centered title flanked by rules, fully
78
+ colored — ``──────── title ────────`` — spanning ``width`` columns."""
79
+ total = max(width - len(text) - 2, 0)
80
+ left = total // 2
81
+ line = "─" * left + f" {text} " + "─" * (total - left)
82
+ return paint(line, color, bold=True)
83
+
84
+
85
+ def boxed(
86
+ lines: list[str],
87
+ *,
88
+ color: tuple[int, int, int],
89
+ width: int,
90
+ title: str = "",
91
+ ) -> list[str]:
92
+ """Render a rounded box ``width`` columns wide (content area) around ``lines``.
93
+
94
+ ``lines`` may already contain ANSI codes or be nested boxes — widths are
95
+ measured ignoring escapes, so padding stays aligned. ``title`` is centered in
96
+ the top border. Returns the box as a list of rendered rows.
97
+ """
98
+ inner = width + 2 # one space of padding on each side
99
+ seg = f" {title} " if title else ""
100
+ fill = max(inner - _visible_len(seg), 0)
101
+ left = fill // 2
102
+ top = "╭" + "─" * left + seg + "─" * (fill - left) + "╮"
103
+ bottom = "╰" + "─" * inner + "╯"
104
+ bar = paint("│", color)
105
+ out = [paint(top, color, bold=True)]
106
+ for ln in lines:
107
+ pad = max(width - _visible_len(ln), 0)
108
+ out.append(f"{bar} {ln}{' ' * pad} {bar}")
109
+ out.append(paint(bottom, color))
110
+ return out
111
+
112
+
113
+ def print_json(obj: Any) -> None:
114
+ """Emit ``obj`` as pretty JSON (``default=str`` so stray types don't crash)."""
115
+ print(_json.dumps(obj, indent=2, default=str))
116
+
117
+
118
+ def render_table(headers: list[str], rows: list[list[Any]]) -> None:
119
+ """Print a left-aligned fixed-width table. No-op styling — pipe-friendly."""
120
+ widths = [len(str(h)) for h in headers]
121
+ for row in rows:
122
+ for i, cell in enumerate(row):
123
+ widths[i] = max(widths[i], len(str(cell)))
124
+ fmt = " ".join("{:<" + str(w) + "}" for w in widths)
125
+ print(fmt.format(*headers))
126
+ for row in rows:
127
+ print(fmt.format(*[str(c) for c in row]))
128
+
129
+
130
+ def fmt_value(value: Any) -> str:
131
+ """Compact numeric formatting for scalar values; pass through non-numbers."""
132
+ if isinstance(value, float):
133
+ return f"{value:.4g}"
134
+ return str(value)