loop-autopsy 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.
- loop_autopsy-0.1.0/LICENSE +22 -0
- loop_autopsy-0.1.0/PKG-INFO +230 -0
- loop_autopsy-0.1.0/README.md +205 -0
- loop_autopsy-0.1.0/pyproject.toml +44 -0
- loop_autopsy-0.1.0/setup.cfg +4 -0
- loop_autopsy-0.1.0/src/loop_autopsy/__init__.py +57 -0
- loop_autopsy-0.1.0/src/loop_autopsy/config.py +109 -0
- loop_autopsy-0.1.0/src/loop_autopsy/detectors.py +279 -0
- loop_autopsy-0.1.0/src/loop_autopsy/growth.py +204 -0
- loop_autopsy-0.1.0/src/loop_autopsy/heatmap.py +171 -0
- loop_autopsy-0.1.0/src/loop_autopsy/magics.py +65 -0
- loop_autopsy-0.1.0/src/loop_autopsy/monitor.py +166 -0
- loop_autopsy-0.1.0/src/loop_autopsy/report.py +207 -0
- loop_autopsy-0.1.0/src/loop_autopsy/transformer.py +215 -0
- loop_autopsy-0.1.0/src/loop_autopsy.egg-info/PKG-INFO +230 -0
- loop_autopsy-0.1.0/src/loop_autopsy.egg-info/SOURCES.txt +23 -0
- loop_autopsy-0.1.0/src/loop_autopsy.egg-info/dependency_links.txt +1 -0
- loop_autopsy-0.1.0/src/loop_autopsy.egg-info/requires.txt +4 -0
- loop_autopsy-0.1.0/src/loop_autopsy.egg-info/top_level.txt +1 -0
- loop_autopsy-0.1.0/tests/test_detectors.py +81 -0
- loop_autopsy-0.1.0/tests/test_end_to_end.py +74 -0
- loop_autopsy-0.1.0/tests/test_growth.py +63 -0
- loop_autopsy-0.1.0/tests/test_heatmap.py +47 -0
- loop_autopsy-0.1.0/tests/test_report.py +43 -0
- loop_autopsy-0.1.0/tests/test_transformer.py +69 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NATHANAEL George Steindhart, SETIAWAN Matthew Henson,
|
|
4
|
+
LATHIF Muhammad Akmal, KWAN Kendrick Matthew, HANDAYA Clement Steward
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loop-autopsy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stops runaway loops in Jupyter and shows the evidence for why they never ended.
|
|
5
|
+
Author: NATHANAEL George Steindhart, SETIAWAN Matthew Henson, LATHIF Muhammad Akmal, KWAN Kendrick Matthew, HANDAYA Clement Steward
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Geoeoeo217/loop-autopsy
|
|
8
|
+
Project-URL: Repository, https://github.com/Geoeoeo217/loop-autopsy
|
|
9
|
+
Project-URL: Documentation, https://github.com/Geoeoeo217/loop-autopsy#readme
|
|
10
|
+
Keywords: jupyter,ipython,education,infinite-loop,debugging,beginners
|
|
11
|
+
Classifier: Framework :: IPython
|
|
12
|
+
Classifier: Framework :: Jupyter
|
|
13
|
+
Classifier: Intended Audience :: Education
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Education
|
|
17
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: ipython>=7.0
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest; extra == "test"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# loop-autopsy
|
|
27
|
+
|
|
28
|
+
**Stops runaway loops in Jupyter and shows the evidence for why they never ended.**
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
%load_ext loop_autopsy
|
|
32
|
+
|
|
33
|
+
count = 1
|
|
34
|
+
while count <= 5:
|
|
35
|
+
print(count)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Instead of freezing the notebook, the loop is stopped after 3 rounds and you get:
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Loop autopsy — stuck loop (line 2)
|
|
44
|
+
while count <= 5:
|
|
45
|
+
|
|
46
|
+
Nothing changed between rounds: `count = 1` stayed exactly the same, so the
|
|
47
|
+
condition `count <= 5` is True every time and the loop can never end.
|
|
48
|
+
|
|
49
|
+
Last rounds checked:
|
|
50
|
+
round count (same)
|
|
51
|
+
----- ------------
|
|
52
|
+
1 1
|
|
53
|
+
2 1
|
|
54
|
+
3 1
|
|
55
|
+
|
|
56
|
+
Hint: Add a line inside the loop that changes `count` so that `count <= 5`
|
|
57
|
+
eventually becomes False (for example `count += 1`).
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Motivation
|
|
63
|
+
|
|
64
|
+
Every beginner writes an infinite loop. In a notebook on a shared JupyterHub this
|
|
65
|
+
is worse than it sounds:
|
|
66
|
+
|
|
67
|
+
- the cell hangs with no explanation, and the only remedy is the stop button ■;
|
|
68
|
+
- a `print` inside the loop floods the browser with output until the tab freezes;
|
|
69
|
+
- the loop keeps using CPU and memory on a server shared by the whole class.
|
|
70
|
+
|
|
71
|
+
Pressing ■ stops the loop but teaches nothing. The student still does not know
|
|
72
|
+
*which* variable failed to change or *why* the condition never became False.
|
|
73
|
+
|
|
74
|
+
## Innovation
|
|
75
|
+
|
|
76
|
+
loop-autopsy watches loops **while they run** and stops them only when it can
|
|
77
|
+
**prove** they will never end. Then it shows the evidence.
|
|
78
|
+
|
|
79
|
+
| | Stops a stuck loop? | Explains *why*? | When? |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| Stop button ■ | yes (you press it) | no | whenever you notice |
|
|
82
|
+
| Timeout decorators (e.g. `wrapt_timeout_decorator`) | yes, after a fixed time | no | after the time limit |
|
|
83
|
+
| Error explainers (e.g. `friendly-traceback`) | no | explains *errors*, but an infinite loop raises none | after a crash |
|
|
84
|
+
| Memory monitors (e.g. `jupyter-resource-usage`) | no | no | shows totals only |
|
|
85
|
+
| **loop-autopsy** | **yes, automatically** | **yes, with a replay table** | **during the loop, often after 3 rounds** |
|
|
86
|
+
|
|
87
|
+
How it works, in short:
|
|
88
|
+
|
|
89
|
+
1. An IPython **AST transformer** (it edits the code's syntax tree before it runs)
|
|
90
|
+
adds a tiny check to every `while` and `for` loop you type in a cell.
|
|
91
|
+
2. Each check takes a **fingerprint** of the loop's variables. If a deterministic
|
|
92
|
+
loop comes back to an *exactly identical* state, it must repeat forever: that
|
|
93
|
+
is a proof, not a guess.
|
|
94
|
+
3. Further rules catch a counter moving *away* from its target, a `!=` condition
|
|
95
|
+
that jumps over its stop value (`0.1 + 0.1 + ...` never equals `1.0`), a list
|
|
96
|
+
that grows while you loop over it, and memory running out.
|
|
97
|
+
4. Anything it cannot prove falls back to an honest time limit. No AI model, no
|
|
98
|
+
internet, and the same result every time.
|
|
99
|
+
|
|
100
|
+
Every rule and its numbers are listed below, and every number can be changed (see Settings).
|
|
101
|
+
|
|
102
|
+
## What it detects
|
|
103
|
+
|
|
104
|
+
| Pattern | Example | Stopped after |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| **Stuck**: nothing changes | `while count <= 5: print(count)` | 3 rounds |
|
|
107
|
+
| **Cycle**: values go round in a circle | `while a < 5: a = (a + 1) % 3` | about 2 cycles |
|
|
108
|
+
| **Wrong direction** | `while i < 10: i -= 1` | 500 checks |
|
|
109
|
+
| **Jumped over** the stop value | `while x != 1.0: x += 0.1` | about 100 checks |
|
|
110
|
+
| **Growing list** in a `for` loop | `for n in nums: nums.append(n)` | 2,000 rounds |
|
|
111
|
+
| **Memory** about to run out | a loop filling a huge list | at 85% of the limit |
|
|
112
|
+
| **Too long** (fallback) | anything else | 60 s |
|
|
113
|
+
|
|
114
|
+
Plus two extras:
|
|
115
|
+
|
|
116
|
+
- **Press ■ yourself** and you still get the replay table, showing which
|
|
117
|
+
variables never changed.
|
|
118
|
+
- **Cell health bar**: time, memory and loops for a cell, coloured green / yellow /
|
|
119
|
+
red. It warns when memory passes 75% of your limit and names the biggest
|
|
120
|
+
variables (pandas and NumPy aware). It also mutes output after 1,000 printed
|
|
121
|
+
lines so the browser stays responsive.
|
|
122
|
+
|
|
123
|
+
## Example use cases
|
|
124
|
+
|
|
125
|
+
- **Students** in an intro course: load it once at the top of every notebook and
|
|
126
|
+
never lose a session to a frozen loop again.
|
|
127
|
+
- **Instructors** on JupyterHub: load it for everyone (see below) to protect the
|
|
128
|
+
shared server and turn every infinite loop into a mini-lesson.
|
|
129
|
+
- **Teaching assistants**: the replay table shows at a glance what went wrong in
|
|
130
|
+
a student's loop.
|
|
131
|
+
- **Data notebooks**: the health bar shows which cell is slow or memory-hungry.
|
|
132
|
+
|
|
133
|
+
## Setup
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
pip install loop-autopsy
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The only requirement is IPython (already present in Jupyter). Python 3.9+.
|
|
140
|
+
|
|
141
|
+
In a notebook:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
%load_ext loop_autopsy
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**For instructors**: to turn it on for every notebook, add this to the IPython
|
|
148
|
+
profile (`~/.ipython/profile_default/ipython_config.py`):
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
c.InteractiveShellApp.extensions = ["loop_autopsy"]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Usage
|
|
155
|
+
|
|
156
|
+
| Command | Effect |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `%autopsy` | status and settings |
|
|
159
|
+
| `%autopsy on` / `off` | start / stop watching loops |
|
|
160
|
+
| `%autopsy last` | show the last report again |
|
|
161
|
+
| `%autopsy heatmap on` / `off` / `auto` | cell health bar (`auto` = only when a cell is slow or memory-heavy) |
|
|
162
|
+
| `%autopsy config time_limit=120` | change a setting (see Settings below) |
|
|
163
|
+
| `%%autopsy off` | first line of a cell: run that cell without watching |
|
|
164
|
+
| `%autopsy help` | help |
|
|
165
|
+
|
|
166
|
+
Cells that use `%timeit` / `%%timeit` are never watched, so timings stay accurate.
|
|
167
|
+
Loops inside generators (`yield`) are left alone, because infinite generators are
|
|
168
|
+
normal.
|
|
169
|
+
|
|
170
|
+
Try [examples/demo.ipynb](examples/demo.ipynb) for every feature in one notebook.
|
|
171
|
+
|
|
172
|
+
## Settings
|
|
173
|
+
|
|
174
|
+
Change any of these with `%autopsy config key=value` (for example `%autopsy config time_limit=120`).
|
|
175
|
+
|
|
176
|
+
| Setting | Default | Meaning |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `time_limit` | 60 | seconds before a loop that cannot be proven stuck is stopped (0 = never) |
|
|
179
|
+
| `direction_after` | 500 | checks in a row moving away from the stop value before "wrong direction" |
|
|
180
|
+
| `jump_after` | 100 | checks in a row after jumping over a `!=` stop value |
|
|
181
|
+
| `growth_after` | 2000 | rounds before a growing list in a `for` loop is reported |
|
|
182
|
+
| `memory_stop` | 0.85 | stop a loop when memory reaches this fraction of the limit |
|
|
183
|
+
| `memory_warn` | 0.75 | health bar warning at this fraction of the limit |
|
|
184
|
+
| `max_output_lines` | 1000 | printed lines per cell before output is muted (0 = off) |
|
|
185
|
+
| `replay_rows` | 6 | rows in the replay table |
|
|
186
|
+
| `max_watched` | 25 | variables watched per loop |
|
|
187
|
+
| `heatmap` | `auto` | `on`, `off` or `auto` |
|
|
188
|
+
| `time_yellow` / `time_red` | 1.0 / 10.0 | seconds for a yellow / red health bar |
|
|
189
|
+
| `mem_yellow` / `mem_red` | 0.50 / 0.80 | memory fraction for a yellow / red health bar |
|
|
190
|
+
|
|
191
|
+
The memory limit is read from JupyterHub's `MEM_LIMIT` setting, then the container's
|
|
192
|
+
memory limit, then the computer's total RAM.
|
|
193
|
+
|
|
194
|
+
## Limitations (honest list)
|
|
195
|
+
|
|
196
|
+
- No tool can detect *every* infinite loop (the halting problem). loop-autopsy
|
|
197
|
+
only reports a stuck, cycling, wrong-direction or jumped-over loop when it can
|
|
198
|
+
prove it; everything else gets the time limit.
|
|
199
|
+
- Loops that call your own functions or modules such as `random` cannot be proven
|
|
200
|
+
stuck (those calls might change hidden state), so they rely on the time limit.
|
|
201
|
+
- Only code typed in notebook cells is watched, not code inside imported modules.
|
|
202
|
+
- Watching adds a small cost to each loop round (roughly 0.1–0.2 µs per round on
|
|
203
|
+
simple loops). Use `%%autopsy off` for heavy number crunching.
|
|
204
|
+
- A bare `except:` inside a loop can catch the stop signal, just as it catches ■.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
git clone https://github.com/Geoeoeo217/loop-autopsy
|
|
210
|
+
cd loop-autopsy
|
|
211
|
+
pip install -e ".[test]"
|
|
212
|
+
pytest
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Team
|
|
216
|
+
|
|
217
|
+
Built by a team of five for CS1302 (Introduction to Computer Programming).
|
|
218
|
+
|
|
219
|
+
| Member | Part |
|
|
220
|
+
|---|---|
|
|
221
|
+
| NATHANAEL George Steindhart | project lead: loop rewriting (`transformer.py`), run-time glue (`monitor.py`), magics, packaging |
|
|
222
|
+
| SETIAWAN Matthew Henson | detection rules: stuck, cycle, wrong direction, jumped over, time limit (`detectors.py`) |
|
|
223
|
+
| LATHIF Muhammad Akmal | memory and growing-list rules (`growth.py`) |
|
|
224
|
+
| KWAN Kendrick Matthew | replay table and reports (`report.py`) |
|
|
225
|
+
| HANDAYA Clement Steward | cell health bar and output guard (`heatmap.py`) |
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
MIT
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# loop-autopsy
|
|
2
|
+
|
|
3
|
+
**Stops runaway loops in Jupyter and shows the evidence for why they never ended.**
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
%load_ext loop_autopsy
|
|
7
|
+
|
|
8
|
+
count = 1
|
|
9
|
+
while count <= 5:
|
|
10
|
+
print(count)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Instead of freezing the notebook, the loop is stopped after 3 rounds and you get:
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Loop autopsy — stuck loop (line 2)
|
|
19
|
+
while count <= 5:
|
|
20
|
+
|
|
21
|
+
Nothing changed between rounds: `count = 1` stayed exactly the same, so the
|
|
22
|
+
condition `count <= 5` is True every time and the loop can never end.
|
|
23
|
+
|
|
24
|
+
Last rounds checked:
|
|
25
|
+
round count (same)
|
|
26
|
+
----- ------------
|
|
27
|
+
1 1
|
|
28
|
+
2 1
|
|
29
|
+
3 1
|
|
30
|
+
|
|
31
|
+
Hint: Add a line inside the loop that changes `count` so that `count <= 5`
|
|
32
|
+
eventually becomes False (for example `count += 1`).
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Motivation
|
|
38
|
+
|
|
39
|
+
Every beginner writes an infinite loop. In a notebook on a shared JupyterHub this
|
|
40
|
+
is worse than it sounds:
|
|
41
|
+
|
|
42
|
+
- the cell hangs with no explanation, and the only remedy is the stop button ■;
|
|
43
|
+
- a `print` inside the loop floods the browser with output until the tab freezes;
|
|
44
|
+
- the loop keeps using CPU and memory on a server shared by the whole class.
|
|
45
|
+
|
|
46
|
+
Pressing ■ stops the loop but teaches nothing. The student still does not know
|
|
47
|
+
*which* variable failed to change or *why* the condition never became False.
|
|
48
|
+
|
|
49
|
+
## Innovation
|
|
50
|
+
|
|
51
|
+
loop-autopsy watches loops **while they run** and stops them only when it can
|
|
52
|
+
**prove** they will never end. Then it shows the evidence.
|
|
53
|
+
|
|
54
|
+
| | Stops a stuck loop? | Explains *why*? | When? |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| Stop button ■ | yes (you press it) | no | whenever you notice |
|
|
57
|
+
| Timeout decorators (e.g. `wrapt_timeout_decorator`) | yes, after a fixed time | no | after the time limit |
|
|
58
|
+
| Error explainers (e.g. `friendly-traceback`) | no | explains *errors*, but an infinite loop raises none | after a crash |
|
|
59
|
+
| Memory monitors (e.g. `jupyter-resource-usage`) | no | no | shows totals only |
|
|
60
|
+
| **loop-autopsy** | **yes, automatically** | **yes, with a replay table** | **during the loop, often after 3 rounds** |
|
|
61
|
+
|
|
62
|
+
How it works, in short:
|
|
63
|
+
|
|
64
|
+
1. An IPython **AST transformer** (it edits the code's syntax tree before it runs)
|
|
65
|
+
adds a tiny check to every `while` and `for` loop you type in a cell.
|
|
66
|
+
2. Each check takes a **fingerprint** of the loop's variables. If a deterministic
|
|
67
|
+
loop comes back to an *exactly identical* state, it must repeat forever: that
|
|
68
|
+
is a proof, not a guess.
|
|
69
|
+
3. Further rules catch a counter moving *away* from its target, a `!=` condition
|
|
70
|
+
that jumps over its stop value (`0.1 + 0.1 + ...` never equals `1.0`), a list
|
|
71
|
+
that grows while you loop over it, and memory running out.
|
|
72
|
+
4. Anything it cannot prove falls back to an honest time limit. No AI model, no
|
|
73
|
+
internet, and the same result every time.
|
|
74
|
+
|
|
75
|
+
Every rule and its numbers are listed below, and every number can be changed (see Settings).
|
|
76
|
+
|
|
77
|
+
## What it detects
|
|
78
|
+
|
|
79
|
+
| Pattern | Example | Stopped after |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| **Stuck**: nothing changes | `while count <= 5: print(count)` | 3 rounds |
|
|
82
|
+
| **Cycle**: values go round in a circle | `while a < 5: a = (a + 1) % 3` | about 2 cycles |
|
|
83
|
+
| **Wrong direction** | `while i < 10: i -= 1` | 500 checks |
|
|
84
|
+
| **Jumped over** the stop value | `while x != 1.0: x += 0.1` | about 100 checks |
|
|
85
|
+
| **Growing list** in a `for` loop | `for n in nums: nums.append(n)` | 2,000 rounds |
|
|
86
|
+
| **Memory** about to run out | a loop filling a huge list | at 85% of the limit |
|
|
87
|
+
| **Too long** (fallback) | anything else | 60 s |
|
|
88
|
+
|
|
89
|
+
Plus two extras:
|
|
90
|
+
|
|
91
|
+
- **Press ■ yourself** and you still get the replay table, showing which
|
|
92
|
+
variables never changed.
|
|
93
|
+
- **Cell health bar**: time, memory and loops for a cell, coloured green / yellow /
|
|
94
|
+
red. It warns when memory passes 75% of your limit and names the biggest
|
|
95
|
+
variables (pandas and NumPy aware). It also mutes output after 1,000 printed
|
|
96
|
+
lines so the browser stays responsive.
|
|
97
|
+
|
|
98
|
+
## Example use cases
|
|
99
|
+
|
|
100
|
+
- **Students** in an intro course: load it once at the top of every notebook and
|
|
101
|
+
never lose a session to a frozen loop again.
|
|
102
|
+
- **Instructors** on JupyterHub: load it for everyone (see below) to protect the
|
|
103
|
+
shared server and turn every infinite loop into a mini-lesson.
|
|
104
|
+
- **Teaching assistants**: the replay table shows at a glance what went wrong in
|
|
105
|
+
a student's loop.
|
|
106
|
+
- **Data notebooks**: the health bar shows which cell is slow or memory-hungry.
|
|
107
|
+
|
|
108
|
+
## Setup
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install loop-autopsy
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The only requirement is IPython (already present in Jupyter). Python 3.9+.
|
|
115
|
+
|
|
116
|
+
In a notebook:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
%load_ext loop_autopsy
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**For instructors**: to turn it on for every notebook, add this to the IPython
|
|
123
|
+
profile (`~/.ipython/profile_default/ipython_config.py`):
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
c.InteractiveShellApp.extensions = ["loop_autopsy"]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Usage
|
|
130
|
+
|
|
131
|
+
| Command | Effect |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `%autopsy` | status and settings |
|
|
134
|
+
| `%autopsy on` / `off` | start / stop watching loops |
|
|
135
|
+
| `%autopsy last` | show the last report again |
|
|
136
|
+
| `%autopsy heatmap on` / `off` / `auto` | cell health bar (`auto` = only when a cell is slow or memory-heavy) |
|
|
137
|
+
| `%autopsy config time_limit=120` | change a setting (see Settings below) |
|
|
138
|
+
| `%%autopsy off` | first line of a cell: run that cell without watching |
|
|
139
|
+
| `%autopsy help` | help |
|
|
140
|
+
|
|
141
|
+
Cells that use `%timeit` / `%%timeit` are never watched, so timings stay accurate.
|
|
142
|
+
Loops inside generators (`yield`) are left alone, because infinite generators are
|
|
143
|
+
normal.
|
|
144
|
+
|
|
145
|
+
Try [examples/demo.ipynb](examples/demo.ipynb) for every feature in one notebook.
|
|
146
|
+
|
|
147
|
+
## Settings
|
|
148
|
+
|
|
149
|
+
Change any of these with `%autopsy config key=value` (for example `%autopsy config time_limit=120`).
|
|
150
|
+
|
|
151
|
+
| Setting | Default | Meaning |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `time_limit` | 60 | seconds before a loop that cannot be proven stuck is stopped (0 = never) |
|
|
154
|
+
| `direction_after` | 500 | checks in a row moving away from the stop value before "wrong direction" |
|
|
155
|
+
| `jump_after` | 100 | checks in a row after jumping over a `!=` stop value |
|
|
156
|
+
| `growth_after` | 2000 | rounds before a growing list in a `for` loop is reported |
|
|
157
|
+
| `memory_stop` | 0.85 | stop a loop when memory reaches this fraction of the limit |
|
|
158
|
+
| `memory_warn` | 0.75 | health bar warning at this fraction of the limit |
|
|
159
|
+
| `max_output_lines` | 1000 | printed lines per cell before output is muted (0 = off) |
|
|
160
|
+
| `replay_rows` | 6 | rows in the replay table |
|
|
161
|
+
| `max_watched` | 25 | variables watched per loop |
|
|
162
|
+
| `heatmap` | `auto` | `on`, `off` or `auto` |
|
|
163
|
+
| `time_yellow` / `time_red` | 1.0 / 10.0 | seconds for a yellow / red health bar |
|
|
164
|
+
| `mem_yellow` / `mem_red` | 0.50 / 0.80 | memory fraction for a yellow / red health bar |
|
|
165
|
+
|
|
166
|
+
The memory limit is read from JupyterHub's `MEM_LIMIT` setting, then the container's
|
|
167
|
+
memory limit, then the computer's total RAM.
|
|
168
|
+
|
|
169
|
+
## Limitations (honest list)
|
|
170
|
+
|
|
171
|
+
- No tool can detect *every* infinite loop (the halting problem). loop-autopsy
|
|
172
|
+
only reports a stuck, cycling, wrong-direction or jumped-over loop when it can
|
|
173
|
+
prove it; everything else gets the time limit.
|
|
174
|
+
- Loops that call your own functions or modules such as `random` cannot be proven
|
|
175
|
+
stuck (those calls might change hidden state), so they rely on the time limit.
|
|
176
|
+
- Only code typed in notebook cells is watched, not code inside imported modules.
|
|
177
|
+
- Watching adds a small cost to each loop round (roughly 0.1–0.2 µs per round on
|
|
178
|
+
simple loops). Use `%%autopsy off` for heavy number crunching.
|
|
179
|
+
- A bare `except:` inside a loop can catch the stop signal, just as it catches ■.
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
git clone https://github.com/Geoeoeo217/loop-autopsy
|
|
185
|
+
cd loop-autopsy
|
|
186
|
+
pip install -e ".[test]"
|
|
187
|
+
pytest
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Team
|
|
191
|
+
|
|
192
|
+
Built by a team of five for CS1302 (Introduction to Computer Programming).
|
|
193
|
+
|
|
194
|
+
| Member | Part |
|
|
195
|
+
|---|---|
|
|
196
|
+
| NATHANAEL George Steindhart | project lead: loop rewriting (`transformer.py`), run-time glue (`monitor.py`), magics, packaging |
|
|
197
|
+
| SETIAWAN Matthew Henson | detection rules: stuck, cycle, wrong direction, jumped over, time limit (`detectors.py`) |
|
|
198
|
+
| LATHIF Muhammad Akmal | memory and growing-list rules (`growth.py`) |
|
|
199
|
+
| KWAN Kendrick Matthew | replay table and reports (`report.py`) |
|
|
200
|
+
| HANDAYA Clement Steward | cell health bar and output guard (`heatmap.py`) |
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "loop-autopsy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Stops runaway loops in Jupyter and shows the evidence for why they never ended."
|
|
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 = "KWAN Kendrick Matthew" },
|
|
17
|
+
{ name = "HANDAYA Clement Steward" },
|
|
18
|
+
]
|
|
19
|
+
keywords = ["jupyter", "ipython", "education", "infinite-loop", "debugging", "beginners"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Framework :: IPython",
|
|
22
|
+
"Framework :: Jupyter",
|
|
23
|
+
"Intended Audience :: Education",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Topic :: Education",
|
|
27
|
+
"Topic :: Software Development :: Debuggers",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["ipython>=7.0"]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = ["pytest"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/Geoeoeo217/loop-autopsy"
|
|
36
|
+
Repository = "https://github.com/Geoeoeo217/loop-autopsy"
|
|
37
|
+
Documentation = "https://github.com/Geoeoeo217/loop-autopsy#readme"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
pythonpath = ["tests"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""loop-autopsy: stops runaway loops in Jupyter and shows the evidence for why
|
|
2
|
+
they never ended.
|
|
3
|
+
|
|
4
|
+
Usage in a notebook::
|
|
5
|
+
|
|
6
|
+
%load_ext loop_autopsy
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from .config import Finding, LoopStopped, Settings # noqa: F401
|
|
14
|
+
|
|
15
|
+
_LOADED: dict = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_ipython_extension(ip) -> None:
|
|
19
|
+
from .heatmap import CellHealth
|
|
20
|
+
from .magics import AutopsyMagics
|
|
21
|
+
from .monitor import Monitor
|
|
22
|
+
from .report import display_report
|
|
23
|
+
from .transformer import RUNTIME_NAME, LoopTransformer
|
|
24
|
+
|
|
25
|
+
if id(ip) in _LOADED:
|
|
26
|
+
print("loop-autopsy is already loaded. Use %autopsy help for options.")
|
|
27
|
+
return
|
|
28
|
+
settings = Settings()
|
|
29
|
+
monitor = Monitor(ip, settings)
|
|
30
|
+
transformer = LoopTransformer(monitor.register, lambda: settings.max_watched, monitor.is_paused)
|
|
31
|
+
health = CellHealth(ip, settings, monitor.stats, show=display_report)
|
|
32
|
+
|
|
33
|
+
ip.user_ns[RUNTIME_NAME] = monitor
|
|
34
|
+
ip.user_ns_hidden[RUNTIME_NAME] = monitor # keep it out of %who / %whos
|
|
35
|
+
ip.ast_transformers.append(transformer)
|
|
36
|
+
ip.events.register("pre_run_cell", monitor.pre_run_cell)
|
|
37
|
+
health.register()
|
|
38
|
+
monitor.install_exception_handler()
|
|
39
|
+
ip.register_magics(AutopsyMagics(ip, monitor))
|
|
40
|
+
_LOADED[id(ip)] = (monitor, transformer, health)
|
|
41
|
+
print(f"loop-autopsy {__version__} is watching your loops. Type %autopsy help for options.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unload_ipython_extension(ip) -> None:
|
|
45
|
+
entry = _LOADED.pop(id(ip), None)
|
|
46
|
+
if entry is None:
|
|
47
|
+
return
|
|
48
|
+
monitor, transformer, health = entry
|
|
49
|
+
monitor.enabled = False # functions instrumented earlier keep working, unwatched
|
|
50
|
+
if transformer in ip.ast_transformers:
|
|
51
|
+
ip.ast_transformers.remove(transformer)
|
|
52
|
+
try:
|
|
53
|
+
ip.events.unregister("pre_run_cell", monitor.pre_run_cell)
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
health.unregister()
|
|
57
|
+
monitor.remove_exception_handler()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass, field, fields
|
|
5
|
+
from typing import Any, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LoopStopped(BaseException):
|
|
9
|
+
"""Raised inside a watched loop to stop it.
|
|
10
|
+
|
|
11
|
+
It derives from BaseException (like KeyboardInterrupt) so that a normal
|
|
12
|
+
``except Exception:`` block inside the student's loop cannot swallow it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, finding: "Finding"):
|
|
16
|
+
super().__init__(finding.title)
|
|
17
|
+
self.finding = finding
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Finding:
|
|
22
|
+
"""What a detection rule proved about a loop."""
|
|
23
|
+
|
|
24
|
+
kind: str # stuck | cycle | wrong_direction | jumped_over | growing_iterable | memory | time_limit
|
|
25
|
+
title: str
|
|
26
|
+
explanation: str
|
|
27
|
+
hint: str
|
|
28
|
+
culprits: Tuple[str, ...] = ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class LoopInfo:
|
|
33
|
+
"""Static facts about one loop, collected when the cell is transformed."""
|
|
34
|
+
|
|
35
|
+
loop_id: int
|
|
36
|
+
kind: str # "while" | "for"
|
|
37
|
+
lineno: int
|
|
38
|
+
header: str
|
|
39
|
+
test_src: str = ""
|
|
40
|
+
cond_names: Tuple[str, ...] = ()
|
|
41
|
+
watched: Tuple[str, ...] = ()
|
|
42
|
+
compare: Optional[tuple] = None # (left, op, right); side = ("name", n) | ("const", v)
|
|
43
|
+
iter_name: Optional[str] = None
|
|
44
|
+
receivers: Tuple[str, ...] = () # names used as ``name.method(...)``; checked at run time
|
|
45
|
+
repeat_safe: bool = True
|
|
46
|
+
waits_for_user: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class LoopState:
|
|
51
|
+
"""Run-time record of one execution of a loop."""
|
|
52
|
+
|
|
53
|
+
info: LoopInfo
|
|
54
|
+
started: float
|
|
55
|
+
replay_rows: int = 6
|
|
56
|
+
rounds: int = 0
|
|
57
|
+
checks: int = 0
|
|
58
|
+
stride: int = 1
|
|
59
|
+
next_check: int = 1
|
|
60
|
+
last_check: float = 0.0
|
|
61
|
+
history: deque = field(default_factory=deque)
|
|
62
|
+
scratch: dict = field(default_factory=dict)
|
|
63
|
+
stopped: Optional[Finding] = None
|
|
64
|
+
|
|
65
|
+
def __post_init__(self):
|
|
66
|
+
self.history = deque(maxlen=max(1, self.replay_rows))
|
|
67
|
+
self.last_check = self.started
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Settings:
|
|
72
|
+
"""User-adjustable settings (``%autopsy config key=value``)."""
|
|
73
|
+
|
|
74
|
+
time_limit: float = 60.0
|
|
75
|
+
direction_after: int = 500
|
|
76
|
+
jump_after: int = 100
|
|
77
|
+
growth_after: int = 2000
|
|
78
|
+
memory_stop: float = 0.85
|
|
79
|
+
memory_warn: float = 0.75
|
|
80
|
+
max_output_lines: int = 1000
|
|
81
|
+
replay_rows: int = 6
|
|
82
|
+
max_watched: int = 25
|
|
83
|
+
heatmap: str = "auto" # on | off | auto
|
|
84
|
+
time_yellow: float = 1.0
|
|
85
|
+
time_red: float = 10.0
|
|
86
|
+
mem_yellow: float = 0.50
|
|
87
|
+
mem_red: float = 0.80
|
|
88
|
+
|
|
89
|
+
def update(self, key: str, raw: str) -> Any:
|
|
90
|
+
"""Set ``key`` from a string such as ``"30"``; returns the new value."""
|
|
91
|
+
names = {f.name: f for f in fields(self)}
|
|
92
|
+
if key not in names:
|
|
93
|
+
raise KeyError(f"unknown setting {key!r}; choose from: {', '.join(names)}")
|
|
94
|
+
current = getattr(self, key)
|
|
95
|
+
if isinstance(current, str):
|
|
96
|
+
value: Any = raw.strip().strip("'\"").lower()
|
|
97
|
+
if key == "heatmap" and value not in ("on", "off", "auto"):
|
|
98
|
+
raise ValueError("heatmap must be on, off or auto")
|
|
99
|
+
elif isinstance(current, int) and not isinstance(current, bool):
|
|
100
|
+
value = int(float(raw))
|
|
101
|
+
else:
|
|
102
|
+
value = float(raw)
|
|
103
|
+
if isinstance(value, (int, float)) and value < 0:
|
|
104
|
+
raise ValueError(f"{key} cannot be negative")
|
|
105
|
+
setattr(self, key, value)
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
def as_dict(self) -> dict:
|
|
109
|
+
return {f.name: getattr(self, f.name) for f in fields(self)}
|