cellwatch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cellwatch/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """cellwatch: see which variables a Jupyter cell created, changed or deleted."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Public names and the module each one lives in.
6
+ _PUBLIC = {
7
+ "Record": "snapshot",
8
+ "snapshot": "snapshot",
9
+ "Change": "compare",
10
+ "diff": "compare",
11
+ "format_changes": "report",
12
+ "History": "history",
13
+ "format_history": "history",
14
+ }
15
+
16
+ __all__ = sorted(_PUBLIC) + ["load_ipython_extension", "unload_ipython_extension"]
17
+
18
+
19
+ def __getattr__(name):
20
+ """Import a public name the first time someone asks for it.
21
+
22
+ Python calls this special function when ``cellwatch.<name>`` is not found
23
+ the normal way. Loading modules only when needed keeps each module usable
24
+ on its own.
25
+ """
26
+ if name in _PUBLIC:
27
+ from importlib import import_module
28
+
29
+ return getattr(import_module(f".{_PUBLIC[name]}", __name__), name)
30
+ raise AttributeError(f"module 'cellwatch' has no attribute {name!r}")
31
+
32
+
33
+ def load_ipython_extension(ipython):
34
+ """Called by ``%load_ext cellwatch``."""
35
+ from .magic import load
36
+
37
+ load(ipython)
38
+
39
+
40
+ def unload_ipython_extension(ipython):
41
+ """Called by ``%unload_ext cellwatch``."""
42
+ from .magic import unload
43
+
44
+ unload(ipython)
cellwatch/compare.py ADDED
@@ -0,0 +1,52 @@
1
+ """Step 2: compare two snapshots and list the differences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .snapshot import Record
8
+
9
+ KINDS = ("created", "changed", "deleted")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Change:
14
+ """One difference between two snapshots."""
15
+
16
+ name: str
17
+ kind: str # one of KINDS
18
+ before: Record | None # None when the variable was created
19
+ after: Record | None # None when the variable was deleted
20
+
21
+ @property
22
+ def in_place(self) -> bool:
23
+ """True if the same object was modified, e.g. by ``list.sort()``."""
24
+ return (
25
+ self.kind == "changed"
26
+ and self.before.identity == self.after.identity
27
+ and self.before.type_name == self.after.type_name
28
+ )
29
+
30
+
31
+ def diff(before: dict[str, Record], after: dict[str, Record]) -> list[Change]:
32
+ """List what was created, changed or deleted between two snapshots."""
33
+ changes = []
34
+ # "after.keys() - before.keys()" is the set of names only found in after.
35
+ for name in sorted(after.keys() - before.keys()):
36
+ changes.append(Change(name, "created", None, after[name]))
37
+ # "&" keeps the names found in both snapshots.
38
+ for name in sorted(after.keys() & before.keys()):
39
+ old, new = before[name], after[name]
40
+ if old.text != new.text or old.type_name != new.type_name:
41
+ changes.append(Change(name, "changed", old, new))
42
+ for name in sorted(before.keys() - after.keys()):
43
+ changes.append(Change(name, "deleted", before[name], None))
44
+ return changes
45
+
46
+
47
+ def count_by_kind(changes: list[Change]) -> dict[str, int]:
48
+ """Count the changes of each kind, e.g. {"created": 1, "changed": 2}."""
49
+ counts = {}
50
+ for change in changes:
51
+ counts[change.kind] = counts.get(change.kind, 0) + 1
52
+ return counts
cellwatch/history.py ADDED
@@ -0,0 +1,42 @@
1
+ """Step 4: remember the changes made by every watched cell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .compare import Change
6
+ from .report import describe
7
+
8
+
9
+ class History:
10
+ """A running log of changes, grouped by the cell that made them."""
11
+
12
+ def __init__(self):
13
+ self._entries = [] # list of (cell_number, Change) pairs
14
+
15
+ def record(self, cell_number: int, changes: list[Change]) -> None:
16
+ """Add the changes made by one cell."""
17
+ for change in changes:
18
+ self._entries.append((cell_number, change))
19
+
20
+ def entries(self, name: str | None = None) -> list[tuple[int, Change]]:
21
+ """All entries, or only those for the variable called ``name``."""
22
+ if name is None:
23
+ return list(self._entries)
24
+ return [entry for entry in self._entries if entry[1].name == name]
25
+
26
+ def clear(self) -> None:
27
+ """Forget everything recorded so far."""
28
+ self._entries.clear()
29
+
30
+ def __len__(self) -> int:
31
+ return len(self._entries)
32
+
33
+
34
+ def format_history(history: History, name: str | None = None) -> str:
35
+ """A text timeline, one line per change, labelled with its cell number."""
36
+ entries = history.entries(name)
37
+ subject = f"'{name}'" if name else "all variables"
38
+ if not entries:
39
+ return f"cellwatch: no recorded changes for {subject}"
40
+ lines = [f"cellwatch history for {subject}:"]
41
+ lines += [f" [{cell_number}] {describe(change)}" for cell_number, change in entries]
42
+ return "\n".join(lines)
cellwatch/magic.py ADDED
@@ -0,0 +1,114 @@
1
+ """Step 5: the Jupyter commands %%cellwatch and %cellwatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from IPython.core.magic import Magics, line_cell_magic, magics_class
6
+
7
+ from .compare import diff
8
+ from .history import History, format_history
9
+ from .report import format_changes
10
+ from .snapshot import snapshot
11
+
12
+ USAGE = """Usage:
13
+ %%cellwatch report on this cell (first line of the cell)
14
+ %cellwatch on | off report after every cell / stop
15
+ %cellwatch history list every recorded change
16
+ %cellwatch history NAME list the changes made to one variable
17
+ %cellwatch clear forget the recorded history"""
18
+
19
+
20
+ @magics_class
21
+ class CellWatchMagics(Magics):
22
+ """Adds the ``cellwatch`` magic command to a notebook."""
23
+
24
+ def __init__(self, shell):
25
+ super().__init__(shell)
26
+ self.history = History()
27
+ self._auto = False
28
+ self._before = None
29
+
30
+ @line_cell_magic
31
+ def cellwatch(self, line, cell=None):
32
+ """Report which variables a cell created, changed or deleted."""
33
+ if cell is not None:
34
+ self._run_and_report(cell)
35
+ return
36
+ words = line.split()
37
+ command = words[0].lower() if words else ""
38
+ if command == "on":
39
+ self._set_auto(True)
40
+ print("cellwatch: reporting after every cell (%cellwatch off to stop)")
41
+ elif command == "off":
42
+ self._set_auto(False)
43
+ print("cellwatch: automatic reporting stopped")
44
+ elif command == "history":
45
+ print(format_history(self.history, words[1] if len(words) > 1 else None))
46
+ elif command == "clear":
47
+ self.history.clear()
48
+ print("cellwatch: history cleared")
49
+ else:
50
+ print(USAGE)
51
+
52
+ def _run_and_report(self, cell):
53
+ was_auto = self._auto
54
+ self._auto = False # avoid a second report from automatic mode
55
+ cell_number = self._cell_number()
56
+ before = snapshot(self.shell.user_ns)
57
+ try:
58
+ self.shell.run_cell(cell)
59
+ finally:
60
+ self._auto = was_auto
61
+ changes = diff(before, snapshot(self.shell.user_ns))
62
+ self.history.record(cell_number, changes)
63
+ print(format_changes(changes))
64
+
65
+ def _cell_number(self):
66
+ """The number shown as ``In [n]`` beside the cell that is running.
67
+
68
+ IPython keeps every cell's code in a list called ``In``; the running
69
+ cell is its last item, so its number is the last index of that list.
70
+ """
71
+ inputs = self.shell.user_ns.get("In", [])
72
+ return max(len(inputs) - 1, 0)
73
+
74
+ def _set_auto(self, enabled):
75
+ if enabled == self._auto:
76
+ return
77
+ events = self.shell.events
78
+ if enabled:
79
+ events.register("pre_run_cell", self._pre_run_cell)
80
+ events.register("post_run_cell", self._post_run_cell)
81
+ else:
82
+ events.unregister("pre_run_cell", self._pre_run_cell)
83
+ events.unregister("post_run_cell", self._post_run_cell)
84
+ self._auto = enabled
85
+ self._before = None
86
+
87
+ def _pre_run_cell(self, info):
88
+ self._before = snapshot(self.shell.user_ns)
89
+
90
+ def _post_run_cell(self, result):
91
+ if self._before is None or not self._auto:
92
+ return
93
+ changes = diff(self._before, snapshot(self.shell.user_ns))
94
+ self._before = None
95
+ if changes:
96
+ self.history.record(self._cell_number(), changes)
97
+ print(format_changes(changes))
98
+
99
+
100
+ _instances = {}
101
+
102
+
103
+ def load(ipython):
104
+ """Register the magic command with a running IPython/Jupyter session."""
105
+ magics = CellWatchMagics(ipython)
106
+ _instances[id(ipython)] = magics
107
+ ipython.register_magics(magics)
108
+
109
+
110
+ def unload(ipython):
111
+ """Stop automatic reporting when the extension is unloaded."""
112
+ magics = _instances.pop(id(ipython), None)
113
+ if magics is not None:
114
+ magics._set_auto(False)
cellwatch/report.py ADDED
@@ -0,0 +1,42 @@
1
+ """Step 3: turn a list of changes into a short, readable text report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .compare import KINDS, Change, count_by_kind
6
+
7
+ MAX_WIDTH = 60 # longest value text shown in a report
8
+
9
+
10
+ def shorten(text: str, width: int = MAX_WIDTH) -> str:
11
+ """Put ``text`` on one line and cut it to ``width`` characters."""
12
+ text = " ".join(text.split())
13
+ return text if len(text) <= width else text[: width - 3] + "..."
14
+
15
+
16
+ def describe(change: Change) -> str:
17
+ """One report line: ``+`` created, ``~`` changed, ``-`` deleted."""
18
+ if change.kind == "created":
19
+ return f"+ {change.name} = {shorten(change.after.text)} ({change.after.type_name})"
20
+ if change.kind == "deleted":
21
+ return f"- {change.name} (was {shorten(change.before.text)})"
22
+ line = f"~ {change.name}: {shorten(change.before.text)} -> {shorten(change.after.text)}"
23
+ if change.before.type_name != change.after.type_name:
24
+ line += f" (type {change.before.type_name} -> {change.after.type_name})"
25
+ elif change.in_place:
26
+ line += " (modified in place)"
27
+ return line
28
+
29
+
30
+ def summarize(changes: list[Change]) -> str:
31
+ """A one-line count such as ``1 created, 2 changed``."""
32
+ counts = count_by_kind(changes)
33
+ return ", ".join(f"{counts[kind]} {kind}" for kind in KINDS if kind in counts)
34
+
35
+
36
+ def format_changes(changes: list[Change]) -> str:
37
+ """The full report for one cell."""
38
+ if not changes:
39
+ return "cellwatch: no variables changed"
40
+ lines = ["cellwatch: " + summarize(changes)]
41
+ lines += [" " + describe(change) for change in changes]
42
+ return "\n".join(lines)
cellwatch/snapshot.py ADDED
@@ -0,0 +1,50 @@
1
+ """Step 1: take a snapshot (a record of every user variable at one moment)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import types
6
+ from dataclasses import dataclass
7
+
8
+ # Names that IPython itself puts in the notebook; not the user's variables.
9
+ IPYTHON_NAMES = {"In", "Out", "exit", "quit", "get_ipython", "open"}
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Record:
14
+ """What we remember about one variable at one moment."""
15
+
16
+ text: str # repr() of the value, i.e. how Python prints it
17
+ type_name: str # e.g. "list"
18
+ identity: int # id() of the object; same id means same object
19
+
20
+
21
+ def is_user_variable(name: str, value: object) -> bool:
22
+ """Return False for private names, IPython's own names and modules."""
23
+ if name.startswith("_"):
24
+ return False
25
+ if name in IPYTHON_NAMES:
26
+ return False
27
+ if isinstance(value, types.ModuleType):
28
+ return False
29
+ return True
30
+
31
+
32
+ def safe_repr(value: object) -> str:
33
+ """Like repr(), but never raises an error."""
34
+ try:
35
+ return repr(value)
36
+ except Exception as error: # a broken __repr__ must not break the report
37
+ return f"<unprintable {type(value).__name__}: {error}>"
38
+
39
+
40
+ def snapshot(namespace: dict) -> dict[str, Record]:
41
+ """Record the current state of every user variable in ``namespace``.
42
+
43
+ ``namespace`` is a dictionary of variable names to values, for example
44
+ ``globals()`` or a notebook's ``user_ns``.
45
+ """
46
+ return {
47
+ name: Record(safe_repr(value), type(value).__name__, id(value))
48
+ for name, value in list(namespace.items())
49
+ if is_user_variable(name, value)
50
+ }
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.5
2
+ Name: cellwatch
3
+ Version: 0.1.0
4
+ Summary: See exactly which variables a Jupyter cell created, changed or deleted.
5
+ Project-URL: Homepage, https://github.com/Geoeoeo217/cellwatch
6
+ Project-URL: Documentation, https://github.com/Geoeoeo217/cellwatch#readme
7
+ Project-URL: Issues, https://github.com/Geoeoeo217/cellwatch/issues
8
+ Author: NATHANAEL George Steindhart, SETIAWAN Matthew Henson, LATHIF Muhammad Akmal, HANDAYA Clement Steward, KWAN Kendrick Matthew
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: diff,education,ipython,jupyter,notebook,variables
12
+ Classifier: Framework :: IPython
13
+ Classifier: Framework :: Jupyter
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: ipython>=7.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: build; extra == 'dev'
21
+ Requires-Dist: pytest; extra == 'dev'
22
+ Requires-Dist: twine; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # cellwatch
26
+
27
+ See exactly which variables a Jupyter cell **created**, **changed** or **deleted**.
28
+
29
+ ```text
30
+ %%cellwatch
31
+ scores.sort()
32
+ total = sum(scores)
33
+ del draft
34
+ ```
35
+
36
+ ```text
37
+ cellwatch: 1 created, 1 changed, 1 deleted
38
+ + total = 6 (int)
39
+ ~ scores: [3, 1, 2] -> [1, 2, 3] (modified in place)
40
+ - draft (was 'old text')
41
+ ```
42
+
43
+ ## Motivation
44
+
45
+ In a notebook, variables live on after a cell finishes, and any later cell can
46
+ quietly change them. Beginners often get stuck because a cell changed something
47
+ they did not expect: a list was sorted in place, a name was reused, or a
48
+ variable was overwritten by a cell that was run twice. Jupyter can list the
49
+ variables that exist right now (`%whos`), but it cannot tell you what a cell
50
+ just did to them.
51
+
52
+ ## What is new
53
+
54
+ - **A per-cell diff of variables.** `cellwatch` records the variables before a
55
+ cell runs, records them again afterwards, and reports only the differences.
56
+ - **It spots in-place changes.** `scores.sort()` does not assign anything, so
57
+ it is easy to miss. `cellwatch` labels it `modified in place`.
58
+ - **A timeline per variable.** `%cellwatch history x` lists every recorded
59
+ change to `x` and the cell that made it.
60
+ - **It complements existing tools.** `%whos` lists variables but shows no
61
+ changes. [Kishu](https://github.com/illinoisdata/kishu) can undo a cell but
62
+ does not explain what the cell changed. `cellwatch` is the explaining part,
63
+ with one small dependency (IPython, which Jupyter already includes).
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install cellwatch
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ Load the extension once per notebook:
74
+
75
+ ```python
76
+ %load_ext cellwatch
77
+ ```
78
+
79
+ **Report on one cell** by putting `%%cellwatch` on its first line:
80
+
81
+ ```python
82
+ %%cellwatch
83
+ x = 10
84
+ ```
85
+
86
+ **Report after every cell** until you switch it off:
87
+
88
+ ```python
89
+ %cellwatch on
90
+ ...
91
+ %cellwatch off
92
+ ```
93
+
94
+ **See the timeline of a variable** across all watched cells (the number in
95
+ brackets is the cell's execution number):
96
+
97
+ ```python
98
+ %cellwatch history scores
99
+ ```
100
+
101
+ ```text
102
+ cellwatch history for 'scores':
103
+ [1] + scores = [3, 1, 2] (list)
104
+ [2] ~ scores: [3, 1, 2] -> [1, 2, 3] (modified in place)
105
+ ```
106
+
107
+ `%cellwatch history` without a name lists every change, and `%cellwatch clear`
108
+ empties the history.
109
+
110
+ How to read a report:
111
+
112
+ | Symbol | Meaning |
113
+ |---|---|
114
+ | `+` | the variable was created |
115
+ | `~` | the variable's value changed (`before -> after`) |
116
+ | `-` | the variable was deleted |
117
+
118
+ Imported modules and names starting with `_` are not reported.
119
+
120
+ **Use it from plain Python** (no notebook needed):
121
+
122
+ ```python
123
+ from cellwatch import snapshot, diff, format_changes
124
+
125
+ before = snapshot(globals())
126
+ data = [1, 2, 3]
127
+ print(format_changes(diff(before, snapshot(globals()))))
128
+ ```
129
+
130
+ ## Example use cases
131
+
132
+ - **Debugging a surprise.** A result is wrong and you suspect an earlier cell.
133
+ Turn on `%cellwatch on`, rerun the cells, and read what each one touched.
134
+ - **Learning mutation vs. reassignment.** Compare `b = a` then `b.append(4)`
135
+ with `b = a + [4]`: the report shows that the first also changes `a`.
136
+ - **Teaching and demos.** An instructor can show the effect of each cell
137
+ without adding `print` statements everywhere.
138
+ - **Checking a notebook before submission.** Confirm that a cell does not
139
+ overwrite a variable another cell depends on.
140
+
141
+ ## Limitations
142
+
143
+ - Changes are detected by comparing the printed form (`repr`) of each value,
144
+ so an object whose printed form stays the same is reported as unchanged.
145
+ - Very large values can make each report slower, because they are printed
146
+ twice per cell.
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ git clone https://github.com/Geoeoeo217/cellwatch
152
+ cd cellwatch
153
+ pip install -e ".[dev]"
154
+ pytest
155
+ ```
156
+
157
+ How the code is organised:
158
+
159
+ | File | Job |
160
+ |---|---|
161
+ | `snapshot.py` | Record every user variable at one moment |
162
+ | `compare.py` | Compare two snapshots and list the differences |
163
+ | `report.py` | Turn the differences into a text report |
164
+ | `history.py` | Remember the changes made by every watched cell |
165
+ | `magic.py` | The `%%cellwatch` and `%cellwatch` notebook commands |
166
+
167
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how the team works and what is left
168
+ to build.
169
+
170
+ ## License
171
+
172
+ MIT
@@ -0,0 +1,10 @@
1
+ cellwatch/__init__.py,sha256=dC3SMFsp8ft4JcQP7k9wcU0Tp_fGLq9sFcruIpbXzps,1203
2
+ cellwatch/compare.py,sha256=yJwM5BCe3ej_ndGwPPqdhxRQcbxDUQefvM6fP5vrin0,1827
3
+ cellwatch/history.py,sha256=5kpx2JVElDaRpF-FfVryrGNit7HLDnmgbiarIu9vWxw,1502
4
+ cellwatch/magic.py,sha256=Cf-0u4HdeLaij4F8FkU0BV38VzzHf2CBVvaJ3ETIF_w,3901
5
+ cellwatch/report.py,sha256=dqNHKXt3tPhnl8sgHfwmOzTH_rPqp3K0CwTMf_2l_ak,1629
6
+ cellwatch/snapshot.py,sha256=JeB3MWxoSCoEtGsQP9jhyDg9KkIJLI0WjnuOFeJvEe4,1599
7
+ cellwatch-0.1.0.dist-info/METADATA,sha256=S-hCYCame8jQ9zFYjDTqIUEJZI4M_3IPZtMMIVpV8_I,5125
8
+ cellwatch-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ cellwatch-0.1.0.dist-info/licenses/LICENSE,sha256=etqyFJRjLt4mer4j8ZcWZHFF8ovQhX1S1gzUcsgO_f0,1075
10
+ cellwatch-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The cellwatch team
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.