ipy-runlog 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.
ipy_runlog/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .extension import load_ipython_extension, unload_ipython_extension
2
+
3
+ __all__ = ["load_ipython_extension", "unload_ipython_extension"]
ipy_runlog/config.py ADDED
@@ -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
ipy_runlog/logger.py ADDED
@@ -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,9 @@
1
+ ipy_runlog/__init__.py,sha256=prfA4xsdXVbog-8szs_VgnDRom9zzXiAboqr4sRh8Fs,138
2
+ ipy_runlog/config.py,sha256=XzFoL1qEeR_g-cjHBRFc6OxMS3rndn1dskEBAMV6TP0,1252
3
+ ipy_runlog/extension.py,sha256=OQ546uKC3pR_UVgDmxOveKyeScNSGMVFsAa13KODrCc,6812
4
+ ipy_runlog/logger.py,sha256=BqX7MJPNiPXoPJMFVgxRtuXFuoUnxGt5BjyESMICC-4,5066
5
+ ipy_runlog-0.1.0.dist-info/licenses/LICENSE,sha256=ZPYo_6NjzuLSvib6puzPpirM8GfeJr4RLpxYfNR11XA,1074
6
+ ipy_runlog-0.1.0.dist-info/METADATA,sha256=U0THj3_w9Hck4d1BiKhnCVeR7UOOQlHkqMOC5Ip9dIA,4943
7
+ ipy_runlog-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ ipy_runlog-0.1.0.dist-info/top_level.txt,sha256=4_HSWhmaP8HbR9C4BSfk35oy_INgNe7WsCqfXmsBCSo,11
9
+ ipy_runlog-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ ipy_runlog