sciworld 0.1.0__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.
sciworld/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """sciworld — research environments for paper reproduction.
2
+
3
+ This package exposes the same commands the ``sw`` CLI provides as Python
4
+ functions. Typical usage from a script or notebook:
5
+
6
+ import sciworld
7
+
8
+ sciworld.research(id="2505.19252", agent="claude-code", mode="scratch",
9
+ loop=True, max_loops=5)
10
+ sciworld.evaluate(id="2505.19252", run="claude-code-scratch")
11
+
12
+ Prompt kwargs accept either a literal string or a ``pathlib.Path`` — the
13
+ file is read at call time.
14
+ """
15
+
16
+ from sciworld._api import (
17
+ research,
18
+ evaluate,
19
+ select,
20
+ info,
21
+ init,
22
+ config,
23
+ )
24
+
25
+ __all__ = [
26
+ "research",
27
+ "evaluate",
28
+ "select",
29
+ "info",
30
+ "init",
31
+ "config",
32
+ ]
sciworld/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m sciworld``."""
2
+ from sciworld.cli import main
3
+ import sys
4
+
5
+ sys.exit(main())
sciworld/_api.py ADDED
@@ -0,0 +1,242 @@
1
+ """Python API mirror of the ``sw`` CLI.
2
+
3
+ Every top-level CLI command has a sibling Python function here. The
4
+ function signature reuses the CLI's kwargs; interactive prompts are
5
+ replaced with ``TypeError`` on missing required args so calling the
6
+ API from a notebook or script never blocks on stdin.
7
+
8
+ Typical usage:
9
+
10
+ import sciworld
11
+ result = sciworld.research(
12
+ id="2505.19252", agent="claude-code", mode="scratch",
13
+ loop=True, max_loops=5,
14
+ system_prompt=Path("~/my_prompts/strict.md"),
15
+ )
16
+ # result = {"arxiv_id": ..., "run_name": ..., "env_dir": Path(...),
17
+ # "launch_script": Path(...), "source_dir": Path(...)}
18
+
19
+ sciworld.evaluate(id="2505.19252", run=result["run_name"])
20
+
21
+ Prompt kwargs (``system_prompt``, ``mode_prompt``, ``continue_prompt``,
22
+ ``test_prompt``, ``compute``) accept either a literal string or a
23
+ ``pathlib.Path``. Paths are read at call time — the resolved content
24
+ is used for that call; later file edits do not affect the running
25
+ session (for live per-iteration binding, see the `live_prompts`
26
+ roadmap item).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from pathlib import Path
32
+ from types import SimpleNamespace
33
+ from typing import Any
34
+
35
+ from sciworld import config as _config
36
+
37
+
38
+ # ── Helpers ────────────────────────────────────────────────
39
+
40
+
41
+ def _resolve_prompt(value: Any) -> str | None:
42
+ """Normalize a prompt kwarg: Path → file content; str → as-is; None → None."""
43
+ if value is None:
44
+ return None
45
+ if isinstance(value, Path):
46
+ return value.expanduser().read_text()
47
+ return str(value)
48
+
49
+
50
+ def _require(**kwargs) -> None:
51
+ """Raise TypeError for any kwarg that's None (required but missing)."""
52
+ missing = [k for k, v in kwargs.items() if v is None]
53
+ if missing:
54
+ raise TypeError(
55
+ f"Missing required argument(s): {', '.join(missing)}"
56
+ )
57
+
58
+
59
+ def _run_with_overrides(callable_, args_ns, overrides: dict) -> int:
60
+ """Run a cmd_* function with prompt overrides active for this call."""
61
+ if not overrides:
62
+ return callable_(args_ns)
63
+ token = _config._instruction_overrides.set(overrides)
64
+ try:
65
+ return callable_(args_ns)
66
+ finally:
67
+ _config._instruction_overrides.reset(token)
68
+
69
+
70
+ # ── research ──────────────────────────────────────────────
71
+
72
+
73
+ def research(
74
+ *,
75
+ id: str,
76
+ agent: str,
77
+ mode: str,
78
+ tag: str | None = None,
79
+ path: str | Path | None = None,
80
+ loop: bool = False,
81
+ max_loops: int = 10,
82
+ setup_only: bool = False,
83
+ refresh: bool = False,
84
+ org: str | None = None,
85
+ system_prompt: str | Path | None = None,
86
+ mode_prompt: str | Path | None = None,
87
+ continue_prompt: str | Path | None = None,
88
+ test_prompt: str | Path | None = None,
89
+ compute: str | Path | None = None,
90
+ ) -> dict:
91
+ """Build (and optionally launch) a research environment.
92
+
93
+ Mirrors ``sw research``. Returns a dict with ``arxiv_id``, ``run_name``,
94
+ ``env_dir``, ``source_dir``, ``launch_script`` — all the paths you need
95
+ to inspect the workspace or tail the agent log.
96
+
97
+ Raises ``TypeError`` if any of the three required kwargs (``id``,
98
+ ``agent``, ``mode``) is missing, and ``RuntimeError`` if the command
99
+ itself exits with a non-zero return code.
100
+ """
101
+ _require(id=id, agent=agent, mode=mode)
102
+
103
+ from sciworld.research_cmd import cmd_research # lazy import
104
+
105
+ args_ns = SimpleNamespace(
106
+ paper_id=id,
107
+ agent=agent,
108
+ mode=mode,
109
+ tag=tag,
110
+ dest_path=str(path) if path else None,
111
+ loop=loop,
112
+ max_loops=max_loops,
113
+ setup_only=setup_only,
114
+ refresh=refresh,
115
+ org=org,
116
+ )
117
+
118
+ overrides: dict[str, str] = {}
119
+ if system_prompt is not None:
120
+ overrides["system_prompt"] = _resolve_prompt(system_prompt)
121
+ if mode_prompt is not None:
122
+ overrides[f"research_{mode}"] = _resolve_prompt(mode_prompt)
123
+ if continue_prompt is not None:
124
+ overrides["continue"] = _resolve_prompt(continue_prompt)
125
+ if test_prompt is not None:
126
+ overrides["test"] = _resolve_prompt(test_prompt)
127
+ if compute is not None:
128
+ overrides["compute"] = _resolve_prompt(compute)
129
+
130
+ rc = _run_with_overrides(cmd_research, args_ns, overrides)
131
+ if rc != 0:
132
+ raise RuntimeError(f"sciworld.research exited with code {rc}")
133
+
134
+ result = getattr(args_ns, "_sw_result", None)
135
+ if result is None:
136
+ raise RuntimeError(
137
+ "sciworld.research completed but produced no result metadata"
138
+ )
139
+ return result
140
+
141
+
142
+ # ── evaluate ──────────────────────────────────────────────
143
+
144
+
145
+ def evaluate(*, id: str, run: str | None = None) -> int:
146
+ """Compare a run's scores to the paper's scores.
147
+
148
+ Mirrors ``sw evaluate``. Returns the command's exit code (0 = success).
149
+ Raises ``TypeError`` if ``id`` is missing.
150
+ """
151
+ _require(id=id)
152
+ from sciworld.evaluate_cmd import cmd_evaluate # lazy import
153
+
154
+ args_ns = SimpleNamespace(arxiv_id=id, run=run)
155
+ return cmd_evaluate(args_ns)
156
+
157
+
158
+ # ── select ────────────────────────────────────────────────
159
+
160
+
161
+ def select(
162
+ *,
163
+ id: str | None = None,
164
+ path: str | Path | None = None,
165
+ refresh: bool = False,
166
+ ) -> int:
167
+ """Select a paper and clone its repo.
168
+
169
+ Mirrors ``sw select``. When ``id`` is given, fetches that paper
170
+ directly; when missing, raises ``TypeError`` (no interactive fallback
171
+ in the Python API).
172
+ """
173
+ _require(id=id)
174
+ from sciworld.select_cmd import cmd_select # lazy import
175
+
176
+ args_ns = SimpleNamespace(
177
+ paper_id=id,
178
+ dest_path=str(path) if path else None,
179
+ refresh=refresh,
180
+ )
181
+ return cmd_select(args_ns)
182
+
183
+
184
+ # ── info ──────────────────────────────────────────────────
185
+
186
+
187
+ def info(*, id: str, full: bool = False) -> int:
188
+ """Show a paper's metadata (overview, problem, experiments, …).
189
+
190
+ Mirrors ``sw info``. Raises ``TypeError`` if ``id`` is missing.
191
+ """
192
+ _require(id=id)
193
+ from sciworld.info_cmd import cmd_info # lazy import
194
+
195
+ args_ns = SimpleNamespace(arxiv_id=id, full=full)
196
+ return cmd_info(args_ns)
197
+
198
+
199
+ # ── config ────────────────────────────────────────────────
200
+
201
+
202
+ def config(section: str | None = None, **overrides) -> dict:
203
+ """View or update configuration.
204
+
205
+ ``sciworld.config()`` → returns the current config as a dict.
206
+ ``sciworld.config(section="...")`` → runs the interactive editor for
207
+ that section; raises ``ValueError`` if the section is unknown.
208
+ """
209
+ if section is None and not overrides:
210
+ return dict(_config.load_config())
211
+
212
+ if section is not None:
213
+ from sciworld.cli import _CONFIG_SECTIONS, cmd_config
214
+ if section not in _CONFIG_SECTIONS:
215
+ valid = ", ".join(_CONFIG_SECTIONS)
216
+ raise ValueError(f"Unknown config section {section!r}. Valid: {valid}")
217
+ args_ns = SimpleNamespace(section=section)
218
+ cmd_config(args_ns)
219
+ return dict(_config.load_config())
220
+
221
+ # If we got here, someone passed raw kwargs with no section. Write them
222
+ # straight into config.json. (Low-level escape hatch.)
223
+ cfg = dict(_config.load_config())
224
+ cfg.update(overrides)
225
+ _config.save_config(cfg)
226
+ return cfg
227
+
228
+
229
+ # ── init ──────────────────────────────────────────────────
230
+
231
+
232
+ def init(*, force: bool = False) -> int:
233
+ """Run the first-time setup wizard (builds the agent SIF, etc.).
234
+
235
+ Mirrors ``sw init``. The underlying command is interactive; calling
236
+ it from a script typically only makes sense with the setup already
237
+ partially complete. ``force=True`` rebuilds the agent container.
238
+ """
239
+ from sciworld.cli import cmd_init # lazy import
240
+
241
+ args_ns = SimpleNamespace(force=force)
242
+ return cmd_init(args_ns)
sciworld/cache.py ADDED
@@ -0,0 +1,42 @@
1
+ """TTL-based cache for GitHub repo data."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from sciworld.config import config_dir
9
+
10
+ CACHE_DIR = config_dir() / "cache"
11
+
12
+
13
+ def cache_path(org: str) -> Path:
14
+ return CACHE_DIR / f"{org}_repos.json"
15
+
16
+
17
+ def load_cached(org: str, ttl: int = 600) -> list[dict] | None:
18
+ """Load cached repo data. Returns None if stale or missing."""
19
+ path = cache_path(org)
20
+ if not path.exists():
21
+ return None
22
+ try:
23
+ data = json.loads(path.read_text())
24
+ if time.time() - data.get("timestamp", 0) > ttl:
25
+ return None
26
+ return data.get("repos", [])
27
+ except (json.JSONDecodeError, KeyError):
28
+ return None
29
+
30
+
31
+ def save_cached(org: str, repos: list[dict]) -> None:
32
+ """Write repos to cache with current timestamp."""
33
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
34
+ data = {"timestamp": time.time(), "repos": repos}
35
+ cache_path(org).write_text(json.dumps(data))
36
+
37
+
38
+ def invalidate(org: str) -> None:
39
+ """Delete cache for an org."""
40
+ path = cache_path(org)
41
+ if path.exists():
42
+ path.unlink()