cellwatch 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.
- cellwatch-0.1.0/.gitignore +8 -0
- cellwatch-0.1.0/CONTRIBUTING.md +69 -0
- cellwatch-0.1.0/LICENSE +21 -0
- cellwatch-0.1.0/PKG-INFO +172 -0
- cellwatch-0.1.0/README.md +148 -0
- cellwatch-0.1.0/pyproject.toml +41 -0
- cellwatch-0.1.0/src/cellwatch/__init__.py +44 -0
- cellwatch-0.1.0/src/cellwatch/compare.py +52 -0
- cellwatch-0.1.0/src/cellwatch/history.py +42 -0
- cellwatch-0.1.0/src/cellwatch/magic.py +114 -0
- cellwatch-0.1.0/src/cellwatch/report.py +42 -0
- cellwatch-0.1.0/src/cellwatch/snapshot.py +50 -0
- cellwatch-0.1.0/tests/test_compare.py +35 -0
- cellwatch-0.1.0/tests/test_history.py +30 -0
- cellwatch-0.1.0/tests/test_magic.py +52 -0
- cellwatch-0.1.0/tests/test_report.py +30 -0
- cellwatch-0.1.0/tests/test_snapshot.py +21 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Working on cellwatch as a team
|
|
2
|
+
|
|
3
|
+
## Who uploads what
|
|
4
|
+
|
|
5
|
+
The package is built from five modules. Each module depends only on the ones
|
|
6
|
+
above it in this table, so push them **in this order**.
|
|
7
|
+
|
|
8
|
+
| Order | Person | Files to push |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| 0 | Repository owner | `pyproject.toml`, `README.md`, `CONTRIBUTING.md`, `LICENSE`, `.gitignore`, `src/cellwatch/__init__.py` |
|
|
11
|
+
| 1 | Member 1 | `src/cellwatch/snapshot.py`, `tests/test_snapshot.py` |
|
|
12
|
+
| 2 | Member 2 | `src/cellwatch/compare.py`, `tests/test_compare.py` |
|
|
13
|
+
| 3 | Member 3 | `src/cellwatch/report.py`, `tests/test_report.py` |
|
|
14
|
+
| 4 | Member 4 | `src/cellwatch/history.py`, `tests/test_history.py` |
|
|
15
|
+
| 5 | Member 5 | `src/cellwatch/magic.py`, `tests/test_magic.py` |
|
|
16
|
+
|
|
17
|
+
The repository owner is also one of the five members. The `%%cellwatch`
|
|
18
|
+
command works once all five modules are in.
|
|
19
|
+
|
|
20
|
+
## One-time setup (every member)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git clone https://github.com/Geoeoeo217/cellwatch
|
|
24
|
+
cd cellwatch
|
|
25
|
+
pip install -e ".[dev]"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`git clone` downloads the repository. `pip install -e .` is an "editable"
|
|
29
|
+
install: Python uses the files in this folder directly, so your edits take
|
|
30
|
+
effect without reinstalling.
|
|
31
|
+
|
|
32
|
+
## Pushing your module
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git pull # get your teammates' latest work first
|
|
36
|
+
# copy your two files into src/cellwatch/ and tests/
|
|
37
|
+
pytest # every test must pass
|
|
38
|
+
git add src/cellwatch/YOURFILE.py tests/test_YOURFILE.py
|
|
39
|
+
git commit -m "Add YOURFILE module: one line on what it does"
|
|
40
|
+
git push
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If `git push` is rejected, a teammate pushed before you: run `git pull` and
|
|
44
|
+
then `git push` again.
|
|
45
|
+
|
|
46
|
+
## After the first five pushes
|
|
47
|
+
|
|
48
|
+
One commit each is a start, not a "regular commit history". Each member
|
|
49
|
+
should own their module from here on and keep improving it:
|
|
50
|
+
|
|
51
|
+
- **snapshot.py** – option to ignore chosen variable names; faster handling
|
|
52
|
+
of very large values.
|
|
53
|
+
- **compare.py** – for lists and dictionaries, say which items were added or
|
|
54
|
+
removed instead of only before/after.
|
|
55
|
+
- **report.py** – coloured HTML table for notebooks (green created, yellow
|
|
56
|
+
changed, red deleted).
|
|
57
|
+
- **history.py** – limit the history size; export the history as a list of
|
|
58
|
+
dictionaries.
|
|
59
|
+
- **magic.py** – `%cellwatch ignore NAME`; a demo notebook in `examples/`
|
|
60
|
+
that doubles as the 1-minute presentation script.
|
|
61
|
+
|
|
62
|
+
Small, frequent commits with clear messages are better than one big one.
|
|
63
|
+
|
|
64
|
+
## Publishing to PyPI
|
|
65
|
+
|
|
66
|
+
1. Fill in the author names in `pyproject.toml`.
|
|
67
|
+
2. Raise `version` in `pyproject.toml` and in `src/cellwatch/__init__.py`.
|
|
68
|
+
3. `python -m build` creates the files to upload in `dist/`.
|
|
69
|
+
4. `twine upload dist/*` uploads them; it asks for a PyPI API token.
|
cellwatch-0.1.0/LICENSE
ADDED
|
@@ -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.
|
cellwatch-0.1.0/PKG-INFO
ADDED
|
@@ -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,148 @@
|
|
|
1
|
+
# cellwatch
|
|
2
|
+
|
|
3
|
+
See exactly which variables a Jupyter cell **created**, **changed** or **deleted**.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
%%cellwatch
|
|
7
|
+
scores.sort()
|
|
8
|
+
total = sum(scores)
|
|
9
|
+
del draft
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```text
|
|
13
|
+
cellwatch: 1 created, 1 changed, 1 deleted
|
|
14
|
+
+ total = 6 (int)
|
|
15
|
+
~ scores: [3, 1, 2] -> [1, 2, 3] (modified in place)
|
|
16
|
+
- draft (was 'old text')
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Motivation
|
|
20
|
+
|
|
21
|
+
In a notebook, variables live on after a cell finishes, and any later cell can
|
|
22
|
+
quietly change them. Beginners often get stuck because a cell changed something
|
|
23
|
+
they did not expect: a list was sorted in place, a name was reused, or a
|
|
24
|
+
variable was overwritten by a cell that was run twice. Jupyter can list the
|
|
25
|
+
variables that exist right now (`%whos`), but it cannot tell you what a cell
|
|
26
|
+
just did to them.
|
|
27
|
+
|
|
28
|
+
## What is new
|
|
29
|
+
|
|
30
|
+
- **A per-cell diff of variables.** `cellwatch` records the variables before a
|
|
31
|
+
cell runs, records them again afterwards, and reports only the differences.
|
|
32
|
+
- **It spots in-place changes.** `scores.sort()` does not assign anything, so
|
|
33
|
+
it is easy to miss. `cellwatch` labels it `modified in place`.
|
|
34
|
+
- **A timeline per variable.** `%cellwatch history x` lists every recorded
|
|
35
|
+
change to `x` and the cell that made it.
|
|
36
|
+
- **It complements existing tools.** `%whos` lists variables but shows no
|
|
37
|
+
changes. [Kishu](https://github.com/illinoisdata/kishu) can undo a cell but
|
|
38
|
+
does not explain what the cell changed. `cellwatch` is the explaining part,
|
|
39
|
+
with one small dependency (IPython, which Jupyter already includes).
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install cellwatch
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Load the extension once per notebook:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
%load_ext cellwatch
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Report on one cell** by putting `%%cellwatch` on its first line:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
%%cellwatch
|
|
59
|
+
x = 10
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Report after every cell** until you switch it off:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
%cellwatch on
|
|
66
|
+
...
|
|
67
|
+
%cellwatch off
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**See the timeline of a variable** across all watched cells (the number in
|
|
71
|
+
brackets is the cell's execution number):
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
%cellwatch history scores
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
cellwatch history for 'scores':
|
|
79
|
+
[1] + scores = [3, 1, 2] (list)
|
|
80
|
+
[2] ~ scores: [3, 1, 2] -> [1, 2, 3] (modified in place)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`%cellwatch history` without a name lists every change, and `%cellwatch clear`
|
|
84
|
+
empties the history.
|
|
85
|
+
|
|
86
|
+
How to read a report:
|
|
87
|
+
|
|
88
|
+
| Symbol | Meaning |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `+` | the variable was created |
|
|
91
|
+
| `~` | the variable's value changed (`before -> after`) |
|
|
92
|
+
| `-` | the variable was deleted |
|
|
93
|
+
|
|
94
|
+
Imported modules and names starting with `_` are not reported.
|
|
95
|
+
|
|
96
|
+
**Use it from plain Python** (no notebook needed):
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from cellwatch import snapshot, diff, format_changes
|
|
100
|
+
|
|
101
|
+
before = snapshot(globals())
|
|
102
|
+
data = [1, 2, 3]
|
|
103
|
+
print(format_changes(diff(before, snapshot(globals()))))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Example use cases
|
|
107
|
+
|
|
108
|
+
- **Debugging a surprise.** A result is wrong and you suspect an earlier cell.
|
|
109
|
+
Turn on `%cellwatch on`, rerun the cells, and read what each one touched.
|
|
110
|
+
- **Learning mutation vs. reassignment.** Compare `b = a` then `b.append(4)`
|
|
111
|
+
with `b = a + [4]`: the report shows that the first also changes `a`.
|
|
112
|
+
- **Teaching and demos.** An instructor can show the effect of each cell
|
|
113
|
+
without adding `print` statements everywhere.
|
|
114
|
+
- **Checking a notebook before submission.** Confirm that a cell does not
|
|
115
|
+
overwrite a variable another cell depends on.
|
|
116
|
+
|
|
117
|
+
## Limitations
|
|
118
|
+
|
|
119
|
+
- Changes are detected by comparing the printed form (`repr`) of each value,
|
|
120
|
+
so an object whose printed form stays the same is reported as unchanged.
|
|
121
|
+
- Very large values can make each report slower, because they are printed
|
|
122
|
+
twice per cell.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
git clone https://github.com/Geoeoeo217/cellwatch
|
|
128
|
+
cd cellwatch
|
|
129
|
+
pip install -e ".[dev]"
|
|
130
|
+
pytest
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
How the code is organised:
|
|
134
|
+
|
|
135
|
+
| File | Job |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `snapshot.py` | Record every user variable at one moment |
|
|
138
|
+
| `compare.py` | Compare two snapshots and list the differences |
|
|
139
|
+
| `report.py` | Turn the differences into a text report |
|
|
140
|
+
| `history.py` | Remember the changes made by every watched cell |
|
|
141
|
+
| `magic.py` | The `%%cellwatch` and `%cellwatch` notebook commands |
|
|
142
|
+
|
|
143
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for how the team works and what is left
|
|
144
|
+
to build.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cellwatch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "See exactly which variables a Jupyter cell created, changed or deleted."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "NATHANAEL George Steindhart" },
|
|
14
|
+
{ name = "SETIAWAN Matthew Henson" },
|
|
15
|
+
{ name = "LATHIF Muhammad Akmal" },
|
|
16
|
+
{ name = "HANDAYA Clement Steward" },
|
|
17
|
+
{ name = "KWAN Kendrick Matthew" },
|
|
18
|
+
]
|
|
19
|
+
keywords = ["jupyter", "ipython", "notebook", "variables", "diff", "education"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Framework :: IPython",
|
|
22
|
+
"Framework :: Jupyter",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Intended Audience :: Education",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["ipython>=7.0"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
dev = ["pytest", "build", "twine"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/Geoeoeo217/cellwatch"
|
|
34
|
+
Documentation = "https://github.com/Geoeoeo217/cellwatch#readme"
|
|
35
|
+
Issues = "https://github.com/Geoeoeo217/cellwatch/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/cellwatch"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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,35 @@
|
|
|
1
|
+
from cellwatch.compare import count_by_kind, diff
|
|
2
|
+
from cellwatch.snapshot import snapshot
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def changes_between(before_ns, after_ns):
|
|
6
|
+
return diff(snapshot(before_ns), snapshot(after_ns))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_created_and_deleted():
|
|
10
|
+
[created] = changes_between({}, {"x": 1})
|
|
11
|
+
[deleted] = changes_between({"x": 1}, {})
|
|
12
|
+
assert (created.kind, deleted.kind) == ("created", "deleted")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_reassigned_variable():
|
|
16
|
+
[change] = changes_between({"x": 1}, {"x": 2})
|
|
17
|
+
assert (change.kind, change.before.text, change.after.text) == ("changed", "1", "2")
|
|
18
|
+
assert not change.in_place
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_in_place_change_is_detected():
|
|
22
|
+
scores = [3, 1, 2]
|
|
23
|
+
before = snapshot({"scores": scores})
|
|
24
|
+
scores.sort()
|
|
25
|
+
[change] = diff(before, snapshot({"scores": scores}))
|
|
26
|
+
assert change.kind == "changed" and change.in_place
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_unchanged_variable_is_not_reported():
|
|
30
|
+
assert changes_between({"x": 1}, {"x": 1}) == []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_count_by_kind():
|
|
34
|
+
changes = changes_between({"a": 1, "b": 1}, {"a": 2, "c": 3})
|
|
35
|
+
assert count_by_kind(changes) == {"created": 1, "changed": 1, "deleted": 1}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from cellwatch.compare import diff
|
|
2
|
+
from cellwatch.history import History, format_history
|
|
3
|
+
from cellwatch.snapshot import snapshot
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_history():
|
|
7
|
+
history = History()
|
|
8
|
+
history.record(1, diff(snapshot({}), snapshot({"x": 1, "y": 5})))
|
|
9
|
+
history.record(2, diff(snapshot({"x": 1}), snapshot({"x": 2})))
|
|
10
|
+
return history
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_entries_can_be_filtered_by_name():
|
|
14
|
+
history = make_history()
|
|
15
|
+
assert len(history) == 3
|
|
16
|
+
assert [cell for cell, _ in history.entries("x")] == [1, 2]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_format_history_for_one_variable():
|
|
20
|
+
assert format_history(make_history(), "x").splitlines() == [
|
|
21
|
+
"cellwatch history for 'x':",
|
|
22
|
+
" [1] + x = 1 (int)",
|
|
23
|
+
" [2] ~ x: 1 -> 2",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_empty_and_cleared_history():
|
|
28
|
+
history = make_history()
|
|
29
|
+
history.clear()
|
|
30
|
+
assert format_history(history) == "cellwatch: no recorded changes for all variables"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
IPython = pytest.importorskip("IPython")
|
|
4
|
+
from IPython.testing.globalipapp import start_ipython
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture(scope="module")
|
|
8
|
+
def ip():
|
|
9
|
+
shell = start_ipython() or IPython.get_ipython()
|
|
10
|
+
shell.run_line_magic("load_ext", "cellwatch")
|
|
11
|
+
return shell
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_cell_magic_reports_changes(ip, capsys):
|
|
15
|
+
ip.run_cell("scores = [3, 1, 2]")
|
|
16
|
+
capsys.readouterr()
|
|
17
|
+
ip.run_cell_magic("cellwatch", "", "scores.sort()\ntotal = sum(scores)")
|
|
18
|
+
out = capsys.readouterr().out
|
|
19
|
+
assert "1 created, 1 changed" in out
|
|
20
|
+
assert "+ total = 6" in out
|
|
21
|
+
assert "~ scores: [3, 1, 2] -> [1, 2, 3] (modified in place)" in out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_auto_mode_on_and_off(ip, capsys):
|
|
25
|
+
ip.run_line_magic("cellwatch", "on")
|
|
26
|
+
ip.run_cell("auto_value = 42")
|
|
27
|
+
assert "+ auto_value = 42" in capsys.readouterr().out
|
|
28
|
+
ip.run_line_magic("cellwatch", "off")
|
|
29
|
+
capsys.readouterr()
|
|
30
|
+
ip.run_cell("another_value = 1")
|
|
31
|
+
assert "another_value" not in capsys.readouterr().out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_history_command(ip, capsys):
|
|
35
|
+
ip.run_line_magic("cellwatch", "history total")
|
|
36
|
+
out = capsys.readouterr().out
|
|
37
|
+
assert "cellwatch history for 'total':" in out and "+ total = 6" in out
|
|
38
|
+
ip.run_line_magic("cellwatch", "clear")
|
|
39
|
+
ip.run_line_magic("cellwatch", "history")
|
|
40
|
+
assert "no recorded changes" in capsys.readouterr().out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_public_names_are_importable():
|
|
44
|
+
from cellwatch import History, diff, format_changes, snapshot # noqa: F401
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_history_uses_the_cell_number(ip, capsys):
|
|
48
|
+
ip.run_line_magic("cellwatch", "clear")
|
|
49
|
+
result = ip.run_cell("%%cellwatch\nnumbered = 1", store_history=True)
|
|
50
|
+
capsys.readouterr()
|
|
51
|
+
ip.run_line_magic("cellwatch", "history numbered")
|
|
52
|
+
assert f"[{result.execution_count}] + numbered = 1" in capsys.readouterr().out
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from cellwatch.compare import diff
|
|
2
|
+
from cellwatch.report import format_changes, shorten
|
|
3
|
+
from cellwatch.snapshot import snapshot
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def report(before_ns, after_ns):
|
|
7
|
+
return format_changes(diff(snapshot(before_ns), snapshot(after_ns)))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_no_changes():
|
|
11
|
+
assert report({"x": 1}, {"x": 1}) == "cellwatch: no variables changed"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_full_report():
|
|
15
|
+
text = report({"a": 1, "b": "old"}, {"a": 2, "c": [1]})
|
|
16
|
+
assert text.splitlines() == [
|
|
17
|
+
"cellwatch: 1 created, 1 changed, 1 deleted",
|
|
18
|
+
" + c = [1] (list)",
|
|
19
|
+
" ~ a: 1 -> 2",
|
|
20
|
+
" - b (was 'old')",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_type_change_is_mentioned():
|
|
25
|
+
assert "(type int -> str)" in report({"x": 1}, {"x": "1"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_long_values_are_shortened():
|
|
29
|
+
assert len(shorten("x" * 500)) == 60
|
|
30
|
+
assert all(len(line) < 100 for line in report({}, {"big": list(range(1000))}).splitlines())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
from cellwatch.snapshot import safe_repr, snapshot
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_records_text_type_and_identity():
|
|
7
|
+
scores = [1, 2]
|
|
8
|
+
record = snapshot({"scores": scores})["scores"]
|
|
9
|
+
assert (record.text, record.type_name, record.identity) == ("[1, 2]", "list", id(scores))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_private_names_ipython_names_and_modules_are_skipped():
|
|
13
|
+
assert snapshot({"_hidden": 1, "math": math, "In": [], "x": 1}).keys() == {"x"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_broken_repr_does_not_crash():
|
|
17
|
+
class Broken:
|
|
18
|
+
def __repr__(self):
|
|
19
|
+
raise ValueError("oops")
|
|
20
|
+
|
|
21
|
+
assert safe_repr(Broken()).startswith("<unprintable Broken")
|