researchium 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.
@@ -0,0 +1,59 @@
1
+ """Researchium - a research module for organizing and analyzing scientific work."""
2
+
3
+ from researchium.environment import (
4
+ Environment,
5
+ EnvironmentExistsError,
6
+ EnvironmentNotFoundError,
7
+ ResearchiumError,
8
+ discover_environment,
9
+ initialize_environment,
10
+ is_initialized,
11
+ )
12
+ from researchium.experiments import (
13
+ Experiment,
14
+ ExperimentExistsError,
15
+ ExperimentNotFoundError,
16
+ InvalidExperimentNameError,
17
+ create_experiment,
18
+ get_experiment,
19
+ get_experiment_results,
20
+ list_experiments,
21
+ search_experiments,
22
+ )
23
+ from researchium.runtime import (
24
+ Config,
25
+ Results,
26
+ RunFailedError,
27
+ RunScriptNotFoundError,
28
+ format_configuration,
29
+ format_run_results,
30
+ run_experiment,
31
+ )
32
+
33
+ __all__ = [
34
+ "Config",
35
+ "Environment",
36
+ "EnvironmentExistsError",
37
+ "EnvironmentNotFoundError",
38
+ "Experiment",
39
+ "ExperimentExistsError",
40
+ "ExperimentNotFoundError",
41
+ "InvalidExperimentNameError",
42
+ "ResearchiumError",
43
+ "Results",
44
+ "RunFailedError",
45
+ "RunScriptNotFoundError",
46
+ "create_experiment",
47
+ "discover_environment",
48
+ "format_configuration",
49
+ "format_run_results",
50
+ "get_experiment",
51
+ "get_experiment_results",
52
+ "initialize_environment",
53
+ "is_initialized",
54
+ "list_experiments",
55
+ "run_experiment",
56
+ "search_experiments",
57
+ ]
58
+
59
+ __version__ = "0.1.0"
researchium/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """Command-line interface for Researchium."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from researchium import (
10
+ ResearchiumError,
11
+ create_experiment,
12
+ format_configuration,
13
+ format_run_results,
14
+ initialize_environment,
15
+ run_experiment,
16
+ search_experiments,
17
+ )
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="researchium", description="Organize and track research experiments."
23
+ )
24
+ subparsers = parser.add_subparsers(dest="command", required=True)
25
+
26
+ init = subparsers.add_parser("init", help="Initialize a research environment.")
27
+ init.add_argument("name", help="Name of the research project.")
28
+
29
+ create = subparsers.add_parser("create", help="Create a new experiment.")
30
+ create.add_argument("name", help="Experiment name (used as the folder name).")
31
+ create.add_argument("description", help="Brief description of the experiment.")
32
+
33
+ search = subparsers.add_parser(
34
+ "search", help="Search experiments by name or description."
35
+ )
36
+ search.add_argument("query", help="Text to search for.")
37
+
38
+ config = subparsers.add_parser(
39
+ "config", help="Show an experiment's configuration as key: value lines."
40
+ )
41
+ config.add_argument("identifier", help="ID or name of the experiment.")
42
+
43
+ results = subparsers.add_parser(
44
+ "results", help="Show an experiment's results as key: value lines."
45
+ )
46
+ results.add_argument("identifier", help="ID or name of the experiment.")
47
+
48
+ run = subparsers.add_parser(
49
+ "run", help="Run an experiment's script and store its config and results."
50
+ )
51
+ run.add_argument("identifier", help="ID or name of the experiment.")
52
+
53
+ return parser
54
+
55
+
56
+ def main(argv: Sequence[str] | None = None) -> int:
57
+ args = build_parser().parse_args(argv)
58
+ try:
59
+ return _dispatch(args)
60
+ except ResearchiumError as exc:
61
+ print(f"error: {exc}", file=sys.stderr)
62
+ return 1
63
+
64
+
65
+ def _dispatch(args: argparse.Namespace) -> int:
66
+ if args.command == "init":
67
+ env = initialize_environment(name=args.name)
68
+ print(f"Initialized researchium environment at {env.root}")
69
+ return 0
70
+
71
+ if args.command == "create":
72
+ experiment = create_experiment(args.name, args.description)
73
+ print(
74
+ f"Created experiment {experiment.name} "
75
+ f"(ID {experiment.id}) at {experiment.path}"
76
+ )
77
+ return 0
78
+
79
+ if args.command == "search":
80
+ experiments = search_experiments(args.query)
81
+ if not experiments:
82
+ print(f"No experiments matching {args.query!r}.")
83
+ return 0
84
+ for experiment in experiments:
85
+ print(
86
+ f"- {experiment.name} (ID {experiment.id}): {experiment.description}"
87
+ )
88
+ return 0
89
+
90
+ if args.command == "config":
91
+ print(format_configuration(args.identifier))
92
+ return 0
93
+
94
+ if args.command == "results":
95
+ print(format_run_results(args.identifier))
96
+ return 0
97
+
98
+ if args.command == "run":
99
+ experiment = run_experiment(args.identifier)
100
+ print(f"Ran experiment {experiment.name} (ID {experiment.id})")
101
+ print(f" config -> experiments/{experiment.name}/config.md, config.json")
102
+ print(f" results -> results/{experiment.name}/results.md, results.json")
103
+ return 0
104
+
105
+ raise SystemExit("unknown command") # pragma: no cover
106
+
107
+
108
+ if __name__ == "__main__": # pragma: no cover
109
+ raise SystemExit(main())
@@ -0,0 +1,179 @@
1
+ """Environment awareness: initialize and discover Researchium environments.
2
+
3
+ An environment is a directory carrying a hidden ``.researchium/`` marker,
4
+ mirroring how ``git`` owns a directory with ``.git`` and ``uv`` owns one with
5
+ ``uv.lock``. Any command that needs context reports the nearest environment
6
+ found by walking up from the current directory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from datetime import date
14
+ from pathlib import Path
15
+
16
+ import researchium
17
+
18
+ MARKER_DIR_NAME = ".researchium"
19
+ METADATA_FILE_NAME = "environment.json"
20
+ EXPERIMENTS_NOTEBOOK = "experiments.md"
21
+ EXPERIMENTS_REGISTRY = "experiments.json"
22
+ REGISTRY_VERSION = 1
23
+
24
+
25
+ class ResearchiumError(Exception):
26
+ """Base error raised by Researchium."""
27
+
28
+
29
+ class EnvironmentExistsError(ResearchiumError):
30
+ """Raised when initializing an already-initialized directory."""
31
+
32
+
33
+ class EnvironmentNotFoundError(ResearchiumError):
34
+ """Raised when no environment can be discovered from a directory."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Environment:
39
+ """A discovered Researchium environment."""
40
+
41
+ root: Path
42
+ name: str
43
+ created: date
44
+ researchium_version: str
45
+
46
+ @property
47
+ def experiments_dir(self) -> Path:
48
+ return self.root / "experiments"
49
+
50
+ @property
51
+ def src_dir(self) -> Path:
52
+ return self.root / "src"
53
+
54
+ @property
55
+ def results_dir(self) -> Path:
56
+ return self.root / "results"
57
+
58
+ @property
59
+ def experiments_notebook(self) -> Path:
60
+ return self.root / EXPERIMENTS_NOTEBOOK
61
+
62
+ @property
63
+ def experiments_registry(self) -> Path:
64
+ return self.root / EXPERIMENTS_REGISTRY
65
+
66
+
67
+ def initialize_environment(
68
+ path: str | Path = ".", name: str | None = None
69
+ ) -> Environment:
70
+ """Create the standard Researchium structure and register the directory.
71
+
72
+ Args:
73
+ path: Directory to initialize. Created if it does not exist.
74
+ name: Environment name. Defaults to the directory's basename.
75
+
76
+ Returns:
77
+ The newly created ``Environment``.
78
+
79
+ Raises:
80
+ EnvironmentExistsError: If ``path`` is already a Researchium environment.
81
+ """
82
+ root = Path(path).resolve()
83
+ if (root / MARKER_DIR_NAME).exists():
84
+ raise EnvironmentExistsError(f"{root} is already a Researchium environment.")
85
+
86
+ experiments = root / "experiments"
87
+ src = root / "src"
88
+ results = root / "results"
89
+
90
+ experiments.mkdir(parents=True, exist_ok=True)
91
+ src.mkdir(parents=True, exist_ok=True)
92
+ results.mkdir(parents=True, exist_ok=True)
93
+
94
+ _write_experiments_notebook(root, name)
95
+ _write_experiments_registry(root)
96
+ marker = _write_marker(root, name)
97
+
98
+ return _load_environment(marker)
99
+
100
+
101
+ def is_initialized(path: str | Path = ".") -> bool:
102
+ """Return ``True`` if ``path`` is directly an initialized environment."""
103
+ return (Path(path).resolve() / MARKER_DIR_NAME).is_dir()
104
+
105
+
106
+ def discover_environment(path: str | Path = ".") -> Environment:
107
+ """Return the nearest environment at ``path``, walking up parent directories.
108
+
109
+ Raises:
110
+ EnvironmentNotFoundError: If no ancestor directory is initialized.
111
+ """
112
+ start = Path(path).resolve()
113
+ for current in (start, *start.parents):
114
+ marker = current / MARKER_DIR_NAME
115
+ if marker.is_dir():
116
+ return _load_environment(marker)
117
+ raise EnvironmentNotFoundError(
118
+ f"No Researchium environment found at or above {start}. "
119
+ f"Run `researchium init` to create one."
120
+ )
121
+
122
+
123
+ def _write_experiments_notebook(root: Path, name: str | None) -> None:
124
+ created = date.today()
125
+ header = name or root.name
126
+ content = (
127
+ f"# {header}\n"
128
+ "\n"
129
+ "Researchium environment initialized on "
130
+ f"{created.isoformat()}.\n"
131
+ "\n"
132
+ "## Experiments\n"
133
+ "\n"
134
+ "Each experiment has a folder in `experiments/` and a section below.\n"
135
+ )
136
+ (root / EXPERIMENTS_NOTEBOOK).write_text(content, encoding="utf-8")
137
+
138
+
139
+ def load_experiments_registry(path: str | Path) -> list[dict]:
140
+ """Load experiment metadata records from ``path`` (empty list if absent)."""
141
+ registry = Path(path)
142
+ if not registry.exists():
143
+ return []
144
+ data = json.loads(registry.read_text(encoding="utf-8"))
145
+ return data["experiments"]
146
+
147
+
148
+ def save_experiments_registry(path: str | Path, experiments: list[dict]) -> None:
149
+ """Write experiment metadata records to ``path``."""
150
+ data = {"version": REGISTRY_VERSION, "experiments": experiments}
151
+ Path(path).write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
152
+
153
+
154
+ def _write_experiments_registry(root: Path) -> None:
155
+ save_experiments_registry(root / EXPERIMENTS_REGISTRY, [])
156
+
157
+
158
+ def _write_marker(root: Path, name: str | None) -> Path:
159
+ marker = root / MARKER_DIR_NAME
160
+ marker.mkdir(parents=True, exist_ok=True)
161
+ metadata = {
162
+ "name": name or root.name,
163
+ "created": date.today().isoformat(),
164
+ "researchium": researchium.__version__,
165
+ }
166
+ (marker / METADATA_FILE_NAME).write_text(
167
+ json.dumps(metadata, indent=2) + "\n", encoding="utf-8"
168
+ )
169
+ return marker
170
+
171
+
172
+ def _load_environment(marker: Path) -> Environment:
173
+ metadata = json.loads((marker / METADATA_FILE_NAME).read_text(encoding="utf-8"))
174
+ return Environment(
175
+ root=marker.parent,
176
+ name=metadata["name"],
177
+ created=date.fromisoformat(metadata["created"]),
178
+ researchium_version=metadata["researchium"],
179
+ )
@@ -0,0 +1,213 @@
1
+ """Experiment management inside a Researchium environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ from pathlib import Path
10
+
11
+ from researchium.environment import (
12
+ Environment,
13
+ ResearchiumError,
14
+ discover_environment,
15
+ load_experiments_registry,
16
+ save_experiments_registry,
17
+ )
18
+
19
+
20
+ class ExperimentExistsError(ResearchiumError):
21
+ """Raised when an experiment name is already in use."""
22
+
23
+
24
+ class InvalidExperimentNameError(ResearchiumError):
25
+ """Raised when an experiment name is not a safe folder name."""
26
+
27
+
28
+ class ExperimentNotFoundError(ResearchiumError):
29
+ """Raised when no experiment matches a given ID or name."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Experiment:
34
+ """A single experiment living inside an environment's experiments folder."""
35
+
36
+ id: str
37
+ name: str
38
+ description: str
39
+ created: date
40
+ environment: Environment
41
+
42
+ @property
43
+ def path(self) -> Path:
44
+ return self.environment.experiments_dir / self.name
45
+
46
+ @property
47
+ def results_dir(self) -> Path:
48
+ return self.environment.results_dir / self.name
49
+
50
+ def to_metadata(self) -> dict:
51
+ """Return the record stored in ``experiments.json``."""
52
+ return {
53
+ "id": self.id,
54
+ "name": self.name,
55
+ "description": self.description,
56
+ "created": self.created.isoformat(),
57
+ }
58
+
59
+
60
+ def create_experiment(
61
+ name: str, description: str, path: str | Path = "."
62
+ ) -> Experiment:
63
+ """Create an experiment folder and log it in the experiments notebook.
64
+
65
+ Args:
66
+ name: Experiment name, also used as the folder name.
67
+ description: Brief description logged in ``experiments.md``.
68
+ path: Where to start looking for the environment. Defaults to the
69
+ current directory and walks up from there.
70
+
71
+ Returns:
72
+ The created ``Experiment``.
73
+
74
+ Raises:
75
+ EnvironmentNotFoundError: If no environment is found at ``path``.
76
+ InvalidExperimentNameError: If ``name`` is empty or a path separator.
77
+ ExperimentExistsError: If the experiment folder or notebook entry
78
+ already exists.
79
+ """
80
+ env = discover_environment(path)
81
+ _validate_name(name)
82
+
83
+ folder = env.experiments_dir / name
84
+ if folder.exists():
85
+ raise ExperimentExistsError(f"Experiment {name!r} already exists at {folder}.")
86
+ if _has_notebook_entry(env.experiments_notebook, name):
87
+ raise ExperimentExistsError(
88
+ f"Experiment {name!r} is already logged in the experiments notebook."
89
+ )
90
+
91
+ created = date.today()
92
+ experiment = Experiment(
93
+ id=_generate_id(),
94
+ name=name,
95
+ description=description,
96
+ created=created,
97
+ environment=env,
98
+ )
99
+ folder.mkdir()
100
+ experiment.results_dir.mkdir(parents=True)
101
+ _append_notebook_entry(
102
+ env.experiments_notebook, experiment.id, name, description, created
103
+ )
104
+ _add_to_registry(env, experiment)
105
+
106
+ return experiment
107
+
108
+
109
+ def list_experiments(path: str | Path = ".") -> list[Experiment]:
110
+ """Return all experiments in the environment at ``path``, sorted by name."""
111
+ env = discover_environment(path)
112
+ records = load_experiments_registry(env.experiments_registry)
113
+ experiments = [_experiment_from_record(env, record) for record in records]
114
+ return sorted(experiments, key=lambda experiment: experiment.name)
115
+
116
+
117
+ def search_experiments(query: str, path: str | Path = ".") -> list[Experiment]:
118
+ """Return experiments whose name or description contains ``query``."""
119
+ needle = query.strip().lower()
120
+ if not needle:
121
+ return []
122
+ return [
123
+ experiment
124
+ for experiment in list_experiments(path)
125
+ if needle in experiment.name.lower()
126
+ or needle in experiment.description.lower()
127
+ ]
128
+
129
+
130
+ def get_experiment(identifier: str, path: str | Path = ".") -> Experiment:
131
+ """Return the experiment registered under an ID or name.
132
+
133
+ IDs are checked first, then names (case-insensitive).
134
+
135
+ Raises:
136
+ ExperimentNotFoundError: If no experiment matches ``identifier``.
137
+ """
138
+ experiments = list_experiments(path)
139
+ for experiment in experiments:
140
+ if experiment.id == identifier:
141
+ return experiment
142
+ for experiment in experiments:
143
+ if experiment.name.lower() == identifier.lower():
144
+ return experiment
145
+ raise ExperimentNotFoundError(
146
+ f"No experiment with ID or name {identifier!r}."
147
+ )
148
+
149
+
150
+ def get_experiment_results(
151
+ identifier: str, path: str | Path = "."
152
+ ) -> list[Path]:
153
+ """Return the result files of an experiment, sorted by name.
154
+
155
+ ``identifier`` may be an experiment ID or name.
156
+ """
157
+ experiment = get_experiment(identifier, path)
158
+ results = experiment.results_dir
159
+ if not results.is_dir():
160
+ return []
161
+ return sorted(path for path in results.iterdir() if path.is_file())
162
+
163
+
164
+ def _experiment_from_record(env: Environment, record: dict) -> Experiment:
165
+ return Experiment(
166
+ id=record["id"],
167
+ name=record["name"],
168
+ description=record["description"],
169
+ created=date.fromisoformat(record["created"]),
170
+ environment=env,
171
+ )
172
+
173
+
174
+ def _generate_id() -> str:
175
+ return uuid.uuid4().hex[:8]
176
+
177
+
178
+ def _validate_name(name: str) -> None:
179
+ if not name or name.strip() in {".", ".."}:
180
+ raise InvalidExperimentNameError("Experiment name may not be empty.")
181
+ if "/" in name or "\\" in name:
182
+ raise InvalidExperimentNameError(
183
+ "Experiment name may not contain path separators."
184
+ )
185
+
186
+
187
+ def _has_notebook_entry(notebook: Path, name: str) -> bool:
188
+ if not notebook.exists():
189
+ return False
190
+ text = notebook.read_text(encoding="utf-8")
191
+ return re.search(rf"^### {re.escape(name)}\s*$", text, re.MULTILINE) is not None
192
+
193
+
194
+ def _append_notebook_entry(
195
+ notebook: Path, experiment_id: str, name: str, description: str, created: date
196
+ ) -> None:
197
+ entry = (
198
+ f"\n### {name}\n"
199
+ f"\n"
200
+ f"{description}\n"
201
+ f"\n"
202
+ f"- ID: `{experiment_id}`\n"
203
+ f"- Created: {created.isoformat()}\n"
204
+ f"- Folder: `experiments/{name}`\n"
205
+ )
206
+ with notebook.open("a", encoding="utf-8") as handle:
207
+ handle.write(entry)
208
+
209
+
210
+ def _add_to_registry(env: Environment, experiment: Experiment) -> None:
211
+ experiments = load_experiments_registry(env.experiments_registry)
212
+ experiments.append(experiment.to_metadata())
213
+ save_experiments_registry(env.experiments_registry, experiments)
researchium/runtime.py ADDED
@@ -0,0 +1,182 @@
1
+ """Ephemeral run-time registries and the experiment runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import runpy
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from researchium.environment import ResearchiumError
11
+ from researchium.experiments import Experiment, get_experiment
12
+
13
+
14
+ class RunScriptNotFoundError(ResearchiumError):
15
+ """Raised when an experiment has no run script."""
16
+
17
+
18
+ class RunFailedError(ResearchiumError):
19
+ """Raised when an experiment's run script raises."""
20
+
21
+
22
+ class RunStorage(dict):
23
+ """Mutable registry that a run script fills in at runtime."""
24
+
25
+
26
+ Config = RunStorage()
27
+ Results = RunStorage()
28
+
29
+
30
+ def run_experiment(identifier: str, path: str | Path = ".") -> Experiment:
31
+ """Execute an experiment's run script and persist its config and results.
32
+
33
+ Runs ``experiments/<name>/run.py`` with a fresh ``Config``/``Results``,
34
+ then writes:
35
+
36
+ - ``experiments/<name>/config.md`` / ``config.json``
37
+ - ``results/<name>/results.md`` / ``results.json``
38
+
39
+ Args:
40
+ identifier: Experiment ID or name.
41
+ path: Where to start looking for the environment.
42
+
43
+ Returns:
44
+ The executed ``Experiment``.
45
+
46
+ Raises:
47
+ RunScriptNotFoundError: If the experiment has no ``run.py``.
48
+ RunFailedError: If the run script raises.
49
+ """
50
+ experiment = get_experiment(identifier, path)
51
+ script = experiment.path / "run.py"
52
+ if not script.is_file():
53
+ raise RunScriptNotFoundError(
54
+ f"Experiment {experiment.name!r} has no run script at {script}. "
55
+ f"Create experiments/{experiment.name}/run.py first."
56
+ )
57
+
58
+ Config.clear()
59
+ Results.clear()
60
+ try:
61
+ runpy.run_path(str(script), run_name="__main__")
62
+ except Exception as exc:
63
+ raise RunFailedError(
64
+ f"Experiment {experiment.name!r} failed during run: {exc}"
65
+ ) from exc
66
+
67
+ write_run_outputs(experiment)
68
+ return experiment
69
+
70
+
71
+ def write_run_outputs(experiment: Experiment) -> list[Path]:
72
+ """Write the current ``Config``/``Results`` for an experiment to disk."""
73
+ run_at = datetime.now().strftime("%Y-%m-%d %H:%M")
74
+ config_md = experiment.path / "config.md"
75
+ config_md.write_text(
76
+ _format_document(experiment, "Configuration", Config, run_at),
77
+ encoding="utf-8",
78
+ )
79
+
80
+ experiment.results_dir.mkdir(parents=True, exist_ok=True)
81
+ results_md = experiment.results_dir / "results.md"
82
+ results_md.write_text(
83
+ _format_document(experiment, "Results", Results, run_at),
84
+ encoding="utf-8",
85
+ )
86
+
87
+ _remove_stale_readme(experiment)
88
+ _write_json(experiment.path / "config.json", Config)
89
+ _write_json(experiment.results_dir / "results.json", Results)
90
+
91
+ return [
92
+ config_md,
93
+ experiment.path / "config.json",
94
+ results_md,
95
+ experiment.results_dir / "results.json",
96
+ ]
97
+
98
+
99
+ def format_configuration(identifier: str, path: str | Path = ".") -> str:
100
+ """Return an experiment's configuration as ``key: value`` lines."""
101
+ experiment = get_experiment(identifier, path)
102
+ config_json = experiment.path / "config.json"
103
+ if not config_json.is_file():
104
+ return "(no configuration yet)"
105
+ records = json.loads(config_json.read_text(encoding="utf-8"))
106
+ return _format_key_values(records)
107
+
108
+
109
+ def format_run_results(identifier: str, path: str | Path = ".") -> str:
110
+ """Return an experiment's results as ``key: value`` lines.
111
+
112
+ Falls back to a file listing when the experiment has no ``results.json``.
113
+ """
114
+ experiment = get_experiment(identifier, path)
115
+ results_json = experiment.results_dir / "results.json"
116
+ if results_json.is_file():
117
+ records = json.loads(results_json.read_text(encoding="utf-8"))
118
+ return _format_key_values(records)
119
+ return _format_results_listing(experiment)
120
+
121
+
122
+ def _format_key_values(records: dict) -> str:
123
+ if not records:
124
+ return "(none)"
125
+ return "\n".join(
126
+ f"{key}: {_inline(value)}" for key, value in records.items()
127
+ )
128
+
129
+
130
+ def _format_document(
131
+ experiment: Experiment, title: str, registry: RunStorage, run_at: str
132
+ ) -> str:
133
+ lines = [f"# {experiment.name}", "", f"Last run: {run_at}", "", f"## {title}", ""]
134
+ if not registry:
135
+ lines.append("(none)")
136
+ for key, value in registry.items():
137
+ lines.append(f"- **{key}**: {_inline(value)}")
138
+ return "\n".join(lines) + "\n"
139
+
140
+
141
+ def _format_results_listing(experiment: Experiment) -> str:
142
+ results_dir = experiment.results_dir
143
+ lines = [
144
+ f"Experiment: {experiment.name} (ID {experiment.id})",
145
+ f"Results ({results_dir}):",
146
+ ]
147
+ if results_dir.is_dir():
148
+ results = sorted(path for path in results_dir.iterdir() if path.is_file())
149
+ else:
150
+ results = []
151
+ if not results:
152
+ lines.append(" (no results yet)")
153
+ return "\n".join(lines)
154
+ for result in results:
155
+ lines.append(f" - {result.name} ({_format_size(result.stat().st_size)})")
156
+ return "\n".join(lines)
157
+
158
+
159
+ def _inline(value: object) -> str:
160
+ if isinstance(value, (dict, list, tuple)):
161
+ return json.dumps(value)
162
+ return str(value)
163
+
164
+
165
+ def _format_size(num_bytes: int) -> str:
166
+ for unit in ("B", "KB", "MB", "GB"):
167
+ if num_bytes < 1024:
168
+ return f"{num_bytes:.0f} {unit}"
169
+ num_bytes /= 1024
170
+ return f"{num_bytes:.1f} TB"
171
+
172
+
173
+ def _write_json(path: Path, storage: RunStorage) -> None:
174
+ path.write_text(
175
+ json.dumps(storage, indent=2, default=str) + "\n", encoding="utf-8"
176
+ )
177
+
178
+
179
+ def _remove_stale_readme(experiment: Experiment) -> None:
180
+ readme = experiment.path / "readme.md"
181
+ if readme.exists():
182
+ readme.unlink()
@@ -0,0 +1,475 @@
1
+ Metadata-Version: 2.5
2
+ Name: researchium
3
+ Version: 0.1.0
4
+ Summary: A research module for organizing and analyzing scientific work.
5
+ Project-URL: Homepage, https://github.com/samTheComputerArchitect/researchium
6
+ Project-URL: Repository, https://github.com/samTheComputerArchitect/researchium
7
+ Author: Samarth Javagal
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: analysis,experiments,research,science
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Requires-Python: >=3.10
24
+ Provides-Extra: dev
25
+ Requires-Dist: build==1.6.1; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Requires-Dist: twine==7.0.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # Researchium
33
+
34
+ [![PyPI - Version](https://img.shields.io/pypi/v/researchium.svg)](https://pypi.org/project/researchium) [![PyPI - Python Versions](https://img.shields.io/pypi/pyversions/researchium.svg)](https://pypi.org/project/researchium) [![PyPI - License](https://img.shields.io/pypi/l/researchium.svg)](LICENSE) [![Build Status](https://github.com/samTheComputerArchitect/researchium/actions/workflows/ci.yml/badge.svg)](https://github.com/samTheComputerArchitect/researchium/actions/workflows/ci.yml) [![PyPI - Downloads](https://img.shields.io/pypi/dm/researchium.svg)](https://pypi.org/project/researchium)
35
+
36
+ **Version 0.1.0**
37
+
38
+ Researchium is a small, opinionated toolkit for organizing scientific work into
39
+ self-contained **environments** and tracking **experiments** within them. It
40
+ gives every experiment a stable ID, a home folder, a runnable script, and a
41
+ place for its configuration and results — all stored as plain, human-readable
42
+ files so nothing is locked in a database.
43
+
44
+ It is built around three ideas:
45
+
46
+ 1. **An environment owns a directory.** `researchium init` lays down a standard
47
+ structure (mirroring what `git` does with `.git` and `uv` with `uv.lock`).
48
+ Every subsequent command discovers the environment by walking up from the
49
+ current directory, so you can work from any subfolder.
50
+ 2. **Experiments are first-class.** Each experiment is a folder in
51
+ `experiments/`, has a unique short ID, and keeps its configuration and
52
+ results in its own location.
53
+ 3. **Run scripts fill config and results.** Write a normal Python script that
54
+ imports `Config` and `Results` from `researchium`. `researchium run` executes
55
+ it and snapshots both registries into Markdown *and* JSON files — ready for
56
+ humans and machines.
57
+
58
+ ---
59
+
60
+ ## Table of contents
61
+
62
+ - [Quick start](#quick-start)
63
+ - [The environment layout](#the-environment-layout)
64
+ - [Command-line usage](#command-line-usage)
65
+ - [Writing run scripts](#writing-run-scripts)
66
+ - [Python API](#python-api)
67
+ - [File formats](#file-formats)
68
+ - [Error handling](#error-handling)
69
+ - [Development](#development)
70
+ - [Project layout](#project-layout)
71
+
72
+ ---
73
+
74
+ ## Quick start
75
+
76
+ ```bash
77
+ researchium init "Climate Genomics" # lay down an environment
78
+ researchium create alpine-transcriptomics \
79
+ "RNA-seq of alpine vs lowland populations." # register an experiment
80
+ ```
81
+
82
+ Write a run script:
83
+
84
+ ```python
85
+ # experiments/alpine-transcriptomics/run.py
86
+ from researchium import Config, Results
87
+
88
+ Config["epochs"] = 10
89
+ Config["learning_rate"] = 0.001
90
+
91
+ Results["mse"] = 0.032
92
+ Results["r2"] = 0.91
93
+ ```
94
+
95
+ Run it and inspect:
96
+
97
+ ```bash
98
+ researchium run alpine-transcriptomics # or by ID: researchium run 1e70f7c3
99
+ researchium config alpine-transcriptomics # epochs: 10
100
+ researchium results alpine-transcriptomics # mse: 0.032
101
+ researchium search alpine # find experiments and their IDs
102
+ ```
103
+
104
+ ---
105
+
106
+ ## The environment layout
107
+
108
+ `researchium init NAME` creates a directory with this structure:
109
+
110
+ ```
111
+ my-study/
112
+ ├── .researchium/ # marker + environment metadata (like .git/)
113
+ │ └── environment.json
114
+ ├── experiments/ # one folder per experiment
115
+ ├── results/ # one folder per experiment, mirroring experiments/
116
+ ├── src/ # reusable code for the project
117
+ ├── experiments.md # human log of every experiment
118
+ └── experiments.json # machine-readable registry of experiments
119
+ ```
120
+
121
+ After registering and running an experiment, an experiment looks like:
122
+
123
+ ```
124
+ experiments/
125
+ └── alpine-transcriptomics/
126
+ ├── run.py # you write this
127
+ ├── config.md # written by `run`
128
+ └── config.json
129
+ results/
130
+ └── alpine-transcriptomics/
131
+ ├── results.md
132
+ └── results.json
133
+ ```
134
+
135
+ Key details:
136
+
137
+ - **The `.researchium/` marker is what makes a directory an environment.**
138
+ `discover_environment()` walks up parent directories to find it, so commands
139
+ work from `results/alpine-transcriptomics/`, `src/`, or anywhere below the
140
+ root.
141
+ - **Result folders are keyed by experiment name** (`results/<name>/`), which
142
+ keeps them readable while `researchium results <name_or_id>` still resolves
143
+ either form.
144
+ - **Results and config are stored as both Markdown (for humans) and JSON (for
145
+ tooling).**
146
+
147
+ ---
148
+
149
+ ## Command-line usage
150
+
151
+ Every command accepts an experiment either by **exact name** or by **ID**.
152
+
153
+ | Command | Description |
154
+ | -------------------------------- | ------------------------------------------------------------------ |
155
+ | `researchium init NAME` | Create an environment named `NAME` in the current directory. |
156
+ | `researchium create NAME DESC` | Register an experiment: folder + log entry + registry record. |
157
+ | `researchium search QUERY` | Find experiments matching name or description (case-insensitive). |
158
+ | `researchium run NAME_OR_ID` | Execute `experiments/<name>/run.py`, snapshot config + results. |
159
+ | `researchium config NAME_OR_ID` | Print the experiment config as `key: value` lines. |
160
+ | `researchium results NAME_OR_ID` | Print the experiment results as `key: value` lines. |
161
+
162
+ ### `init`
163
+
164
+ ```bash
165
+ $ researchium init "Climate Genomics"
166
+ Initialized researchium environment at /path/to/project
167
+ ```
168
+
169
+ Creating an environment where one already exists fails with exit code `1`.
170
+
171
+ ### `create`
172
+
173
+ ```bash
174
+ $ researchium create alpine-transcriptomics "RNA-seq of alpine vs lowland populations."
175
+ Created experiment alpine-transcriptomics (ID 1e70f7c3) at .../experiments/alpine-transcriptomics
176
+ ```
177
+
178
+ `create` refuses empty or path-traversal names (e.g. `../x`) and duplicate
179
+ names. It also creates the experiment's `results/<name>/` folder and appends a
180
+ date/description/ID entry to `experiments.md`.
181
+
182
+ ### `search`
183
+
184
+ ```bash
185
+ $ researchium search alpine
186
+ - alpine-transcriptomics (ID 1e70f7c3): RNA-seq of alpine vs lowland populations.
187
+ ```
188
+
189
+ Matching is case-insensitive over both name and description. No matches prints
190
+ `No experiments matching 'query'.` and still exits `0`.
191
+
192
+ ### `run`
193
+
194
+ ```bash
195
+ $ researchium run alpine-transcriptomics
196
+ Ran experiment alpine-transcriptomics (ID 1e70f7c3)
197
+ config -> experiments/alpine-transcriptomics/config.md, config.json
198
+ results -> results/alpine-transcriptomics/results.md, results.json
199
+ ```
200
+
201
+ `run` clears the previous `Config`/`Results`, executes
202
+ `experiments/<name>/run.py`, then writes four files. It fails with exit code
203
+ `1` if the script is missing or the script raises. See
204
+ [Writing run scripts](#writing-run-scripts).
205
+
206
+ ### `config` and `results`
207
+
208
+ ```bash
209
+ $ researchium config alpine
210
+ epochs: 10
211
+ learning_rate: 0.001
212
+
213
+ $ researchium results alpine
214
+ mse: 0.032
215
+ r2: 0.91
216
+ ```
217
+
218
+ Structured values (lists, dicts) print as compact JSON, e.g.
219
+ `metrics: {"loss": 0.2}`. Before the first run, `config` prints
220
+ `(no configuration yet)`; `results` falls back to a file listing of the
221
+ results folder.
222
+
223
+ ---
224
+
225
+ ## Writing run scripts
226
+
227
+ A run script is any Python file named `run.py` inside an experiment folder. It
228
+ should import `Config`/`Results` and set values by key:
229
+
230
+ ```python
231
+ from researchium import Config, Results
232
+
233
+ import numpy as np
234
+ from sklearn.model_selection import train_test_split
235
+
236
+ Config["epochs"] = 10
237
+ Config["optimizer"] = "adam"
238
+ Config["layers"] = [256, 128, 64]
239
+
240
+ # ... your training code ...
241
+ accuracy = 0.93
242
+ Results["accuracy"] = accuracy
243
+ Results["metrics"] = {"precision": 0.91, "recall": 0.89}
244
+ ```
245
+
246
+ - `Config` and `Results` are plain mutable registries (dict-like). Use any key
247
+ you like; order of the printed/`.md` output follows insertion order.
248
+ - Values that are lists, tuples, or dicts are stored as JSON; any other object
249
+ is stored via `str()` — so non-JSON values (e.g. numpy types) never crash a
250
+ `run`.
251
+ - The registries are **cleared before every run**, so runs never leak state.
252
+ - `run` executes the script with `runpy`, as `__main__`. Anything the script
253
+ `print()`s appears on the CLI output.
254
+
255
+ ---
256
+
257
+ ## Python API
258
+
259
+ Everything below is importable from the top-level `researchium` package.
260
+
261
+ ### Environments
262
+
263
+ | Function | Description |
264
+ | ----------------------------------------- | -------------------------------------------------- |
265
+ | `initialize_environment(path, name=None)` | Create the standard structure + `.researchium/` marker. Returns `Environment`. |
266
+ | `is_initialized(path)` | `True` if `path` is directly an environment root. |
267
+ | `discover_environment(path)` | Nearest environment at/above `path`. Raises `EnvironmentNotFoundError`. |
268
+ | `Environment` (dataclass) | `root`, `name`, `created`, `researchium_version`; properties `experiments_dir`, `src_dir`, `results_dir`, `experiments_notebook`, `experiments_registry`. |
269
+ | `load_experiments_registry(path)` | Read experiment records from `experiments.json`. |
270
+ | `save_experiments_registry(path, records)`| Write experiment records to `experiments.json`. |
271
+
272
+ ### Experiments
273
+
274
+ | Function | Description |
275
+ | ---------------------------------------------- | -------------------------------------------------- |
276
+ | `create_experiment(name, description, path)` | Register an experiment and return it. Raises `ExperimentExistsError`. |
277
+ | `list_experiments(path)` | All experiments, sorted by name. |
278
+ | `search_experiments(query, path)` | Experiments matching name/description. |
279
+ | `get_experiment(identifier, path)` | Look up by ID or name. Raises `ExperimentNotFoundError`. |
280
+ | `get_experiment_results(identifier, path)` | Result files (paths) of an experiment. |
281
+ | `Experiment` (dataclass) | `id`, `name`, `description`, `created`, `environment`; properties `path`, `results_dir`. |
282
+
283
+ ### Runtime
284
+
285
+ | Function | Description |
286
+ | ------------------------------------- | -------------------------------------------------- |
287
+ | `Config`, `Results` | Mutable registries your run script fills in. |
288
+ | `run_experiment(identifier, path)` | Execute the run script and persist outputs. Raises `RunScriptNotFoundError` / `RunFailedError`. |
289
+ | `format_configuration(identifier)` | Config as `key: value` lines. |
290
+ | `format_run_results(identifier)` | Results as `key: value` lines (listing fallback). |
291
+
292
+ ### Example
293
+
294
+ ```python
295
+ from researchium import (
296
+ Config,
297
+ Results,
298
+ create_experiment,
299
+ discover_environment,
300
+ initialize_environment,
301
+ run_experiment,
302
+ )
303
+
304
+ env = initialize_environment("~/study", name="Climate Genomics")
305
+ experiment = create_experiment(
306
+ "climate-model", "Temperature projections for 2050.", "~/study"
307
+ )
308
+
309
+ experiment.path.joinpath("run.py").write_text(
310
+ "from researchium import Config, Results\n"
311
+ "Config['seed'] = 42\n"
312
+ "Results['mae'] = 1.7\n"
313
+ )
314
+
315
+ run_experiment(experiment.name, "~/study") # runs + writes files
316
+ with open(experiment.path / "config.json") as fh:
317
+ print(fh.read())
318
+
319
+ # From anywhere inside the project:
320
+ env = discover_environment(".") # finds it by walking up
321
+ ```
322
+
323
+ ---
324
+
325
+ ## File formats
326
+
327
+ ### `experiments.md`
328
+
329
+ A human log. `create` appends a section per experiment:
330
+
331
+ ```markdown
332
+ ### alpine-transcriptomics
333
+
334
+ RNA-seq of alpine vs lowland populations.
335
+
336
+ - ID: `1e70f7c3`
337
+ - Created: 2026-09-22
338
+ - Folder: `experiments/alpine-transcriptomics`
339
+ ```
340
+
341
+ ### `experiments.json`
342
+
343
+ The machine-readable registry (created empty by `init`, appended by `create`):
344
+
345
+ ```json
346
+ {
347
+ "version": 1,
348
+ "experiments": [
349
+ {
350
+ "id": "1e70f7c3",
351
+ "name": "alpine-transcriptomics",
352
+ "description": "RNA-seq of alpine vs lowland populations.",
353
+ "created": "2026-09-22"
354
+ }
355
+ ]
356
+ }
357
+ ```
358
+
359
+ ### `config.md` / `results.md`
360
+
361
+ Human-formatted snapshots written by `run`:
362
+
363
+ ```markdown
364
+ # alpine-transcriptomics
365
+
366
+ Last run: 2026-09-22 23:08
367
+
368
+ ## Configuration
369
+
370
+ - **epochs**: 10
371
+ - **learning_rate**: 0.001
372
+ ```
373
+
374
+ ### `config.json` / `results.json`
375
+
376
+ The same data as JSON, suitable for programmatic consumption:
377
+
378
+ ```json
379
+ {
380
+ "epochs": 10,
381
+ "learning_rate": 0.001
382
+ }
383
+ ```
384
+
385
+ Note: a stale `readme.md` produced by a development build is removed
386
+ automatically on the next `run`.
387
+
388
+ ### `.researchium/environment.json`
389
+
390
+ Environment metadata; this directory is the environment marker:
391
+
392
+ ```json
393
+ {
394
+ "name": "Climate Genomics",
395
+ "created": "2026-09-22",
396
+ "researchium": "0.1.0"
397
+ }
398
+ ```
399
+
400
+ ---
401
+
402
+ ## Error handling
403
+
404
+ All domain errors subclass `researchium.ResearchiumError`:
405
+
406
+ | Error | Raised when |
407
+ | ---------------------------- | ----------------------------------------- |
408
+ | `EnvironmentExistsError` | `init` on an already-initialized directory |
409
+ | `EnvironmentNotFoundError` | no environment at/above the given path |
410
+ | `ExperimentExistsError` | creating a duplicate experiment name |
411
+ | `ExperimentNotFoundError` | no experiment matches the ID/name |
412
+ | `InvalidExperimentNameError` | empty or path-traversal experiment name |
413
+ | `RunScriptNotFoundError` | experiment has no `run.py` |
414
+ | `RunFailedError` | the run script raised |
415
+
416
+ The CLI catches these, prints `error: <message>` to stderr, and exits `1`.
417
+
418
+ ---
419
+
420
+ ## Development
421
+
422
+ ```bash
423
+ uv sync --extra dev # install the package + dev tools
424
+ uv run pytest # 44 tests
425
+ uv run pytest -q # keep it quiet
426
+ uv run ruff check . # lint (E, F, W, I, UP on src + tests)
427
+ ```
428
+
429
+ Test coverage is configured in `pyproject.toml` (`--cov=researchium
430
+ --cov-report=term-missing`) and is kept at 100%. The GitHub Actions workflow in
431
+ `.github/workflows/ci.yml` runs tests and lint on Python 3.10 / 3.12 / 3.14.
432
+
433
+ ### Adding a feature
434
+
435
+ 1. Add the function to the relevant module under `src/researchium/`.
436
+ 2. Export it from `researchium/__init__.py` and add it to `__all__`.
437
+ 3. Add tests under `tests/`; keep coverage at 100% (`uv run pytest` will show
438
+ any missed lines).
439
+ 4. Run `uv run ruff check . && uv run pytest` and reinstall the global tool if
440
+ you use it: `uv tool install --force /path/to/researchium`.
441
+
442
+ ---
443
+
444
+ ## Project layout
445
+
446
+ ```
447
+ researchium/
448
+ ├── pyproject.toml # PEP 621 metadata, hatchling build, ruff/pytest config
449
+ ├── uv.lock # locked development dependencies
450
+ ├── README.md
451
+ ├── .python-version
452
+ ├── .github/workflows/ci.yml # lint + tests matrix
453
+ ├── src/researchium/
454
+ │ ├── __init__.py # public API surface
455
+ │ ├── environment.py # init, discovery, environment metadata + registry
456
+ │ ├── experiments.py # experiment lifecycle, search, lookup
457
+ │ ├── runtime.py # Config/Results registries + runner + formatters
458
+ │ └── cli.py # argparse-based CLI (entry point `researchium`)
459
+ └── tests/
460
+ ├── test_environment.py
461
+ ├── test_experiments.py
462
+ ├── test_runtime.py
463
+ └── test_cli.py
464
+ ```
465
+
466
+ ---
467
+
468
+ ## Roadmap
469
+
470
+ Planned directions, in rough order:
471
+
472
+ - `researchium scaffold <name>` to generate a starter `run.py`.
473
+ - Experiment status (pending / running / completed) tracked in the registry.
474
+ - Deleting and renaming experiments with safe migration of results.
475
+ - A smaller `researchium` output format flag (`--json`) for scripting.
@@ -0,0 +1,10 @@
1
+ researchium/__init__.py,sha256=Ezle8dFpjtZwLU3J2ElpOCKJG4PZKOMyCLHqcunaEos,1355
2
+ researchium/cli.py,sha256=J86DS3nniDQ5l_TJXKW_D2W7jfIGa3TGan2t4_pr0bM,3541
3
+ researchium/environment.py,sha256=mTZaKpnLD3grVndebWKp8CmWlXNDfvkJveCgPvJsKrs,5452
4
+ researchium/experiments.py,sha256=HN6ubISfspMDY9rcKIWAXC4OH8i76_Wy63_GyMG2zdE,6533
5
+ researchium/runtime.py,sha256=8XidorLPSm8-ZMrynXczY61ER8XHSzwiMh04MKNayYM,5640
6
+ researchium-0.1.0.dist-info/METADATA,sha256=pPAIq6I0JmszhRL4XYAM-QVQd1SKxPAWMXclLkvQkHM,16888
7
+ researchium-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ researchium-0.1.0.dist-info/entry_points.txt,sha256=PT99_EpSMwl7LknsaCM9Pb8Y7gL18PKVT1vkdsL08qE,53
9
+ researchium-0.1.0.dist-info/licenses/LICENSE,sha256=yuK449AmmLkXdEuSVgJdmIxrqdTaygTEs7XrWGVKE_8,1071
10
+ researchium-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ researchium = researchium.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samarth Javagal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.