jupytertracker 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.
- jupytertracker-0.1.0/.claude/CLAUDE.md +24 -0
- jupytertracker-0.1.0/.gitignore +13 -0
- jupytertracker-0.1.0/PKG-INFO +148 -0
- jupytertracker-0.1.0/README.md +136 -0
- jupytertracker-0.1.0/pyproject.toml +26 -0
- jupytertracker-0.1.0/setup.cfg +7 -0
- jupytertracker-0.1.0/src/jupytertracker/__init__.py +68 -0
- jupytertracker-0.1.0/src/jupytertracker/exporter.py +61 -0
- jupytertracker-0.1.0/src/jupytertracker/tracker.py +92 -0
- jupytertracker-0.1.0/tests/conftest.py +24 -0
- jupytertracker-0.1.0/tests/test_exporter.py +127 -0
- jupytertracker-0.1.0/tests/test_init.py +69 -0
- jupytertracker-0.1.0/tests/test_tracker.py +149 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# gstack
|
|
2
|
+
|
|
3
|
+
Use the `/browse` skill from gstack for all web browsing. Never use `mcp__claude-in-chrome__*` tools directly.
|
|
4
|
+
|
|
5
|
+
Available gstack skills:
|
|
6
|
+
`/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, `/plan-design-review`, `/design-consultation`, `/design-shotgun`, `/design-html`, `/review`, `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/design-review`, `/setup-browser-cookies`, `/setup-deploy`, `/setup-gbrain`, `/retro`, `/investigate`, `/document-release`, `/document-generate`, `/codex`, `/cso`, `/autoplan`, `/plan-devex-review`, `/devex-review`, `/careful`, `/freeze`, `/guard`, `/unfreeze`, `/gstack-upgrade`, `/learn`
|
|
7
|
+
|
|
8
|
+
## Skill routing
|
|
9
|
+
|
|
10
|
+
When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill.
|
|
11
|
+
|
|
12
|
+
Key routing rules:
|
|
13
|
+
- Product ideas/brainstorming → invoke /office-hours
|
|
14
|
+
- Strategy/scope → invoke /plan-ceo-review
|
|
15
|
+
- Architecture → invoke /plan-eng-review
|
|
16
|
+
- Design system/plan review → invoke /design-consultation or /plan-design-review
|
|
17
|
+
- Full review pipeline → invoke /autoplan
|
|
18
|
+
- Bugs/errors → invoke /investigate
|
|
19
|
+
- QA/testing site behavior → invoke /qa or /qa-only
|
|
20
|
+
- Code review/diff check → invoke /review
|
|
21
|
+
- Visual polish → invoke /design-review
|
|
22
|
+
- Ship/deploy/PR → invoke /ship or /land-and-deploy
|
|
23
|
+
- Save progress → invoke /context-save
|
|
24
|
+
- Resume context → invoke /context-restore
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jupytertracker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Track Jupyter notebook cell execution and export a clean, ordered Python script
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Requires-Dist: ipython>=7.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: nbformat>=5.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# jupytertracker
|
|
14
|
+
|
|
15
|
+
Part of an end-to-end ML model management system for replicable machine learning.
|
|
16
|
+
|
|
17
|
+
## The problem
|
|
18
|
+
|
|
19
|
+
Building a machine learning model in a Jupyter notebook is iterative and messy — cells run out of order, code gets modified and re-run, hyperparameters get tweaked. When a model reviewer asks "how did you build this?", the data scientist has to manually reconstruct the process. When a compliance team asks for documentation, someone has to write it by hand.
|
|
20
|
+
|
|
21
|
+
The result: models that can't be independently replicated, and whitepapers that are written after the fact from memory rather than from the actual process.
|
|
22
|
+
|
|
23
|
+
## System vision
|
|
24
|
+
|
|
25
|
+
This library is Component 1 of a three-part system for making the ML modeling process fully replicable and auditable:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
29
|
+
│ ML Model Management System │
|
|
30
|
+
├──────────────────┬──────────────────────┬───────────────────────┤
|
|
31
|
+
│ Component 1 │ Component 2 │ Component 3 │
|
|
32
|
+
│ JupyterTracker │ MLflow Integration │ Whitepaper Generator │
|
|
33
|
+
│ (this library) │ │ │
|
|
34
|
+
├──────────────────┼──────────────────────┼───────────────────────┤
|
|
35
|
+
│ Records every │ Registers models, │ Generates a structured│
|
|
36
|
+
│ cell execution │ tracks experiments, │ report (data, method, │
|
|
37
|
+
│ in order. Exports│ parameters, metrics, │ results, limitations) │
|
|
38
|
+
│ an honest Python │ and serves models. │ from code annotations │
|
|
39
|
+
│ script of what │ Uses MLflow as-is. │ using an LLM. │
|
|
40
|
+
│ actually ran. │ │ │
|
|
41
|
+
├──────────────────┴──────────────────────┴───────────────────────┤
|
|
42
|
+
│ Together: a non-technical reviewer can verify what was built, │
|
|
43
|
+
│ how it was built, and reproduce the result independently. │
|
|
44
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Data flow:**
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
Notebook session
|
|
51
|
+
│
|
|
52
|
+
├── JupyterTracker records every cell execution (parallel, live)
|
|
53
|
+
│ └── export_script() → ordered .py file with timing
|
|
54
|
+
│
|
|
55
|
+
├── MLflow tracks experiments, parameters, and metrics (parallel, live)
|
|
56
|
+
│ └── model registry → reproducible run IDs
|
|
57
|
+
│
|
|
58
|
+
└── On demand: Whitepaper generator
|
|
59
|
+
├── pulls execution log from JupyterTracker
|
|
60
|
+
├── pulls run metadata from MLflow
|
|
61
|
+
└── uses wpr_-prefixed function outputs as report sections
|
|
62
|
+
└── LLM assembles → structured whitepaper (PDF/Markdown)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Component 1: JupyterTracker
|
|
68
|
+
|
|
69
|
+
Track Jupyter notebook cell executions and export a clean, ordered Python script — exactly what ran, in the order it ran.
|
|
70
|
+
|
|
71
|
+
### Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install jupytertracker
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Usage
|
|
78
|
+
|
|
79
|
+
Add one line at the top of your notebook:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
import jupytertracker
|
|
83
|
+
jupytertracker.start()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
When you're done, export:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
jupytertracker.export_script("my_analysis.py")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The output is a `.py` file with every cell execution in order, one block per run:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# Generated by jupytertracker (sequential mode)
|
|
96
|
+
# Total execution time: 2m 14.3s
|
|
97
|
+
# Cells recorded: 5
|
|
98
|
+
|
|
99
|
+
# execution 1 [340ms]
|
|
100
|
+
x = load_data("train.csv")
|
|
101
|
+
|
|
102
|
+
# execution 2 [1m 52.1s]
|
|
103
|
+
model = train(x, lr=0.01)
|
|
104
|
+
|
|
105
|
+
# execution 3 [18.4s]
|
|
106
|
+
evaluate(model)
|
|
107
|
+
|
|
108
|
+
# execution 4 (re-run) [1m 48.7s]
|
|
109
|
+
model = train(x, lr=0.1)
|
|
110
|
+
|
|
111
|
+
# execution 5 (re-run) [15.1s]
|
|
112
|
+
evaluate(model)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### API
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
jupytertracker.start(ip=None) # start tracking; idempotent
|
|
119
|
+
jupytertracker.stop() # stop tracking; next start() begins fresh
|
|
120
|
+
jupytertracker.export_script(path) # write execution log to .py file
|
|
121
|
+
jupytertracker.clear() # clear the log without stopping
|
|
122
|
+
jupytertracker.get_log() # return list of ExecutionRecord
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Notes
|
|
126
|
+
|
|
127
|
+
- **Call `start()` in your very first cell**, before any imports or data loading. The tracker only records what runs after `start()` is called. Any state built up before — loaded dataframes, imported libraries, defined variables — is invisible to the tracker and will be missing from the exported script.
|
|
128
|
+
|
|
129
|
+
- **The exported script is an execution record, not a guaranteed reproducible script.** If cells depended on state that existed in the kernel but wasn't captured (see above), the script will fail with a `NameError` when run top-to-bottom.
|
|
130
|
+
|
|
131
|
+
- **Failed cells are excluded.** Cells that raise an exception, have a syntax error, or are interrupted by the user are not recorded — only successful executions appear in the output.
|
|
132
|
+
|
|
133
|
+
- **Kernel restart** resets tracking automatically (Python state is cleared). Call `export_script()` before restarting if you want to preserve the session.
|
|
134
|
+
|
|
135
|
+
- Magic commands (`%matplotlib inline`, `!pip install ...`) are included with a comment noting they require a Jupyter environment.
|
|
136
|
+
|
|
137
|
+
## Related projects
|
|
138
|
+
|
|
139
|
+
- **[ipyflow](https://github.com/ipyflow/ipyflow)** — reactive Python kernel that tracks dataflow between cells and can recover the minimal set of cells needed to reproduce an output. Requires switching kernels; takes a "prevent the mess" approach vs. jupytertracker's "record the mess" approach.
|
|
140
|
+
- **[papermill](https://github.com/nteract/papermill)** — parameterizes and executes notebooks top-to-bottom. Good for batch runs; doesn't handle interactive out-of-order execution.
|
|
141
|
+
- **[reprozip-jupyter](https://pypi.org/project/reprozip-jupyter/)** — packs the full notebook environment (libraries, data) for portability. Solves environment reproducibility, not execution-order reproducibility.
|
|
142
|
+
- **[MLflow](https://mlflow.org)** — experiment tracking, model registry, and model serving. Component 2 of this system.
|
|
143
|
+
|
|
144
|
+
## Roadmap
|
|
145
|
+
|
|
146
|
+
- **v2:** `mode='dedup'` — deduplicate to the last version of each cell, ordered by last execution. For "clean up my notebook" workflows.
|
|
147
|
+
- **Component 2:** MLflow integration — link JupyterTracker sessions to MLflow run IDs automatically.
|
|
148
|
+
- **Component 3:** Whitepaper generator — `wpr_`-prefixed functions collect outputs for LLM-generated structured reports.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# jupytertracker
|
|
2
|
+
|
|
3
|
+
Part of an end-to-end ML model management system for replicable machine learning.
|
|
4
|
+
|
|
5
|
+
## The problem
|
|
6
|
+
|
|
7
|
+
Building a machine learning model in a Jupyter notebook is iterative and messy — cells run out of order, code gets modified and re-run, hyperparameters get tweaked. When a model reviewer asks "how did you build this?", the data scientist has to manually reconstruct the process. When a compliance team asks for documentation, someone has to write it by hand.
|
|
8
|
+
|
|
9
|
+
The result: models that can't be independently replicated, and whitepapers that are written after the fact from memory rather than from the actual process.
|
|
10
|
+
|
|
11
|
+
## System vision
|
|
12
|
+
|
|
13
|
+
This library is Component 1 of a three-part system for making the ML modeling process fully replicable and auditable:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
17
|
+
│ ML Model Management System │
|
|
18
|
+
├──────────────────┬──────────────────────┬───────────────────────┤
|
|
19
|
+
│ Component 1 │ Component 2 │ Component 3 │
|
|
20
|
+
│ JupyterTracker │ MLflow Integration │ Whitepaper Generator │
|
|
21
|
+
│ (this library) │ │ │
|
|
22
|
+
├──────────────────┼──────────────────────┼───────────────────────┤
|
|
23
|
+
│ Records every │ Registers models, │ Generates a structured│
|
|
24
|
+
│ cell execution │ tracks experiments, │ report (data, method, │
|
|
25
|
+
│ in order. Exports│ parameters, metrics, │ results, limitations) │
|
|
26
|
+
│ an honest Python │ and serves models. │ from code annotations │
|
|
27
|
+
│ script of what │ Uses MLflow as-is. │ using an LLM. │
|
|
28
|
+
│ actually ran. │ │ │
|
|
29
|
+
├──────────────────┴──────────────────────┴───────────────────────┤
|
|
30
|
+
│ Together: a non-technical reviewer can verify what was built, │
|
|
31
|
+
│ how it was built, and reproduce the result independently. │
|
|
32
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Data flow:**
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
Notebook session
|
|
39
|
+
│
|
|
40
|
+
├── JupyterTracker records every cell execution (parallel, live)
|
|
41
|
+
│ └── export_script() → ordered .py file with timing
|
|
42
|
+
│
|
|
43
|
+
├── MLflow tracks experiments, parameters, and metrics (parallel, live)
|
|
44
|
+
│ └── model registry → reproducible run IDs
|
|
45
|
+
│
|
|
46
|
+
└── On demand: Whitepaper generator
|
|
47
|
+
├── pulls execution log from JupyterTracker
|
|
48
|
+
├── pulls run metadata from MLflow
|
|
49
|
+
└── uses wpr_-prefixed function outputs as report sections
|
|
50
|
+
└── LLM assembles → structured whitepaper (PDF/Markdown)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Component 1: JupyterTracker
|
|
56
|
+
|
|
57
|
+
Track Jupyter notebook cell executions and export a clean, ordered Python script — exactly what ran, in the order it ran.
|
|
58
|
+
|
|
59
|
+
### Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install jupytertracker
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Usage
|
|
66
|
+
|
|
67
|
+
Add one line at the top of your notebook:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import jupytertracker
|
|
71
|
+
jupytertracker.start()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
When you're done, export:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
jupytertracker.export_script("my_analysis.py")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The output is a `.py` file with every cell execution in order, one block per run:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# Generated by jupytertracker (sequential mode)
|
|
84
|
+
# Total execution time: 2m 14.3s
|
|
85
|
+
# Cells recorded: 5
|
|
86
|
+
|
|
87
|
+
# execution 1 [340ms]
|
|
88
|
+
x = load_data("train.csv")
|
|
89
|
+
|
|
90
|
+
# execution 2 [1m 52.1s]
|
|
91
|
+
model = train(x, lr=0.01)
|
|
92
|
+
|
|
93
|
+
# execution 3 [18.4s]
|
|
94
|
+
evaluate(model)
|
|
95
|
+
|
|
96
|
+
# execution 4 (re-run) [1m 48.7s]
|
|
97
|
+
model = train(x, lr=0.1)
|
|
98
|
+
|
|
99
|
+
# execution 5 (re-run) [15.1s]
|
|
100
|
+
evaluate(model)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### API
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
jupytertracker.start(ip=None) # start tracking; idempotent
|
|
107
|
+
jupytertracker.stop() # stop tracking; next start() begins fresh
|
|
108
|
+
jupytertracker.export_script(path) # write execution log to .py file
|
|
109
|
+
jupytertracker.clear() # clear the log without stopping
|
|
110
|
+
jupytertracker.get_log() # return list of ExecutionRecord
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Notes
|
|
114
|
+
|
|
115
|
+
- **Call `start()` in your very first cell**, before any imports or data loading. The tracker only records what runs after `start()` is called. Any state built up before — loaded dataframes, imported libraries, defined variables — is invisible to the tracker and will be missing from the exported script.
|
|
116
|
+
|
|
117
|
+
- **The exported script is an execution record, not a guaranteed reproducible script.** If cells depended on state that existed in the kernel but wasn't captured (see above), the script will fail with a `NameError` when run top-to-bottom.
|
|
118
|
+
|
|
119
|
+
- **Failed cells are excluded.** Cells that raise an exception, have a syntax error, or are interrupted by the user are not recorded — only successful executions appear in the output.
|
|
120
|
+
|
|
121
|
+
- **Kernel restart** resets tracking automatically (Python state is cleared). Call `export_script()` before restarting if you want to preserve the session.
|
|
122
|
+
|
|
123
|
+
- Magic commands (`%matplotlib inline`, `!pip install ...`) are included with a comment noting they require a Jupyter environment.
|
|
124
|
+
|
|
125
|
+
## Related projects
|
|
126
|
+
|
|
127
|
+
- **[ipyflow](https://github.com/ipyflow/ipyflow)** — reactive Python kernel that tracks dataflow between cells and can recover the minimal set of cells needed to reproduce an output. Requires switching kernels; takes a "prevent the mess" approach vs. jupytertracker's "record the mess" approach.
|
|
128
|
+
- **[papermill](https://github.com/nteract/papermill)** — parameterizes and executes notebooks top-to-bottom. Good for batch runs; doesn't handle interactive out-of-order execution.
|
|
129
|
+
- **[reprozip-jupyter](https://pypi.org/project/reprozip-jupyter/)** — packs the full notebook environment (libraries, data) for portability. Solves environment reproducibility, not execution-order reproducibility.
|
|
130
|
+
- **[MLflow](https://mlflow.org)** — experiment tracking, model registry, and model serving. Component 2 of this system.
|
|
131
|
+
|
|
132
|
+
## Roadmap
|
|
133
|
+
|
|
134
|
+
- **v2:** `mode='dedup'` — deduplicate to the last version of each cell, ordered by last execution. For "clean up my notebook" workflows.
|
|
135
|
+
- **Component 2:** MLflow integration — link JupyterTracker sessions to MLflow run IDs automatically.
|
|
136
|
+
- **Component 3:** Whitepaper generator — `wpr_`-prefixed functions collect outputs for LLM-generated structured reports.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jupytertracker"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Track Jupyter notebook cell execution and export a clean, ordered Python script"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"ipython>=7.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=7.0",
|
|
19
|
+
"nbformat>=5.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/jupytertracker"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
jupytertracker — record Jupyter notebook cell executions and export an ordered script.
|
|
3
|
+
|
|
4
|
+
Basic usage:
|
|
5
|
+
import jupytertracker
|
|
6
|
+
jupytertracker.start()
|
|
7
|
+
# ... run cells in your notebook ...
|
|
8
|
+
jupytertracker.export_script("output.py")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from .tracker import Tracker
|
|
17
|
+
from .exporter import export_sequential
|
|
18
|
+
|
|
19
|
+
_tracker: Optional[Tracker] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def start(ip=None) -> None:
|
|
23
|
+
"""Start tracking cell executions. Safe to call multiple times (idempotent)."""
|
|
24
|
+
global _tracker
|
|
25
|
+
if _tracker is None:
|
|
26
|
+
_tracker = Tracker()
|
|
27
|
+
_tracker.start(ip=ip)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stop() -> None:
|
|
31
|
+
"""Stop tracking. Does nothing if tracking was not started."""
|
|
32
|
+
if _tracker is not None:
|
|
33
|
+
_tracker.stop()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def export_script(path: str, mode: str = "sequential") -> None:
|
|
37
|
+
"""Export the recorded execution log to a Python script.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: Output file path (e.g. 'output.py').
|
|
41
|
+
mode: 'sequential' (default) — every execution in order, no deduplication.
|
|
42
|
+
'dedup' — last version of each cell only (deferred to v2).
|
|
43
|
+
"""
|
|
44
|
+
if _tracker is None:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"Tracking has not been started. Call jupytertracker.start() first."
|
|
47
|
+
)
|
|
48
|
+
if mode == "sequential":
|
|
49
|
+
export_sequential(_tracker.log, path)
|
|
50
|
+
elif mode == "dedup":
|
|
51
|
+
raise NotImplementedError(
|
|
52
|
+
"mode='dedup' is planned for v2. Use mode='sequential' (the default)."
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(f"Unknown mode '{mode}'. Use 'sequential'.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def clear() -> None:
|
|
59
|
+
"""Clear the recorded execution log without stopping tracking."""
|
|
60
|
+
if _tracker is not None:
|
|
61
|
+
_tracker.clear()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_log():
|
|
65
|
+
"""Return a copy of the current execution log (list of ExecutionRecord)."""
|
|
66
|
+
if _tracker is None:
|
|
67
|
+
return []
|
|
68
|
+
return _tracker.log
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import textwrap
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .tracker import ExecutionRecord
|
|
8
|
+
|
|
9
|
+
_HEADER_TEMPLATE = """\
|
|
10
|
+
# Generated by jupytertracker (sequential mode)
|
|
11
|
+
# Total execution time: {total_time}
|
|
12
|
+
# Cells recorded: {cell_count}
|
|
13
|
+
#
|
|
14
|
+
# Each block below reflects exactly what ran, in the order it ran.
|
|
15
|
+
# A cell that was modified and re-run appears multiple times — once per execution.
|
|
16
|
+
# NOTE: This script may not run top-to-bottom without error if cells relied on
|
|
17
|
+
# intermediate kernel state not captured here. It is an execution record, not a
|
|
18
|
+
# guaranteed reproducible script.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fmt_duration(seconds: float) -> str:
|
|
23
|
+
"""Human-readable duration: '34ms', '1.23s', '2m 5.1s'."""
|
|
24
|
+
if seconds < 1:
|
|
25
|
+
return f"{seconds * 1000:.0f}ms"
|
|
26
|
+
if seconds < 60:
|
|
27
|
+
return f"{seconds:.2f}s"
|
|
28
|
+
mins = int(seconds // 60)
|
|
29
|
+
secs = seconds % 60
|
|
30
|
+
return f"{mins}m {secs:.1f}s"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def export_sequential(log: List[ExecutionRecord], path: str | Path) -> None:
|
|
34
|
+
"""Write the raw execution log to a .py file, one block per execution."""
|
|
35
|
+
if not log:
|
|
36
|
+
header = _HEADER_TEMPLATE.format(total_time="0ms", cell_count=0)
|
|
37
|
+
Path(path).write_text(header + "# No cells were recorded.\n", encoding="utf-8")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
total_seconds = sum(r.duration for r in log)
|
|
41
|
+
header = _HEADER_TEMPLATE.format(
|
|
42
|
+
total_time=_fmt_duration(total_seconds),
|
|
43
|
+
cell_count=len(log),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
blocks = [header]
|
|
47
|
+
for record in log:
|
|
48
|
+
source = record.source.rstrip("\n")
|
|
49
|
+
comment = _magic_comment(source)
|
|
50
|
+
timing = _fmt_duration(record.duration)
|
|
51
|
+
block = f"# execution {record.exec_count} [{timing}]\n{comment}{source}\n"
|
|
52
|
+
blocks.append(block)
|
|
53
|
+
|
|
54
|
+
Path(path).write_text("\n".join(blocks) + "\n", encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _magic_comment(source: str) -> str:
|
|
58
|
+
first_line = source.lstrip().split("\n")[0]
|
|
59
|
+
if first_line.startswith("%") or first_line.startswith("!"):
|
|
60
|
+
return "# magic/shell command — requires Jupyter environment to run as-is\n"
|
|
61
|
+
return ""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ExecutionRecord:
|
|
11
|
+
exec_count: int
|
|
12
|
+
source: str
|
|
13
|
+
timestamp: float
|
|
14
|
+
duration: float = 0.0 # seconds; set by post_run_cell
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Tracker:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._log: List[ExecutionRecord] = []
|
|
20
|
+
self._ip = None
|
|
21
|
+
self._registered = False
|
|
22
|
+
self._counter = 0 # own counter — ip.execution_count isn't reliable pre-run
|
|
23
|
+
self._pending = None # staged record; committed only on successful post_run_cell
|
|
24
|
+
|
|
25
|
+
def start(self, ip=None) -> None:
|
|
26
|
+
if self._registered:
|
|
27
|
+
return # idempotent — already tracking, do nothing
|
|
28
|
+
if ip is None:
|
|
29
|
+
try:
|
|
30
|
+
from IPython import get_ipython
|
|
31
|
+
ip = get_ipython()
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
if ip is None:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"No active IPython kernel found. "
|
|
37
|
+
"Call jupytertracker.start() from inside a Jupyter notebook, "
|
|
38
|
+
"or pass an IPython instance: jupytertracker.start(ip=get_ipython())"
|
|
39
|
+
)
|
|
40
|
+
self._ip = ip
|
|
41
|
+
self._log.clear() # fresh session — discard any log from a previous run
|
|
42
|
+
self._counter = 0
|
|
43
|
+
self._pending = None
|
|
44
|
+
ip.events.register("pre_run_cell", self._on_pre_run_cell)
|
|
45
|
+
ip.events.register("post_run_cell", self._on_post_run_cell)
|
|
46
|
+
self._registered = True
|
|
47
|
+
|
|
48
|
+
def stop(self) -> None:
|
|
49
|
+
if not self._registered or self._ip is None:
|
|
50
|
+
return
|
|
51
|
+
for event, handler in [
|
|
52
|
+
("pre_run_cell", self._on_pre_run_cell),
|
|
53
|
+
("post_run_cell", self._on_post_run_cell),
|
|
54
|
+
]:
|
|
55
|
+
try:
|
|
56
|
+
self._ip.events.unregister(event, handler)
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
self._pending = None
|
|
60
|
+
self._registered = False
|
|
61
|
+
|
|
62
|
+
def _on_pre_run_cell(self, info) -> None:
|
|
63
|
+
try:
|
|
64
|
+
self._counter += 1
|
|
65
|
+
self._pending = ExecutionRecord(
|
|
66
|
+
exec_count=self._counter,
|
|
67
|
+
source=info.raw_cell,
|
|
68
|
+
timestamp=time.time(),
|
|
69
|
+
)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
print(f"[jupytertracker] hook error (ignored): {exc}", file=sys.stderr)
|
|
72
|
+
|
|
73
|
+
def _on_post_run_cell(self, result) -> None:
|
|
74
|
+
try:
|
|
75
|
+
if self._pending is None:
|
|
76
|
+
return
|
|
77
|
+
if result.success:
|
|
78
|
+
self._pending.duration = time.time() - self._pending.timestamp
|
|
79
|
+
self._log.append(self._pending)
|
|
80
|
+
else:
|
|
81
|
+
# Discard: error, exception, or user interruption
|
|
82
|
+
self._counter -= 1
|
|
83
|
+
self._pending = None
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
print(f"[jupytertracker] hook error (ignored): {exc}", file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def log(self) -> List[ExecutionRecord]:
|
|
89
|
+
return list(self._log)
|
|
90
|
+
|
|
91
|
+
def clear(self) -> None:
|
|
92
|
+
self._log.clear()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import jupytertracker
|
|
3
|
+
from IPython.testing.globalipapp import start_ipython
|
|
4
|
+
|
|
5
|
+
# Start the global IPython app once and keep a reference to it.
|
|
6
|
+
_IP = start_ipython()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def reset_tracker():
|
|
11
|
+
"""Reset module-level singleton and IPython execution count between tests."""
|
|
12
|
+
jupytertracker.stop()
|
|
13
|
+
jupytertracker._tracker = None
|
|
14
|
+
if _IP is not None:
|
|
15
|
+
_IP.execution_count = 1
|
|
16
|
+
yield
|
|
17
|
+
jupytertracker.stop()
|
|
18
|
+
jupytertracker._tracker = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def ip():
|
|
23
|
+
"""Return the global IPython instance."""
|
|
24
|
+
return _IP
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from jupytertracker.tracker import ExecutionRecord
|
|
4
|
+
from jupytertracker.exporter import export_sequential
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _records(*sources, durations=None):
|
|
8
|
+
if durations is None:
|
|
9
|
+
durations = [0.1 * (i + 1) for i in range(len(sources))]
|
|
10
|
+
return [
|
|
11
|
+
ExecutionRecord(exec_count=i + 1, source=src, timestamp=float(i), duration=dur)
|
|
12
|
+
for i, (src, dur) in enumerate(zip(sources, durations))
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_sequential_preserves_all_executions(tmp_path):
|
|
17
|
+
log = _records("x = 1", "y = 2", "x = 99", "y = 2")
|
|
18
|
+
out = tmp_path / "out.py"
|
|
19
|
+
export_sequential(log, out)
|
|
20
|
+
content = out.read_text()
|
|
21
|
+
assert content.count("# execution") == 4
|
|
22
|
+
assert "x = 1" in content
|
|
23
|
+
assert "x = 99" in content
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_sequential_preserves_modified_source_at_each_run(tmp_path):
|
|
27
|
+
log = _records("model = train(lr=0.01)", "model = train(lr=0.1)")
|
|
28
|
+
out = tmp_path / "out.py"
|
|
29
|
+
export_sequential(log, out)
|
|
30
|
+
content = out.read_text()
|
|
31
|
+
assert "lr=0.01" in content
|
|
32
|
+
assert "lr=0.1" in content
|
|
33
|
+
# Both versions present — neither deduplicated
|
|
34
|
+
assert content.index("lr=0.01") < content.index("lr=0.1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_sequential_execution_order(tmp_path):
|
|
38
|
+
log = _records("a = 1", "b = 2", "c = 3", "b = 99", "c = 3")
|
|
39
|
+
out = tmp_path / "out.py"
|
|
40
|
+
export_sequential(log, out)
|
|
41
|
+
content = out.read_text()
|
|
42
|
+
lines = [l for l in content.splitlines() if l.startswith("# execution")]
|
|
43
|
+
assert len(lines) == 5
|
|
44
|
+
for i, line in enumerate(lines, start=1):
|
|
45
|
+
assert line.startswith(f"# execution {i} [")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_empty_log_produces_header_only(tmp_path):
|
|
49
|
+
out = tmp_path / "out.py"
|
|
50
|
+
export_sequential([], out)
|
|
51
|
+
content = out.read_text()
|
|
52
|
+
assert "No cells were recorded" in content
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_magic_command_gets_comment(tmp_path):
|
|
56
|
+
log = _records("%matplotlib inline", "x = 1")
|
|
57
|
+
out = tmp_path / "out.py"
|
|
58
|
+
export_sequential(log, out)
|
|
59
|
+
content = out.read_text()
|
|
60
|
+
assert "magic/shell command" in content
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_shell_command_gets_comment(tmp_path):
|
|
64
|
+
log = _records("!pip install pandas", "import pandas")
|
|
65
|
+
out = tmp_path / "out.py"
|
|
66
|
+
export_sequential(log, out)
|
|
67
|
+
content = out.read_text()
|
|
68
|
+
assert "magic/shell command" in content
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_normal_cell_no_magic_comment(tmp_path):
|
|
72
|
+
log = _records("x = 1 + 1")
|
|
73
|
+
out = tmp_path / "out.py"
|
|
74
|
+
export_sequential(log, out)
|
|
75
|
+
content = out.read_text()
|
|
76
|
+
assert "magic/shell" not in content
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_output_file_has_header_warning(tmp_path):
|
|
80
|
+
log = _records("x = 1")
|
|
81
|
+
out = tmp_path / "out.py"
|
|
82
|
+
export_sequential(log, out)
|
|
83
|
+
content = out.read_text()
|
|
84
|
+
assert "jupytertracker" in content
|
|
85
|
+
assert "sequential mode" in content
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_execution_time_shown_per_cell(tmp_path):
|
|
89
|
+
log = _records("x = 1", "y = 2", durations=[0.5, 1.25])
|
|
90
|
+
out = tmp_path / "out.py"
|
|
91
|
+
export_sequential(log, out)
|
|
92
|
+
content = out.read_text()
|
|
93
|
+
assert "500ms" in content
|
|
94
|
+
assert "1.25s" in content
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_total_execution_time_in_header(tmp_path):
|
|
98
|
+
log = _records("x = 1", "y = 2", durations=[30.0, 45.0])
|
|
99
|
+
out = tmp_path / "out.py"
|
|
100
|
+
export_sequential(log, out)
|
|
101
|
+
content = out.read_text()
|
|
102
|
+
# 75 seconds total = 1m 15.0s
|
|
103
|
+
assert "1m 15.0s" in content
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_cell_count_in_header(tmp_path):
|
|
107
|
+
log = _records("a = 1", "b = 2", "c = 3")
|
|
108
|
+
out = tmp_path / "out.py"
|
|
109
|
+
export_sequential(log, out)
|
|
110
|
+
content = out.read_text()
|
|
111
|
+
assert "Cells recorded: 3" in content
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_fmt_duration_ms(tmp_path):
|
|
115
|
+
log = _records("x = 1", durations=[0.034])
|
|
116
|
+
out = tmp_path / "out.py"
|
|
117
|
+
export_sequential(log, out)
|
|
118
|
+
assert "34ms" in out.read_text()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_duration_recorded_in_tracker(ip):
|
|
122
|
+
import jupytertracker
|
|
123
|
+
jupytertracker.start(ip=ip)
|
|
124
|
+
ip.run_cell("import time; time.sleep(0.05)")
|
|
125
|
+
log = jupytertracker.get_log()
|
|
126
|
+
assert len(log) == 1
|
|
127
|
+
assert log[0].duration >= 0.05
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import jupytertracker
|
|
4
|
+
from conftest import _IP as _global_ip
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_export_before_start_raises():
|
|
8
|
+
with pytest.raises(RuntimeError, match="not been started"):
|
|
9
|
+
jupytertracker.export_script("/tmp/out.py")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_unknown_mode_raises(tmp_path):
|
|
13
|
+
jupytertracker.start(ip=_global_ip)
|
|
14
|
+
with pytest.raises(ValueError, match="Unknown mode"):
|
|
15
|
+
jupytertracker.export_script(str(tmp_path / "out.py"), mode="unknown")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_dedup_mode_raises_not_implemented(tmp_path):
|
|
19
|
+
jupytertracker.start(ip=_global_ip)
|
|
20
|
+
with pytest.raises(NotImplementedError):
|
|
21
|
+
jupytertracker.export_script(str(tmp_path / "out.py"), mode="dedup")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_start_stop_start_clears_log(tmp_path):
|
|
25
|
+
ip = _global_ip
|
|
26
|
+
jupytertracker.start(ip=ip)
|
|
27
|
+
ip.run_cell("a = 1")
|
|
28
|
+
jupytertracker.stop()
|
|
29
|
+
ip.run_cell("b = 2") # not tracked
|
|
30
|
+
jupytertracker.start(ip=ip) # fresh session — old log discarded
|
|
31
|
+
ip.run_cell("c = 3")
|
|
32
|
+
log = jupytertracker.get_log()
|
|
33
|
+
sources = [r.source for r in log]
|
|
34
|
+
assert not any("a = 1" in s for s in sources) # pre-stop entries gone
|
|
35
|
+
assert not any("b = 2" in s for s in sources) # untracked — still absent
|
|
36
|
+
assert any("c = 3" in s for s in sources) # post-restart entry present
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_full_pipeline(tmp_path):
|
|
40
|
+
ip = _global_ip
|
|
41
|
+
jupytertracker.start(ip=ip)
|
|
42
|
+
ip.run_cell("x = 10")
|
|
43
|
+
ip.run_cell("y = 20")
|
|
44
|
+
ip.run_cell("x = 99") # re-run with new value
|
|
45
|
+
ip.run_cell("y = 20") # re-run unchanged
|
|
46
|
+
out = tmp_path / "output.py"
|
|
47
|
+
jupytertracker.export_script(str(out))
|
|
48
|
+
content = out.read_text()
|
|
49
|
+
assert content.count("# execution") == 4
|
|
50
|
+
assert "x = 10" in content
|
|
51
|
+
assert "x = 99" in content
|
|
52
|
+
assert content.index("x = 10") < content.index("x = 99")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_clear_resets_log():
|
|
56
|
+
ip = _global_ip
|
|
57
|
+
jupytertracker.start(ip=ip)
|
|
58
|
+
ip.run_cell("a = 1")
|
|
59
|
+
assert len(jupytertracker.get_log()) == 1
|
|
60
|
+
jupytertracker.clear()
|
|
61
|
+
assert jupytertracker.get_log() == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_stop_before_start_does_not_raise():
|
|
65
|
+
jupytertracker.stop() # _tracker is None — should not raise
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_get_log_before_start_returns_empty():
|
|
69
|
+
assert jupytertracker.get_log() == []
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from IPython.testing.globalipapp import get_ipython
|
|
3
|
+
from jupytertracker.tracker import Tracker
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _run_cell(ip, source: str):
|
|
7
|
+
"""Simulate a cell execution through the real IPython kernel."""
|
|
8
|
+
ip.run_cell(source)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_records_single_cell(ip):
|
|
12
|
+
tracker = Tracker()
|
|
13
|
+
tracker.start(ip=ip)
|
|
14
|
+
_run_cell(ip, "x = 1")
|
|
15
|
+
log = tracker.log
|
|
16
|
+
assert len(log) == 1
|
|
17
|
+
assert "x = 1" in log[0].source
|
|
18
|
+
tracker.stop()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_records_multiple_cells_in_order(ip):
|
|
22
|
+
tracker = Tracker()
|
|
23
|
+
tracker.start(ip=ip)
|
|
24
|
+
_run_cell(ip, "a = 1")
|
|
25
|
+
_run_cell(ip, "b = 2")
|
|
26
|
+
_run_cell(ip, "c = 3")
|
|
27
|
+
log = tracker.log
|
|
28
|
+
assert len(log) == 3
|
|
29
|
+
assert log[0].exec_count < log[1].exec_count < log[2].exec_count
|
|
30
|
+
tracker.stop()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_records_rerun_with_modified_source(ip):
|
|
34
|
+
tracker = Tracker()
|
|
35
|
+
tracker.start(ip=ip)
|
|
36
|
+
_run_cell(ip, "x = 1")
|
|
37
|
+
_run_cell(ip, "x = 99") # same "cell", modified source
|
|
38
|
+
log = tracker.log
|
|
39
|
+
assert len(log) == 2
|
|
40
|
+
assert "x = 1" in log[0].source
|
|
41
|
+
assert "x = 99" in log[1].source
|
|
42
|
+
tracker.stop()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_start_is_idempotent(ip):
|
|
46
|
+
tracker = Tracker()
|
|
47
|
+
tracker.start(ip=ip)
|
|
48
|
+
hook_count_before = len([h for h in ip.events.callbacks.get("pre_run_cell", [])])
|
|
49
|
+
tracker.start(ip=ip) # second call — must not double-register
|
|
50
|
+
hook_count_after = len([h for h in ip.events.callbacks.get("pre_run_cell", [])])
|
|
51
|
+
assert hook_count_before == hook_count_after
|
|
52
|
+
tracker.stop()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_stop_unregisters_hooks(ip):
|
|
56
|
+
tracker = Tracker()
|
|
57
|
+
tracker.start(ip=ip)
|
|
58
|
+
tracker.stop()
|
|
59
|
+
_run_cell(ip, "y = 42")
|
|
60
|
+
assert tracker.log == []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_stop_before_start_does_not_raise(ip):
|
|
64
|
+
tracker = Tracker()
|
|
65
|
+
tracker.stop() # should not raise
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_hook_exception_does_not_disrupt_execution(ip, capsys):
|
|
69
|
+
tracker = Tracker()
|
|
70
|
+
tracker.start(ip=ip)
|
|
71
|
+
|
|
72
|
+
# Corrupt the hook to raise intentionally
|
|
73
|
+
original = tracker._on_pre_run_cell
|
|
74
|
+
def bad_hook(info):
|
|
75
|
+
raise RuntimeError("intentional test error")
|
|
76
|
+
ip.events.unregister("pre_run_cell", original)
|
|
77
|
+
ip.events.register("pre_run_cell", bad_hook)
|
|
78
|
+
|
|
79
|
+
# Cell execution must still succeed despite bad hook
|
|
80
|
+
result = ip.run_cell("z = 7")
|
|
81
|
+
assert result.success
|
|
82
|
+
|
|
83
|
+
ip.events.unregister("pre_run_cell", bad_hook)
|
|
84
|
+
tracker.stop()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_start_without_ipython_raises(monkeypatch):
|
|
88
|
+
import IPython.core.interactiveshell as _shell
|
|
89
|
+
# Temporarily clear the global singleton so get_ipython() returns None
|
|
90
|
+
orig = _shell.InteractiveShell._instance
|
|
91
|
+
_shell.InteractiveShell._instance = None
|
|
92
|
+
try:
|
|
93
|
+
tracker = Tracker()
|
|
94
|
+
with pytest.raises(RuntimeError, match="No active IPython kernel"):
|
|
95
|
+
tracker.start(ip=None)
|
|
96
|
+
finally:
|
|
97
|
+
_shell.InteractiveShell._instance = orig
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_failed_cell_not_recorded(ip):
|
|
101
|
+
tracker = Tracker()
|
|
102
|
+
tracker.start(ip=ip)
|
|
103
|
+
_run_cell(ip, "x = 1") # succeeds
|
|
104
|
+
_run_cell(ip, "raise ValueError('boom')") # fails
|
|
105
|
+
_run_cell(ip, "y = 2") # succeeds
|
|
106
|
+
log = tracker.log
|
|
107
|
+
assert len(log) == 2
|
|
108
|
+
assert "x = 1" in log[0].source
|
|
109
|
+
assert "y = 2" in log[1].source
|
|
110
|
+
assert log[0].exec_count == 1
|
|
111
|
+
assert log[1].exec_count == 2
|
|
112
|
+
tracker.stop()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_syntax_error_cell_not_recorded(ip):
|
|
116
|
+
tracker = Tracker()
|
|
117
|
+
tracker.start(ip=ip)
|
|
118
|
+
_run_cell(ip, "x = 1")
|
|
119
|
+
_run_cell(ip, "def bad syntax(:") # syntax error — never executes
|
|
120
|
+
_run_cell(ip, "y = 2")
|
|
121
|
+
log = tracker.log
|
|
122
|
+
sources = [r.source for r in log]
|
|
123
|
+
assert len(log) == 2
|
|
124
|
+
assert any("x = 1" in s for s in sources)
|
|
125
|
+
assert any("y = 2" in s for s in sources)
|
|
126
|
+
tracker.stop()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_exec_count_stays_contiguous_after_failure(ip):
|
|
130
|
+
tracker = Tracker()
|
|
131
|
+
tracker.start(ip=ip)
|
|
132
|
+
_run_cell(ip, "a = 1")
|
|
133
|
+
_run_cell(ip, "raise RuntimeError()")
|
|
134
|
+
_run_cell(ip, "b = 2")
|
|
135
|
+
log = tracker.log
|
|
136
|
+
assert len(log) == 2
|
|
137
|
+
assert log[0].exec_count == 1
|
|
138
|
+
assert log[1].exec_count == 2 # counter rolled back on failure, so next success is 2
|
|
139
|
+
tracker.stop()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_clear_empties_log(ip):
|
|
143
|
+
tracker = Tracker()
|
|
144
|
+
tracker.start(ip=ip)
|
|
145
|
+
_run_cell(ip, "a = 1")
|
|
146
|
+
assert len(tracker.log) == 1
|
|
147
|
+
tracker.clear()
|
|
148
|
+
assert tracker.log == []
|
|
149
|
+
tracker.stop()
|