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.
@@ -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,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ *.pyc
13
+ .DS_Store
@@ -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,7 @@
1
+ [options]
2
+ package_dir =
3
+ = src
4
+ packages = find:
5
+
6
+ [options.packages.find]
7
+ where = src
@@ -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()