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.
- ipy_runlog-0.1.0/LICENSE +21 -0
- ipy_runlog-0.1.0/PKG-INFO +185 -0
- ipy_runlog-0.1.0/README.md +159 -0
- ipy_runlog-0.1.0/pyproject.toml +43 -0
- ipy_runlog-0.1.0/setup.cfg +4 -0
- ipy_runlog-0.1.0/src/ipy_runlog/__init__.py +3 -0
- ipy_runlog-0.1.0/src/ipy_runlog/config.py +50 -0
- ipy_runlog-0.1.0/src/ipy_runlog/extension.py +228 -0
- ipy_runlog-0.1.0/src/ipy_runlog/logger.py +149 -0
- ipy_runlog-0.1.0/src/ipy_runlog.egg-info/PKG-INFO +185 -0
- ipy_runlog-0.1.0/src/ipy_runlog.egg-info/SOURCES.txt +14 -0
- ipy_runlog-0.1.0/src/ipy_runlog.egg-info/dependency_links.txt +1 -0
- ipy_runlog-0.1.0/src/ipy_runlog.egg-info/requires.txt +1 -0
- ipy_runlog-0.1.0/src/ipy_runlog.egg-info/top_level.txt +1 -0
- ipy_runlog-0.1.0/tests/test_extension.py +169 -0
- ipy_runlog-0.1.0/tests/test_logger.py +159 -0
ipy_runlog-0.1.0/LICENSE
ADDED
|
@@ -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,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
|
+
|
|
@@ -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}"
|