ipy-runlog 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hiroyuki Kuromiya
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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: ipy-runlog
3
+ Version: 0.1.0
4
+ Summary: Lightweight JSONL execution logging for IPython.
5
+ Author-email: Hiroyuki Kuromiya <contact@kromiii.info>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kromiii/ipy-runlog
8
+ Project-URL: Repository, https://github.com/kromiii/ipy-runlog
9
+ Project-URL: Bug Tracker, https://github.com/kromiii/ipy-runlog/issues
10
+ Keywords: ipython,jupyter,logging,jsonl,experiment,notebook
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: ipython>=8.0
25
+ Dynamic: license-file
26
+
27
+ # ipy-runlog
28
+
29
+ A lightweight IPython extension that records code cell execution history as
30
+ JSON Lines (JSONL).
31
+
32
+ ## Motivation
33
+
34
+ Experiments are shaped by failed attempts as well as successful ones.
35
+ Recording both preserves the trial-and-error process, giving an experiment
36
+ notebook a coherent story instead of showing only its final results.
37
+
38
+ ## Installation
39
+
40
+ With `pip`:
41
+
42
+ ```bash
43
+ pip install ipy-runlog
44
+ ```
45
+
46
+ In a `uv` project:
47
+
48
+ ```bash
49
+ uv add ipy-runlog
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ Load the extension — recording starts automatically:
55
+
56
+ ```python
57
+ %load_ext ipy_runlog
58
+ ```
59
+
60
+ The log is written to `.ipy_runlog/` in the current working directory. The
61
+ file name is generated from the current date and time, for example:
62
+
63
+ ```text
64
+ .ipy_runlog/20260611-123456.jsonl
65
+ ```
66
+
67
+ Recording stops automatically when the IPython session ends.
68
+
69
+ ### Commands
70
+
71
+ Check the current status:
72
+
73
+ ```python
74
+ %runlog status
75
+ ```
76
+
77
+ Switch to a new log file mid-session (closes the current log):
78
+
79
+ ```python
80
+ %runlog new experiment-01
81
+ %runlog new experiment-01 --output # also record cell output
82
+ %runlog new experiment-01 -d ./logs # custom output directory
83
+ ```
84
+
85
+ Rename the current log file without interrupting recording:
86
+
87
+ ```python
88
+ %runlog rename feature-extraction
89
+ ```
90
+
91
+ Stop recording manually:
92
+
93
+ ```python
94
+ %runlog stop
95
+ ```
96
+
97
+ Show help:
98
+
99
+ ```python
100
+ %runlog help
101
+ %runlog new --help
102
+ ```
103
+
104
+ ### Configuration
105
+
106
+ You can set defaults in `pyproject.toml`:
107
+
108
+ ```toml
109
+ [tool.ipy-runlog]
110
+ directory = "./logs"
111
+ output = true
112
+ ```
113
+
114
+ Or in `.ipy_runlog.toml` at the project root (used as a fallback when
115
+ `pyproject.toml` is absent or has no `[tool.ipy-runlog]` section):
116
+
117
+ ```toml
118
+ directory = "./logs"
119
+ output = true
120
+ ```
121
+
122
+ Available config keys:
123
+
124
+ | Key | Type | Default | Description |
125
+ |-------------|--------|------------------|--------------------------------------|
126
+ | `directory` | string | `.ipy_runlog/` | Output directory |
127
+ | `output` | bool | `false` | Record cell output |
128
+ | `name` | string | current timestamp| Default log file name |
129
+
130
+ > **Note**: Python 3.11+ uses the built-in `tomllib`. For Python 3.9–3.10,
131
+ > install `tomli` to enable config file support: `pip install tomli`.
132
+
133
+ ## How It Works
134
+
135
+ The extension uses IPython event handlers to monitor cell execution:
136
+
137
+ - **`pre_run_cell`**: Triggered before a cell is executed. The extension captures the source code of the cell at this point.
138
+ - **`post_run_cell`**: Triggered after a cell finishes executing. The extension calculates the elapsed time, determines if it was successful or failed (including error details), and optionally captures the output.
139
+
140
+ Each event is appended as a single JSON line to the log file. On normal
141
+ session exit, a final `recording_stopped` event is written automatically via
142
+ `atexit`.
143
+
144
+ ## Log Format
145
+
146
+ Logs use UTF-8 encoded JSON Lines, with one event per line. New events are
147
+ appended when the target file already exists.
148
+
149
+ Event types:
150
+
151
+ - `recording_started`: recording started
152
+ - `cell_executed`: a cell finished executing
153
+ - `recording_renamed`: log file was renamed with `%runlog rename`
154
+ - `recording_stopped`: recording stopped (includes `"reason": "session_ended"` on automatic stop)
155
+
156
+ A `cell_executed` event contains:
157
+
158
+ - `started_at` and `ended_at`: local timestamps in ISO 8601 format
159
+ - `elapsed_sec`: execution time in seconds
160
+ - `status`: `success` or `failed`
161
+ - `execution_count`: the IPython execution count
162
+ - `code`: the cell source code
163
+ - `output`: the cell result when `--output` is enabled; non-JSON values are
164
+ stored using `repr()`
165
+ - `error`: error type, message, and traceback (always recorded on failure)
166
+
167
+ ## Development
168
+
169
+ Install this repository in editable mode:
170
+
171
+ ```bash
172
+ python -m pip install -e .
173
+ ```
174
+
175
+ With `uv`:
176
+
177
+ ```bash
178
+ uv pip install -e .
179
+ ```
180
+
181
+ Run the test suite:
182
+
183
+ ```bash
184
+ uv run pytest
185
+ ```
@@ -0,0 +1,159 @@
1
+ # ipy-runlog
2
+
3
+ A lightweight IPython extension that records code cell execution history as
4
+ JSON Lines (JSONL).
5
+
6
+ ## Motivation
7
+
8
+ Experiments are shaped by failed attempts as well as successful ones.
9
+ Recording both preserves the trial-and-error process, giving an experiment
10
+ notebook a coherent story instead of showing only its final results.
11
+
12
+ ## Installation
13
+
14
+ With `pip`:
15
+
16
+ ```bash
17
+ pip install ipy-runlog
18
+ ```
19
+
20
+ In a `uv` project:
21
+
22
+ ```bash
23
+ uv add ipy-runlog
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ Load the extension — recording starts automatically:
29
+
30
+ ```python
31
+ %load_ext ipy_runlog
32
+ ```
33
+
34
+ The log is written to `.ipy_runlog/` in the current working directory. The
35
+ file name is generated from the current date and time, for example:
36
+
37
+ ```text
38
+ .ipy_runlog/20260611-123456.jsonl
39
+ ```
40
+
41
+ Recording stops automatically when the IPython session ends.
42
+
43
+ ### Commands
44
+
45
+ Check the current status:
46
+
47
+ ```python
48
+ %runlog status
49
+ ```
50
+
51
+ Switch to a new log file mid-session (closes the current log):
52
+
53
+ ```python
54
+ %runlog new experiment-01
55
+ %runlog new experiment-01 --output # also record cell output
56
+ %runlog new experiment-01 -d ./logs # custom output directory
57
+ ```
58
+
59
+ Rename the current log file without interrupting recording:
60
+
61
+ ```python
62
+ %runlog rename feature-extraction
63
+ ```
64
+
65
+ Stop recording manually:
66
+
67
+ ```python
68
+ %runlog stop
69
+ ```
70
+
71
+ Show help:
72
+
73
+ ```python
74
+ %runlog help
75
+ %runlog new --help
76
+ ```
77
+
78
+ ### Configuration
79
+
80
+ You can set defaults in `pyproject.toml`:
81
+
82
+ ```toml
83
+ [tool.ipy-runlog]
84
+ directory = "./logs"
85
+ output = true
86
+ ```
87
+
88
+ Or in `.ipy_runlog.toml` at the project root (used as a fallback when
89
+ `pyproject.toml` is absent or has no `[tool.ipy-runlog]` section):
90
+
91
+ ```toml
92
+ directory = "./logs"
93
+ output = true
94
+ ```
95
+
96
+ Available config keys:
97
+
98
+ | Key | Type | Default | Description |
99
+ |-------------|--------|------------------|--------------------------------------|
100
+ | `directory` | string | `.ipy_runlog/` | Output directory |
101
+ | `output` | bool | `false` | Record cell output |
102
+ | `name` | string | current timestamp| Default log file name |
103
+
104
+ > **Note**: Python 3.11+ uses the built-in `tomllib`. For Python 3.9–3.10,
105
+ > install `tomli` to enable config file support: `pip install tomli`.
106
+
107
+ ## How It Works
108
+
109
+ The extension uses IPython event handlers to monitor cell execution:
110
+
111
+ - **`pre_run_cell`**: Triggered before a cell is executed. The extension captures the source code of the cell at this point.
112
+ - **`post_run_cell`**: Triggered after a cell finishes executing. The extension calculates the elapsed time, determines if it was successful or failed (including error details), and optionally captures the output.
113
+
114
+ Each event is appended as a single JSON line to the log file. On normal
115
+ session exit, a final `recording_stopped` event is written automatically via
116
+ `atexit`.
117
+
118
+ ## Log Format
119
+
120
+ Logs use UTF-8 encoded JSON Lines, with one event per line. New events are
121
+ appended when the target file already exists.
122
+
123
+ Event types:
124
+
125
+ - `recording_started`: recording started
126
+ - `cell_executed`: a cell finished executing
127
+ - `recording_renamed`: log file was renamed with `%runlog rename`
128
+ - `recording_stopped`: recording stopped (includes `"reason": "session_ended"` on automatic stop)
129
+
130
+ A `cell_executed` event contains:
131
+
132
+ - `started_at` and `ended_at`: local timestamps in ISO 8601 format
133
+ - `elapsed_sec`: execution time in seconds
134
+ - `status`: `success` or `failed`
135
+ - `execution_count`: the IPython execution count
136
+ - `code`: the cell source code
137
+ - `output`: the cell result when `--output` is enabled; non-JSON values are
138
+ stored using `repr()`
139
+ - `error`: error type, message, and traceback (always recorded on failure)
140
+
141
+ ## Development
142
+
143
+ Install this repository in editable mode:
144
+
145
+ ```bash
146
+ python -m pip install -e .
147
+ ```
148
+
149
+ With `uv`:
150
+
151
+ ```bash
152
+ uv pip install -e .
153
+ ```
154
+
155
+ Run the test suite:
156
+
157
+ ```bash
158
+ uv run pytest
159
+ ```
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ipy-runlog"
7
+ version = "0.1.0"
8
+ description = "Lightweight JSONL execution logging for IPython."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Hiroyuki Kuromiya", email = "contact@kromiii.info" }
15
+ ]
16
+ keywords = ["ipython", "jupyter", "logging", "jsonl", "experiment", "notebook"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: Science/Research",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Scientific/Engineering",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ ]
29
+ dependencies = ["ipython>=8.0"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/kromiii/ipy-runlog"
33
+ Repository = "https://github.com/kromiii/ipy-runlog"
34
+ "Bug Tracker" = "https://github.com/kromiii/ipy-runlog/issues"
35
+
36
+ [dependency-groups]
37
+ dev = ["pytest>=8.0"]
38
+
39
+ [tool.setuptools]
40
+ package-dir = {"" = "src"}
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .extension import load_ipython_extension, unload_ipython_extension
2
+
3
+ __all__ = ["load_ipython_extension", "unload_ipython_extension"]
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ if sys.version_info >= (3, 11):
8
+ import tomllib
9
+ else:
10
+ try:
11
+ import tomllib # type: ignore[no-redef]
12
+ except ImportError:
13
+ try:
14
+ import tomli as tomllib # type: ignore[no-redef]
15
+ except ImportError:
16
+ tomllib = None # type: ignore[assignment]
17
+
18
+
19
+ def load_config(cwd: Path) -> dict[str, Any]:
20
+ """Load ipy-runlog config from pyproject.toml or .ipy_runlog.toml.
21
+
22
+ Search order:
23
+ 1. [tool.ipy-runlog] in pyproject.toml
24
+ 2. .ipy_runlog.toml in cwd
25
+ """
26
+ if tomllib is None:
27
+ return {}
28
+
29
+ # 1. pyproject.toml
30
+ pyproject = cwd / "pyproject.toml"
31
+ if pyproject.exists():
32
+ try:
33
+ with pyproject.open("rb") as f:
34
+ data = tomllib.load(f)
35
+ config = data.get("tool", {}).get("ipy-runlog", {})
36
+ if config:
37
+ return config
38
+ except Exception:
39
+ pass
40
+
41
+ # 2. .ipy_runlog.toml
42
+ fallback = cwd / ".ipy_runlog.toml"
43
+ if fallback.exists():
44
+ try:
45
+ with fallback.open("rb") as f:
46
+ return tomllib.load(f)
47
+ except Exception:
48
+ pass
49
+
50
+ return {}
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from IPython.core.magic import Magics, line_magic, magics_class
8
+
9
+ from .config import load_config
10
+ from .logger import RunLogger
11
+
12
+ _STATE_ATTR = "_ipy_runlog_state"
13
+
14
+ _HELP = """\
15
+ Usage: %runlog <command> [ARGS]
16
+
17
+ Commands:
18
+ new [NAME] [OPTIONS] Close current log and start a new one.
19
+ rename NAME Rename the current log file (recording continues).
20
+ stop Stop recording manually.
21
+ status Show current recording status.
22
+ help Show this help message.
23
+
24
+ Options for 'new':
25
+ NAME Log file name (default: current timestamp)
26
+ -d PATH Output directory (default: .ipy_runlog/)
27
+ --output Also record cell output (default: off)
28
+ -h, --help Show this help message
29
+ """
30
+
31
+ _NEW_HELP = """\
32
+ Usage: %runlog new [NAME] [OPTIONS]
33
+
34
+ Close the current log and start recording to a new file.
35
+ By default, cell input and errors are recorded.
36
+
37
+ Arguments:
38
+ NAME Log file name (default: current timestamp)
39
+
40
+ Options:
41
+ -d PATH Output directory (default: .ipy_runlog/)
42
+ --output Also record cell output (default: off)
43
+ -h, --help Show this help message"""
44
+
45
+
46
+ @magics_class
47
+ class RunLogMagics(Magics):
48
+ def _state(self) -> dict:
49
+ state = getattr(self.shell, _STATE_ATTR, None)
50
+ if state is None:
51
+ state = {"logger": None, "magics_registered": True}
52
+ setattr(self.shell, _STATE_ATTR, state)
53
+ return state
54
+
55
+ @line_magic
56
+ def runlog(self, line: str = "") -> None:
57
+ try:
58
+ args = shlex.split(line)
59
+ except ValueError as exc:
60
+ print(f"runlog: {exc}")
61
+ return
62
+
63
+ if not args or args[0] in ("-h", "--help", "help"):
64
+ print(_HELP)
65
+ return
66
+
67
+ command, rest = args[0], " ".join(args[1:])
68
+
69
+ if command == "new":
70
+ self._runlog_new(rest)
71
+ elif command == "rename":
72
+ self._runlog_rename(rest)
73
+ elif command == "stop":
74
+ self._runlog_stop()
75
+ elif command == "status":
76
+ self._runlog_status()
77
+ else:
78
+ print(f"runlog: unknown command '{command}'. Run '%runlog help' for usage.")
79
+
80
+ def _runlog_new(self, line: str = "") -> None:
81
+ if _help_requested(line):
82
+ print(_NEW_HELP)
83
+ return
84
+ try:
85
+ name, directory, record_output = _parse_new_args(line)
86
+ except ValueError as exc:
87
+ print(f"runlog new: {exc}")
88
+ return
89
+
90
+ state = self._state()
91
+ logger: RunLogger | None = state.get("logger")
92
+ if logger and logger.active:
93
+ logger.stop()
94
+
95
+ config = load_config(Path.cwd())
96
+ output_path = _resolve_output_path(
97
+ name or config.get("name"),
98
+ directory or config.get("directory"),
99
+ )
100
+ logger = RunLogger(
101
+ self.shell,
102
+ output_path,
103
+ record_output=record_output or bool(config.get("output", False)),
104
+ record_error=True,
105
+ )
106
+ logger.start()
107
+ state["logger"] = logger
108
+ print(f"runlog started: {output_path}")
109
+
110
+ def _runlog_rename(self, line: str = "") -> None:
111
+ try:
112
+ args = shlex.split(line)
113
+ except ValueError as exc:
114
+ print(f"runlog rename: {exc}")
115
+ return
116
+
117
+ if not args:
118
+ print("runlog rename: a name is required")
119
+ return
120
+ if len(args) > 1:
121
+ print("runlog rename: only one name may be specified")
122
+ return
123
+
124
+ state = self._state()
125
+ logger: RunLogger | None = state.get("logger")
126
+ if not logger or not logger.active:
127
+ print("runlog is not running")
128
+ return
129
+
130
+ old_path = logger.output_path
131
+ logger.rename(args[0])
132
+ print(f"runlog renamed: {old_path.name} -> {logger.output_path.name}")
133
+
134
+ def _runlog_stop(self) -> None:
135
+ state = self._state()
136
+ logger: RunLogger | None = state.get("logger")
137
+ if not logger or not logger.active:
138
+ print("runlog is not running")
139
+ return
140
+ logger.stop()
141
+ print("runlog stopped")
142
+
143
+ def _runlog_status(self) -> None:
144
+ state = self._state()
145
+ logger: RunLogger | None = state.get("logger")
146
+ if logger and logger.active:
147
+ print(f"running: {logger.output_path}")
148
+ return
149
+ print("stopped")
150
+
151
+
152
+ def load_ipython_extension(ipython) -> None:
153
+ state = getattr(ipython, _STATE_ATTR, None)
154
+ if state and state.get("magics_registered"):
155
+ return
156
+ ipython.register_magics(RunLogMagics)
157
+ state = {"logger": None, "magics_registered": True}
158
+ setattr(ipython, _STATE_ATTR, state)
159
+
160
+ config = load_config(Path.cwd())
161
+ output_path = _resolve_output_path(
162
+ config.get("name"),
163
+ config.get("directory"),
164
+ )
165
+ logger = RunLogger(
166
+ ipython,
167
+ output_path,
168
+ record_output=bool(config.get("output", False)),
169
+ record_error=True,
170
+ )
171
+ logger.start()
172
+ state["logger"] = logger
173
+
174
+
175
+ def unload_ipython_extension(ipython) -> None:
176
+ state = getattr(ipython, _STATE_ATTR, None)
177
+ if not state:
178
+ return
179
+ logger = state.get("logger")
180
+ if logger and logger.active:
181
+ logger.stop()
182
+ ipython.magics_manager.magics["line"].pop("runlog", None)
183
+ delattr(ipython, _STATE_ATTR)
184
+
185
+
186
+ def _help_requested(line: str) -> bool:
187
+ try:
188
+ return any(arg in ("-h", "--help") for arg in shlex.split(line))
189
+ except ValueError:
190
+ return False
191
+
192
+
193
+ def _parse_new_args(line: str) -> tuple[str | None, str | None, bool]:
194
+ try:
195
+ args = shlex.split(line)
196
+ except ValueError as exc:
197
+ raise ValueError(str(exc)) from exc
198
+
199
+ name = None
200
+ directory = None
201
+ record_output = False
202
+ index = 0
203
+ while index < len(args):
204
+ arg = args[index]
205
+ if arg == "-d":
206
+ index += 1
207
+ if index >= len(args):
208
+ raise ValueError("-d requires a path")
209
+ directory = args[index]
210
+ elif arg == "--output":
211
+ record_output = True
212
+ elif arg.startswith("-"):
213
+ raise ValueError(f"unknown option: {arg}")
214
+ elif name is None:
215
+ name = arg
216
+ else:
217
+ raise ValueError("only one log name may be specified")
218
+ index += 1
219
+
220
+ return name, directory, record_output
221
+
222
+
223
+ def _resolve_output_path(name: str | None, directory: str | None = None) -> Path:
224
+ filename = name or datetime.now().strftime("%Y%m%d-%H%M%S")
225
+ if not filename.endswith(".jsonl"):
226
+ filename = f"{filename}.jsonl"
227
+ output_directory = Path(directory).expanduser() if directory else Path.cwd() / ".ipy_runlog"
228
+ return output_directory / filename
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ import traceback
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ class RunLogger:
12
+ def __init__(
13
+ self,
14
+ ipython: Any,
15
+ output_path: Path,
16
+ *,
17
+ record_output: bool = False,
18
+ record_error: bool = True,
19
+ ) -> None:
20
+ self._ipython = ipython
21
+ self.output_path = output_path
22
+ self._record_output = record_output
23
+ self._record_error = record_error
24
+ self._active = False
25
+ self._last_started_at: str | None = None
26
+ self._last_code: str = ""
27
+ self._last_started_dt: datetime | None = None
28
+
29
+ @property
30
+ def active(self) -> bool:
31
+ return self._active
32
+
33
+ def start(self) -> None:
34
+ if self._active:
35
+ return
36
+ self.output_path.parent.mkdir(parents=True, exist_ok=True)
37
+ self._ipython.events.register("pre_run_cell", self._on_pre_run_cell)
38
+ self._ipython.events.register("post_run_cell", self._on_post_run_cell)
39
+ self._active = True
40
+ atexit.register(self._on_exit)
41
+ started_at = _now_iso()
42
+ self._last_started_at = started_at
43
+ self._append_event(
44
+ {
45
+ "event": "recording_started",
46
+ "started_at": started_at,
47
+ "path": str(self.output_path),
48
+ }
49
+ )
50
+
51
+ def stop(self) -> None:
52
+ if not self._active:
53
+ return
54
+ atexit.unregister(self._on_exit)
55
+ self._ipython.events.unregister("pre_run_cell", self._on_pre_run_cell)
56
+ self._ipython.events.unregister("post_run_cell", self._on_post_run_cell)
57
+ self._active = False
58
+ self._append_event(
59
+ {
60
+ "event": "recording_stopped",
61
+ "stopped_at": _now_iso(),
62
+ "path": str(self.output_path),
63
+ }
64
+ )
65
+
66
+ def rename(self, new_name: str) -> None:
67
+ """Rename the current log file. Recording continues uninterrupted."""
68
+ if not new_name.endswith(".jsonl"):
69
+ new_name = f"{new_name}.jsonl"
70
+ new_path = self.output_path.parent / new_name
71
+ self.output_path.rename(new_path)
72
+ self.output_path = new_path
73
+ self._append_event(
74
+ {
75
+ "event": "recording_renamed",
76
+ "renamed_at": _now_iso(),
77
+ "path": str(self.output_path),
78
+ }
79
+ )
80
+
81
+ def _on_exit(self) -> None:
82
+ """Called by atexit when the Python process exits normally."""
83
+ if not self._active:
84
+ return
85
+ self._ipython.events.unregister("pre_run_cell", self._on_pre_run_cell)
86
+ self._ipython.events.unregister("post_run_cell", self._on_post_run_cell)
87
+ self._active = False
88
+ self._append_event(
89
+ {
90
+ "event": "recording_stopped",
91
+ "stopped_at": _now_iso(),
92
+ "path": str(self.output_path),
93
+ "reason": "session_ended",
94
+ }
95
+ )
96
+
97
+ def _on_pre_run_cell(self, info: Any) -> None:
98
+ self._last_code = getattr(info, "raw_cell", "") or ""
99
+ self._last_started_dt = datetime.now()
100
+ self._last_started_at = self._last_started_dt.isoformat(timespec="microseconds")
101
+
102
+ def _on_post_run_cell(self, result: Any) -> None:
103
+ started_dt = self._last_started_dt or datetime.now()
104
+ started_at = self._last_started_at or started_dt.isoformat(timespec="microseconds")
105
+ ended_dt = datetime.now()
106
+ error = getattr(result, "error_in_exec", None) or getattr(result, "error_before_exec", None)
107
+ status = "failed" if error else "success"
108
+ event = {
109
+ "event": "cell_executed",
110
+ "started_at": started_at,
111
+ "ended_at": ended_dt.isoformat(timespec="microseconds"),
112
+ "elapsed_sec": (ended_dt - started_dt).total_seconds(),
113
+ "status": status,
114
+ "execution_count": getattr(result, "execution_count", None),
115
+ "code": self._last_code,
116
+ }
117
+ if self._record_output:
118
+ event["output"] = _format_output(getattr(result, "result", None))
119
+ if self._record_error:
120
+ event["error"] = _format_error(error)
121
+ self._append_event(event)
122
+
123
+ def _append_event(self, payload: dict[str, Any]) -> None:
124
+ with self.output_path.open("a", encoding="utf-8") as f:
125
+ json.dump(payload, f, ensure_ascii=False)
126
+ f.write("\n")
127
+
128
+
129
+ def _now_iso() -> str:
130
+ return datetime.now().isoformat(timespec="microseconds")
131
+
132
+
133
+ def _format_error(error: BaseException | None) -> dict[str, str] | None:
134
+ if error is None:
135
+ return None
136
+ tb = "".join(traceback.format_exception(type(error), error, error.__traceback__))
137
+ return {
138
+ "type": type(error).__name__,
139
+ "message": str(error),
140
+ "traceback": tb,
141
+ }
142
+
143
+
144
+ def _format_output(output: Any) -> Any:
145
+ try:
146
+ json.dumps(output, ensure_ascii=False)
147
+ except (TypeError, ValueError):
148
+ return repr(output)
149
+ return output
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: ipy-runlog
3
+ Version: 0.1.0
4
+ Summary: Lightweight JSONL execution logging for IPython.
5
+ Author-email: Hiroyuki Kuromiya <contact@kromiii.info>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kromiii/ipy-runlog
8
+ Project-URL: Repository, https://github.com/kromiii/ipy-runlog
9
+ Project-URL: Bug Tracker, https://github.com/kromiii/ipy-runlog/issues
10
+ Keywords: ipython,jupyter,logging,jsonl,experiment,notebook
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: ipython>=8.0
25
+ Dynamic: license-file
26
+
27
+ # ipy-runlog
28
+
29
+ A lightweight IPython extension that records code cell execution history as
30
+ JSON Lines (JSONL).
31
+
32
+ ## Motivation
33
+
34
+ Experiments are shaped by failed attempts as well as successful ones.
35
+ Recording both preserves the trial-and-error process, giving an experiment
36
+ notebook a coherent story instead of showing only its final results.
37
+
38
+ ## Installation
39
+
40
+ With `pip`:
41
+
42
+ ```bash
43
+ pip install ipy-runlog
44
+ ```
45
+
46
+ In a `uv` project:
47
+
48
+ ```bash
49
+ uv add ipy-runlog
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ Load the extension — recording starts automatically:
55
+
56
+ ```python
57
+ %load_ext ipy_runlog
58
+ ```
59
+
60
+ The log is written to `.ipy_runlog/` in the current working directory. The
61
+ file name is generated from the current date and time, for example:
62
+
63
+ ```text
64
+ .ipy_runlog/20260611-123456.jsonl
65
+ ```
66
+
67
+ Recording stops automatically when the IPython session ends.
68
+
69
+ ### Commands
70
+
71
+ Check the current status:
72
+
73
+ ```python
74
+ %runlog status
75
+ ```
76
+
77
+ Switch to a new log file mid-session (closes the current log):
78
+
79
+ ```python
80
+ %runlog new experiment-01
81
+ %runlog new experiment-01 --output # also record cell output
82
+ %runlog new experiment-01 -d ./logs # custom output directory
83
+ ```
84
+
85
+ Rename the current log file without interrupting recording:
86
+
87
+ ```python
88
+ %runlog rename feature-extraction
89
+ ```
90
+
91
+ Stop recording manually:
92
+
93
+ ```python
94
+ %runlog stop
95
+ ```
96
+
97
+ Show help:
98
+
99
+ ```python
100
+ %runlog help
101
+ %runlog new --help
102
+ ```
103
+
104
+ ### Configuration
105
+
106
+ You can set defaults in `pyproject.toml`:
107
+
108
+ ```toml
109
+ [tool.ipy-runlog]
110
+ directory = "./logs"
111
+ output = true
112
+ ```
113
+
114
+ Or in `.ipy_runlog.toml` at the project root (used as a fallback when
115
+ `pyproject.toml` is absent or has no `[tool.ipy-runlog]` section):
116
+
117
+ ```toml
118
+ directory = "./logs"
119
+ output = true
120
+ ```
121
+
122
+ Available config keys:
123
+
124
+ | Key | Type | Default | Description |
125
+ |-------------|--------|------------------|--------------------------------------|
126
+ | `directory` | string | `.ipy_runlog/` | Output directory |
127
+ | `output` | bool | `false` | Record cell output |
128
+ | `name` | string | current timestamp| Default log file name |
129
+
130
+ > **Note**: Python 3.11+ uses the built-in `tomllib`. For Python 3.9–3.10,
131
+ > install `tomli` to enable config file support: `pip install tomli`.
132
+
133
+ ## How It Works
134
+
135
+ The extension uses IPython event handlers to monitor cell execution:
136
+
137
+ - **`pre_run_cell`**: Triggered before a cell is executed. The extension captures the source code of the cell at this point.
138
+ - **`post_run_cell`**: Triggered after a cell finishes executing. The extension calculates the elapsed time, determines if it was successful or failed (including error details), and optionally captures the output.
139
+
140
+ Each event is appended as a single JSON line to the log file. On normal
141
+ session exit, a final `recording_stopped` event is written automatically via
142
+ `atexit`.
143
+
144
+ ## Log Format
145
+
146
+ Logs use UTF-8 encoded JSON Lines, with one event per line. New events are
147
+ appended when the target file already exists.
148
+
149
+ Event types:
150
+
151
+ - `recording_started`: recording started
152
+ - `cell_executed`: a cell finished executing
153
+ - `recording_renamed`: log file was renamed with `%runlog rename`
154
+ - `recording_stopped`: recording stopped (includes `"reason": "session_ended"` on automatic stop)
155
+
156
+ A `cell_executed` event contains:
157
+
158
+ - `started_at` and `ended_at`: local timestamps in ISO 8601 format
159
+ - `elapsed_sec`: execution time in seconds
160
+ - `status`: `success` or `failed`
161
+ - `execution_count`: the IPython execution count
162
+ - `code`: the cell source code
163
+ - `output`: the cell result when `--output` is enabled; non-JSON values are
164
+ stored using `repr()`
165
+ - `error`: error type, message, and traceback (always recorded on failure)
166
+
167
+ ## Development
168
+
169
+ Install this repository in editable mode:
170
+
171
+ ```bash
172
+ python -m pip install -e .
173
+ ```
174
+
175
+ With `uv`:
176
+
177
+ ```bash
178
+ uv pip install -e .
179
+ ```
180
+
181
+ Run the test suite:
182
+
183
+ ```bash
184
+ uv run pytest
185
+ ```
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/ipy_runlog/__init__.py
5
+ src/ipy_runlog/config.py
6
+ src/ipy_runlog/extension.py
7
+ src/ipy_runlog/logger.py
8
+ src/ipy_runlog.egg-info/PKG-INFO
9
+ src/ipy_runlog.egg-info/SOURCES.txt
10
+ src/ipy_runlog.egg-info/dependency_links.txt
11
+ src/ipy_runlog.egg-info/requires.txt
12
+ src/ipy_runlog.egg-info/top_level.txt
13
+ tests/test_extension.py
14
+ tests/test_logger.py
@@ -0,0 +1 @@
1
+ ipython>=8.0
@@ -0,0 +1 @@
1
+ ipy_runlog
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from types import SimpleNamespace
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+
9
+ from ipy_runlog.extension import RunLogMagics, _parse_new_args, _resolve_output_path
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # _parse_new_args
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ def test_parse_new_args_defaults() -> None:
18
+ assert _parse_new_args("") == (None, None, False)
19
+
20
+
21
+ def test_parse_new_args_with_name() -> None:
22
+ assert _parse_new_args("analysis") == ("analysis", None, False)
23
+
24
+
25
+ def test_parse_new_args_with_directory() -> None:
26
+ assert _parse_new_args("analysis -d './run logs'") == (
27
+ "analysis",
28
+ "./run logs",
29
+ False,
30
+ )
31
+
32
+
33
+ def test_parse_new_args_with_directory_only() -> None:
34
+ assert _parse_new_args("-d ~/runlogs") == (None, "~/runlogs", False)
35
+
36
+
37
+ def test_parse_new_args_with_output() -> None:
38
+ assert _parse_new_args("analysis --output") == ("analysis", None, True)
39
+
40
+
41
+ def test_parse_new_args_output_and_directory() -> None:
42
+ assert _parse_new_args("analysis -d ./logs --output") == (
43
+ "analysis",
44
+ "./logs",
45
+ True,
46
+ )
47
+
48
+
49
+ def test_parse_new_args_rejects_unknown_option() -> None:
50
+ with pytest.raises(ValueError, match="unknown option: --only-input"):
51
+ _parse_new_args("--only-input")
52
+
53
+
54
+ def test_parse_new_args_rejects_duplicate_name() -> None:
55
+ with pytest.raises(ValueError, match="only one log name may be specified"):
56
+ _parse_new_args("foo bar")
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # %runlog new --help
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ def test_runlog_new_help_lists_options(capsys) -> None:
65
+ magics = RunLogMagics(shell=SimpleNamespace())
66
+
67
+ magics.runlog("new --help")
68
+
69
+ output = capsys.readouterr().out
70
+ assert "Usage: %runlog new [NAME] [OPTIONS]" in output
71
+ assert "-d PATH" in output
72
+ assert "--output" in output
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # %runlog help / unknown command
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def test_runlog_help_lists_commands(capsys) -> None:
81
+ magics = RunLogMagics(shell=SimpleNamespace())
82
+
83
+ magics.runlog("help")
84
+
85
+ output = capsys.readouterr().out
86
+ assert "Usage: %runlog <command>" in output
87
+ assert "new" in output
88
+ assert "rename" in output
89
+ assert "stop" in output
90
+ assert "status" in output
91
+
92
+
93
+ def test_runlog_unknown_command(capsys) -> None:
94
+ magics = RunLogMagics(shell=SimpleNamespace())
95
+
96
+ magics.runlog("unknown")
97
+
98
+ output = capsys.readouterr().out
99
+ assert "unknown command 'unknown'" in output
100
+
101
+
102
+ def test_runlog_stop_when_not_running(capsys) -> None:
103
+ shell = SimpleNamespace()
104
+ magics = RunLogMagics(shell=shell)
105
+
106
+ from ipy_runlog.extension import _STATE_ATTR
107
+
108
+ setattr(shell, _STATE_ATTR, {"logger": None, "magics_registered": True})
109
+
110
+ magics.runlog("stop")
111
+
112
+ output = capsys.readouterr().out
113
+ assert "runlog is not running" in output
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # %runlog rename
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def test_runlog_rename_updates_path(tmp_path, capsys) -> None:
122
+ shell = SimpleNamespace()
123
+ magics = RunLogMagics(shell=shell)
124
+
125
+ from ipy_runlog.logger import RunLogger
126
+ from ipy_runlog.extension import _STATE_ATTR
127
+
128
+ log_file = tmp_path / "old.jsonl"
129
+ log_file.write_text("", encoding="utf-8")
130
+ logger = RunLogger(None, log_file)
131
+ logger._active = True
132
+
133
+ setattr(shell, _STATE_ATTR, {"logger": logger, "magics_registered": True})
134
+
135
+ with patch.object(logger, "rename") as mock_rename:
136
+ magics.runlog("rename newname")
137
+ mock_rename.assert_called_once_with("newname")
138
+
139
+
140
+ def test_runlog_rename_requires_name(capsys) -> None:
141
+ shell = SimpleNamespace()
142
+ magics = RunLogMagics(shell=shell)
143
+
144
+ from ipy_runlog.extension import _STATE_ATTR
145
+
146
+ setattr(shell, _STATE_ATTR, {"logger": None, "magics_registered": True})
147
+
148
+ magics.runlog("rename")
149
+
150
+ output = capsys.readouterr().out
151
+ assert "a name is required" in output
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # _resolve_output_path
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ def test_resolve_output_path_uses_default_directory() -> None:
160
+ with patch("ipy_runlog.extension.Path.cwd", return_value=Path("/work")):
161
+ output_path = _resolve_output_path("analysis", None)
162
+
163
+ assert output_path == Path("/work/.ipy_runlog/analysis.jsonl")
164
+
165
+
166
+ def test_resolve_output_path_uses_specified_directory() -> None:
167
+ output_path = _resolve_output_path("analysis.jsonl", "./logs")
168
+
169
+ assert output_path == Path("logs/analysis.jsonl")
@@ -0,0 +1,159 @@
1
+ import json
2
+ from types import SimpleNamespace
3
+
4
+ from ipy_runlog.logger import RunLogger
5
+
6
+
7
+ def _read_events(path) -> list[dict]:
8
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
9
+
10
+
11
+ def _read_last_event(path) -> dict:
12
+ return _read_events(path)[-1]
13
+
14
+
15
+ def _make_shell():
16
+ return SimpleNamespace(events=SimpleNamespace(register=lambda *a: None, unregister=lambda *a: None))
17
+
18
+
19
+ def test_start_and_stop_record_recording_lifecycle_events(tmp_path) -> None:
20
+ output_path = tmp_path / "run.jsonl"
21
+ logger = RunLogger(_make_shell(), output_path)
22
+
23
+ logger.start()
24
+ assert _read_last_event(output_path)["event"] == "recording_started"
25
+
26
+ logger.stop()
27
+ assert _read_last_event(output_path)["event"] == "recording_stopped"
28
+
29
+
30
+ def test_stop_writes_stopped_event_without_reason(tmp_path) -> None:
31
+ output_path = tmp_path / "run.jsonl"
32
+ logger = RunLogger(_make_shell(), output_path)
33
+ logger.start()
34
+
35
+ logger.stop()
36
+
37
+ event = _read_last_event(output_path)
38
+ assert event["event"] == "recording_stopped"
39
+ assert "reason" not in event
40
+
41
+
42
+ def test_on_exit_writes_stopped_event_with_session_ended_reason(tmp_path) -> None:
43
+ output_path = tmp_path / "run.jsonl"
44
+ logger = RunLogger(_make_shell(), output_path)
45
+ logger.start()
46
+
47
+ logger._on_exit()
48
+
49
+ event = _read_last_event(output_path)
50
+ assert event["event"] == "recording_stopped"
51
+ assert event["reason"] == "session_ended"
52
+
53
+
54
+ def test_on_exit_is_idempotent_after_stop(tmp_path) -> None:
55
+ output_path = tmp_path / "run.jsonl"
56
+ logger = RunLogger(_make_shell(), output_path)
57
+ logger.start()
58
+ logger.stop()
59
+
60
+ # _on_exit should do nothing since _active is already False
61
+ logger._on_exit()
62
+
63
+ events = _read_events(output_path)
64
+ assert sum(1 for e in events if e["event"] == "recording_stopped") == 1
65
+
66
+
67
+ def test_rename_moves_file_and_updates_path(tmp_path) -> None:
68
+ output_path = tmp_path / "old.jsonl"
69
+ logger = RunLogger(None, output_path)
70
+ logger._active = True
71
+ output_path.write_text('{"event":"recording_started"}\n', encoding="utf-8")
72
+
73
+ logger.rename("newname")
74
+
75
+ assert not output_path.exists()
76
+ assert logger.output_path == tmp_path / "newname.jsonl"
77
+ assert logger.output_path.exists()
78
+ events = _read_events(logger.output_path)
79
+ assert events[-1]["event"] == "recording_renamed"
80
+
81
+
82
+ def test_rename_adds_jsonl_extension_if_missing(tmp_path) -> None:
83
+ output_path = tmp_path / "old.jsonl"
84
+ output_path.write_text("", encoding="utf-8")
85
+ logger = RunLogger(None, output_path)
86
+ logger._active = True
87
+
88
+ logger.rename("newname")
89
+
90
+ assert logger.output_path.suffix == ".jsonl"
91
+
92
+
93
+ def test_rename_preserves_existing_jsonl_extension(tmp_path) -> None:
94
+ output_path = tmp_path / "old.jsonl"
95
+ output_path.write_text("", encoding="utf-8")
96
+ logger = RunLogger(None, output_path)
97
+ logger._active = True
98
+
99
+ logger.rename("newname.jsonl")
100
+
101
+ assert logger.output_path.name == "newname.jsonl"
102
+
103
+
104
+ def test_cell_event_records_code_and_error_by_default(tmp_path) -> None:
105
+ output_path = tmp_path / "run.jsonl"
106
+ logger = RunLogger(None, output_path)
107
+ error = ValueError("invalid value")
108
+
109
+ logger._on_pre_run_cell(SimpleNamespace(raw_cell="raise ValueError()"))
110
+ logger._on_post_run_cell(
111
+ SimpleNamespace(
112
+ execution_count=1,
113
+ result=None,
114
+ error_in_exec=error,
115
+ error_before_exec=None,
116
+ )
117
+ )
118
+
119
+ event = _read_last_event(output_path)
120
+ assert event["code"] == "raise ValueError()"
121
+ assert event["error"]["type"] == "ValueError"
122
+ assert "output" not in event
123
+
124
+
125
+ def test_cell_event_can_record_output_and_omit_error(tmp_path) -> None:
126
+ output_path = tmp_path / "run.jsonl"
127
+ logger = RunLogger(None, output_path, record_output=True, record_error=False)
128
+
129
+ logger._on_pre_run_cell(SimpleNamespace(raw_cell="{'answer': 42}"))
130
+ logger._on_post_run_cell(
131
+ SimpleNamespace(
132
+ execution_count=2,
133
+ result={"answer": 42},
134
+ error_in_exec=None,
135
+ error_before_exec=None,
136
+ )
137
+ )
138
+
139
+ event = _read_last_event(output_path)
140
+ assert event["output"] == {"answer": 42}
141
+ assert "error" not in event
142
+
143
+
144
+ def test_non_json_output_is_recorded_as_repr(tmp_path) -> None:
145
+ output_path = tmp_path / "run.jsonl"
146
+ logger = RunLogger(None, output_path, record_output=True)
147
+
148
+ logger._on_pre_run_cell(SimpleNamespace(raw_cell="{1, 2}"))
149
+ logger._on_post_run_cell(
150
+ SimpleNamespace(
151
+ execution_count=3,
152
+ result={1, 2},
153
+ error_in_exec=None,
154
+ error_before_exec=None,
155
+ )
156
+ )
157
+
158
+ event = _read_last_event(output_path)
159
+ assert event["output"] == "{1, 2}"