ipythonng 0.0.1__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.
- ipythonng-0.0.1/PKG-INFO +47 -0
- ipythonng-0.0.1/README.md +37 -0
- ipythonng-0.0.1/ipythonng/__init__.py +5 -0
- ipythonng-0.0.1/ipythonng/cli.py +6 -0
- ipythonng-0.0.1/ipythonng/extension.py +233 -0
- ipythonng-0.0.1/ipythonng.egg-info/PKG-INFO +47 -0
- ipythonng-0.0.1/ipythonng.egg-info/SOURCES.txt +15 -0
- ipythonng-0.0.1/ipythonng.egg-info/dependency_links.txt +1 -0
- ipythonng-0.0.1/ipythonng.egg-info/entry_points.txt +2 -0
- ipythonng-0.0.1/ipythonng.egg-info/requires.txt +3 -0
- ipythonng-0.0.1/ipythonng.egg-info/top_level.txt +1 -0
- ipythonng-0.0.1/pyproject.toml +28 -0
- ipythonng-0.0.1/setup.cfg +4 -0
- ipythonng-0.0.1/tests/test_ipythonng.py +210 -0
ipythonng-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ipythonng
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: IPython extension for terminal markdown, kitty images, and richer output history
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: ipython>=9.0
|
|
8
|
+
Requires-Dist: kittytgp>=0.0.2
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
|
|
11
|
+
# ipythonng
|
|
12
|
+
|
|
13
|
+
`ipythonng` is a small IPython extension for terminal sessions that adds:
|
|
14
|
+
|
|
15
|
+
- `text/markdown` rendering with Rich
|
|
16
|
+
- `image/png` rendering via `kittytgp`
|
|
17
|
+
- matplotlib inline support
|
|
18
|
+
- Includes display objects, streams, and rich results in stored history
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install ipythonng
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Use as an extension
|
|
27
|
+
|
|
28
|
+
Add the extension and enable output logging in your IPython config:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
c.InteractiveShellApp.extensions = ["ipythonng"]
|
|
32
|
+
c.HistoryManager.db_log_output = True
|
|
33
|
+
c.InteractiveShellApp.exec_lines = ["%matplotlib inline"] # if you like
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or launch it ad hoc:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
ipython --ext ipythonng
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For matplotlib, `%matplotlib inline` works with the existing `image/png` renderer. No custom matplotlib backend is needed. Using `exec_lines` runs the magic after extensions load.
|
|
43
|
+
|
|
44
|
+
## Convenience launcher
|
|
45
|
+
|
|
46
|
+
The package also installs an `ipythonng` command that simply starts IPython with
|
|
47
|
+
`--ext ipythonng`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# ipythonng
|
|
2
|
+
|
|
3
|
+
`ipythonng` is a small IPython extension for terminal sessions that adds:
|
|
4
|
+
|
|
5
|
+
- `text/markdown` rendering with Rich
|
|
6
|
+
- `image/png` rendering via `kittytgp`
|
|
7
|
+
- matplotlib inline support
|
|
8
|
+
- Includes display objects, streams, and rich results in stored history
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install ipythonng
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Use as an extension
|
|
17
|
+
|
|
18
|
+
Add the extension and enable output logging in your IPython config:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
c.InteractiveShellApp.extensions = ["ipythonng"]
|
|
22
|
+
c.HistoryManager.db_log_output = True
|
|
23
|
+
c.InteractiveShellApp.exec_lines = ["%matplotlib inline"] # if you like
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or launch it ad hoc:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ipython --ext ipythonng
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For matplotlib, `%matplotlib inline` works with the existing `image/png` renderer. No custom matplotlib backend is needed. Using `exec_lines` runs the magic after extensions load.
|
|
33
|
+
|
|
34
|
+
## Convenience launcher
|
|
35
|
+
|
|
36
|
+
The package also installs an `ipythonng` command that simply starts IPython with
|
|
37
|
+
`--ext ipythonng`.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import redirect_stdout
|
|
5
|
+
from types import MethodType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from kittytgp import build_render_bytes
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markdown import Markdown as RichMarkdown
|
|
11
|
+
|
|
12
|
+
_DEFAULT_CELL_SIZE = (8, 16)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _register_mime_renderer(shell, mime, handler):
|
|
16
|
+
active_types = shell.display_formatter.active_types
|
|
17
|
+
if mime not in active_types: active_types.append(mime)
|
|
18
|
+
formatter = shell.display_formatter.formatters.get(mime)
|
|
19
|
+
if formatter is not None: formatter.enabled = True
|
|
20
|
+
shell.mime_renderers[mime] = handler
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_tty(stream) -> bool:
|
|
24
|
+
isatty = getattr(stream, "isatty", None)
|
|
25
|
+
return bool(isatty and isatty())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_inline_backend(backend: str | None) -> bool:
|
|
29
|
+
if not backend: return False
|
|
30
|
+
return backend.lower() in {"inline", "module://matplotlib_inline.backend_inline"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _RenderTarget:
|
|
34
|
+
def __init__(self, stream): self.stream = stream
|
|
35
|
+
|
|
36
|
+
def fileno(self) -> int:
|
|
37
|
+
for candidate in (getattr(self.stream, "buffer", None), self.stream, getattr(sys.__stdout__, "buffer", None), sys.__stdout__):
|
|
38
|
+
fileno = getattr(candidate, "fileno", None)
|
|
39
|
+
if fileno is None: continue
|
|
40
|
+
try: return fileno()
|
|
41
|
+
except Exception: continue
|
|
42
|
+
return 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class IPythonNGExtension:
|
|
46
|
+
def __init__(self, shell):
|
|
47
|
+
self.shell = shell
|
|
48
|
+
self.history_manager = shell.history_manager
|
|
49
|
+
self._original_store_output = self.history_manager.store_output
|
|
50
|
+
self._pending_store_output = set()
|
|
51
|
+
self._registered_events = []
|
|
52
|
+
self._rendered_mimes = {}
|
|
53
|
+
self._handler_by_mime = {}
|
|
54
|
+
self._added_active_types = set()
|
|
55
|
+
self._original_enable_matplotlib = None
|
|
56
|
+
|
|
57
|
+
def load(self):
|
|
58
|
+
self._install_renderers()
|
|
59
|
+
self._install_history_patch()
|
|
60
|
+
self._install_matplotlib_patch()
|
|
61
|
+
|
|
62
|
+
def unload(self):
|
|
63
|
+
for event, callback in self._registered_events: self.shell.events.unregister(event, callback)
|
|
64
|
+
self._registered_events.clear()
|
|
65
|
+
|
|
66
|
+
self.history_manager.store_output = self._original_store_output
|
|
67
|
+
self._pending_store_output.clear()
|
|
68
|
+
if self._original_enable_matplotlib is not None: self.shell.enable_matplotlib = self._original_enable_matplotlib
|
|
69
|
+
|
|
70
|
+
for mime, original in self._rendered_mimes.items():
|
|
71
|
+
current = self.shell.mime_renderers.get(mime)
|
|
72
|
+
if current is self._handler_by_mime.get(mime):
|
|
73
|
+
if original is None: self.shell.mime_renderers.pop(mime, None)
|
|
74
|
+
else: self.shell.mime_renderers[mime] = original
|
|
75
|
+
|
|
76
|
+
for mime in self._added_active_types:
|
|
77
|
+
if mime in self.shell.display_formatter.active_types: self.shell.display_formatter.active_types.remove(mime)
|
|
78
|
+
|
|
79
|
+
def _install_renderers(self):
|
|
80
|
+
self._add_renderer("text/markdown", self._handle_text_markdown)
|
|
81
|
+
self._add_renderer("image/png", self._handle_image_png)
|
|
82
|
+
|
|
83
|
+
def _add_renderer(self, mime: str, handler):
|
|
84
|
+
self._rendered_mimes[mime] = self.shell.mime_renderers.get(mime)
|
|
85
|
+
self._handler_by_mime[mime] = handler
|
|
86
|
+
if mime not in self.shell.display_formatter.active_types: self._added_active_types.add(mime)
|
|
87
|
+
_register_mime_renderer(self.shell, mime, handler)
|
|
88
|
+
|
|
89
|
+
def _install_history_patch(self):
|
|
90
|
+
self.history_manager.store_output = MethodType(self._deferred_store_output, self.history_manager)
|
|
91
|
+
self.shell.events.register("post_run_cell", self._finalize_history)
|
|
92
|
+
self._registered_events.append(("post_run_cell", self._finalize_history))
|
|
93
|
+
|
|
94
|
+
def _install_matplotlib_patch(self):
|
|
95
|
+
self._original_enable_matplotlib = self.shell.enable_matplotlib
|
|
96
|
+
self.shell.enable_matplotlib = MethodType(lambda shell, gui=None: self._enable_matplotlib(shell, gui), self.shell)
|
|
97
|
+
|
|
98
|
+
def _deferred_store_output(self, history_manager, line_num: int) -> None:
|
|
99
|
+
if history_manager.db_log_output: self._pending_store_output.add(line_num)
|
|
100
|
+
|
|
101
|
+
def _output_stream(self): return getattr(self.shell, "_ipythonng_stream", sys.__stdout__)
|
|
102
|
+
|
|
103
|
+
def _write(self, text: str):
|
|
104
|
+
stream = self._output_stream()
|
|
105
|
+
stream.write(text)
|
|
106
|
+
flush = getattr(stream, "flush", None)
|
|
107
|
+
if flush is not None: flush()
|
|
108
|
+
|
|
109
|
+
def _render_markdown(self, markdown_text: str):
|
|
110
|
+
stream = self._output_stream()
|
|
111
|
+
console = Console(file=stream, force_terminal=_is_tty(stream), highlight=False, soft_wrap=True)
|
|
112
|
+
console.print(RichMarkdown(markdown_text))
|
|
113
|
+
|
|
114
|
+
def _current_matplotlib_backend(self) -> str | None:
|
|
115
|
+
try: import matplotlib
|
|
116
|
+
except Exception: return None
|
|
117
|
+
try: return matplotlib.get_backend()
|
|
118
|
+
except Exception: return None
|
|
119
|
+
|
|
120
|
+
def _close_matplotlib_figures(self):
|
|
121
|
+
try: from matplotlib import pyplot as plt
|
|
122
|
+
except Exception: return
|
|
123
|
+
try: plt.close("all")
|
|
124
|
+
except Exception: return
|
|
125
|
+
|
|
126
|
+
def _ensure_inline_figure_formats(self, shell):
|
|
127
|
+
try:
|
|
128
|
+
from IPython.core.pylabtools import select_figure_formats
|
|
129
|
+
from matplotlib.figure import Figure
|
|
130
|
+
from matplotlib_inline.config import InlineBackend
|
|
131
|
+
except Exception: return
|
|
132
|
+
png_formatter = shell.display_formatter.formatters["image/png"]
|
|
133
|
+
if Figure in png_formatter.type_printers: return
|
|
134
|
+
cfg = InlineBackend.instance(parent=shell)
|
|
135
|
+
select_figure_formats(shell, cfg.figure_formats, **cfg.print_figure_kwargs)
|
|
136
|
+
|
|
137
|
+
def _enable_matplotlib(self, shell, gui=None):
|
|
138
|
+
previous_backend = self._current_matplotlib_backend()
|
|
139
|
+
stdout = io.StringIO()
|
|
140
|
+
with redirect_stdout(stdout): result = self._original_enable_matplotlib(gui)
|
|
141
|
+
gui_name, backend = result
|
|
142
|
+
output = stdout.getvalue()
|
|
143
|
+
if _is_inline_backend(backend):
|
|
144
|
+
self._ensure_inline_figure_formats(shell)
|
|
145
|
+
output = "".join(line for line in output.splitlines(keepends=True) if line.strip() != "No event loop hook running.")
|
|
146
|
+
if not _is_inline_backend(previous_backend): self._close_matplotlib_figures()
|
|
147
|
+
if output: sys.stdout.write(output)
|
|
148
|
+
return gui_name, backend
|
|
149
|
+
|
|
150
|
+
def _needs_execute_result_newline(self) -> bool:
|
|
151
|
+
displayhook = getattr(self.shell, "displayhook", None)
|
|
152
|
+
return bool(displayhook and displayhook.is_active and not displayhook.prompt_end_newline)
|
|
153
|
+
|
|
154
|
+
def _render_png(self, png_b64: str, metadata: dict[str, Any] | None):
|
|
155
|
+
stream = self._output_stream()
|
|
156
|
+
if not _is_tty(stream):
|
|
157
|
+
self._write("[image/png]\n")
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
target = _RenderTarget(stream)
|
|
161
|
+
try:
|
|
162
|
+
png_bytes = base64.b64decode(png_b64)
|
|
163
|
+
payload = build_render_bytes(png_bytes, out=target)
|
|
164
|
+
except RuntimeError:
|
|
165
|
+
try:
|
|
166
|
+
payload = build_render_bytes(png_bytes, out=target, cell_width_px=_DEFAULT_CELL_SIZE[0], cell_height_px=_DEFAULT_CELL_SIZE[1])
|
|
167
|
+
except Exception:
|
|
168
|
+
self._write("[image/png]\n")
|
|
169
|
+
return
|
|
170
|
+
except Exception:
|
|
171
|
+
self._write("[image/png]\n")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if self._needs_execute_result_newline(): self._write("\n")
|
|
175
|
+
self._write(payload.decode("utf-8"))
|
|
176
|
+
|
|
177
|
+
def _handle_text_markdown(self, markdown_text: str, metadata=None): self._render_markdown(markdown_text)
|
|
178
|
+
|
|
179
|
+
def _handle_image_png(self, png_b64: str, metadata=None): self._render_png(png_b64, metadata)
|
|
180
|
+
|
|
181
|
+
def _render_history_output(self, output) -> str:
|
|
182
|
+
if output.output_type in {"out_stream", "err_stream"}: return "".join(output.bundle.get("stream", []))
|
|
183
|
+
|
|
184
|
+
bundle = output.bundle
|
|
185
|
+
if "text/markdown" in bundle: return bundle["text/markdown"]
|
|
186
|
+
if "image/png" in bundle: return "[image/png]"
|
|
187
|
+
if "text/plain" in bundle: return bundle["text/plain"]
|
|
188
|
+
return ""
|
|
189
|
+
|
|
190
|
+
def _flatten_output(self, execution_count: int) -> str | None:
|
|
191
|
+
pieces = []
|
|
192
|
+
for output in self.history_manager.outputs.get(execution_count, []):
|
|
193
|
+
text = self._render_history_output(output)
|
|
194
|
+
if not text: continue
|
|
195
|
+
if pieces and not pieces[-1].endswith("\n") and not text.startswith("\n"): pieces.append("\n")
|
|
196
|
+
pieces.append(text)
|
|
197
|
+
|
|
198
|
+
exception = self.history_manager.exceptions.get(execution_count)
|
|
199
|
+
if exception:
|
|
200
|
+
traceback_text = "".join(exception.get("traceback", []))
|
|
201
|
+
if traceback_text:
|
|
202
|
+
if pieces and not pieces[-1].endswith("\n") and not traceback_text.startswith("\n"): pieces.append("\n")
|
|
203
|
+
pieces.append(traceback_text)
|
|
204
|
+
|
|
205
|
+
if not pieces: return None
|
|
206
|
+
return "".join(pieces)
|
|
207
|
+
|
|
208
|
+
def _finalize_history(self, result):
|
|
209
|
+
execution_count = getattr(result, "execution_count", None)
|
|
210
|
+
if execution_count is None: return
|
|
211
|
+
|
|
212
|
+
flat_output = self._flatten_output(execution_count)
|
|
213
|
+
if flat_output is None: self.history_manager.output_hist_reprs.pop(execution_count, None)
|
|
214
|
+
else: self.history_manager.output_hist_reprs[execution_count] = flat_output
|
|
215
|
+
|
|
216
|
+
should_store = self.history_manager.db_log_output and flat_output is not None and (
|
|
217
|
+
execution_count in self._pending_store_output or execution_count in self.history_manager.exceptions)
|
|
218
|
+
self._pending_store_output.discard(execution_count)
|
|
219
|
+
if should_store: self._original_store_output(execution_count)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def load_ipython_extension(shell):
|
|
223
|
+
if getattr(shell, "_ipythonng_extension", None) is not None: return
|
|
224
|
+
extension = IPythonNGExtension(shell)
|
|
225
|
+
extension.load()
|
|
226
|
+
shell._ipythonng_extension = extension
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def unload_ipython_extension(shell):
|
|
230
|
+
extension = getattr(shell, "_ipythonng_extension", None)
|
|
231
|
+
if extension is None: return
|
|
232
|
+
extension.unload()
|
|
233
|
+
del shell._ipythonng_extension
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ipythonng
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: IPython extension for terminal markdown, kitty images, and richer output history
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: ipython>=9.0
|
|
8
|
+
Requires-Dist: kittytgp>=0.0.2
|
|
9
|
+
Requires-Dist: rich>=13.0
|
|
10
|
+
|
|
11
|
+
# ipythonng
|
|
12
|
+
|
|
13
|
+
`ipythonng` is a small IPython extension for terminal sessions that adds:
|
|
14
|
+
|
|
15
|
+
- `text/markdown` rendering with Rich
|
|
16
|
+
- `image/png` rendering via `kittytgp`
|
|
17
|
+
- matplotlib inline support
|
|
18
|
+
- Includes display objects, streams, and rich results in stored history
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install ipythonng
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Use as an extension
|
|
27
|
+
|
|
28
|
+
Add the extension and enable output logging in your IPython config:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
c.InteractiveShellApp.extensions = ["ipythonng"]
|
|
32
|
+
c.HistoryManager.db_log_output = True
|
|
33
|
+
c.InteractiveShellApp.exec_lines = ["%matplotlib inline"] # if you like
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or launch it ad hoc:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
ipython --ext ipythonng
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For matplotlib, `%matplotlib inline` works with the existing `image/png` renderer. No custom matplotlib backend is needed. Using `exec_lines` runs the magic after extensions load.
|
|
43
|
+
|
|
44
|
+
## Convenience launcher
|
|
45
|
+
|
|
46
|
+
The package also installs an `ipythonng` command that simply starts IPython with
|
|
47
|
+
`--ext ipythonng`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
./ipythonng/__init__.py
|
|
4
|
+
./ipythonng/cli.py
|
|
5
|
+
./ipythonng/extension.py
|
|
6
|
+
ipythonng/__init__.py
|
|
7
|
+
ipythonng/cli.py
|
|
8
|
+
ipythonng/extension.py
|
|
9
|
+
ipythonng.egg-info/PKG-INFO
|
|
10
|
+
ipythonng.egg-info/SOURCES.txt
|
|
11
|
+
ipythonng.egg-info/dependency_links.txt
|
|
12
|
+
ipythonng.egg-info/entry_points.txt
|
|
13
|
+
ipythonng.egg-info/requires.txt
|
|
14
|
+
ipythonng.egg-info/top_level.txt
|
|
15
|
+
tests/test_ipythonng.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ipythonng
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ipythonng"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "IPython extension for terminal markdown, kitty images, and richer output history"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"ipython>=9.0",
|
|
13
|
+
"kittytgp>=0.0.2",
|
|
14
|
+
"rich>=13.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
ipythonng = "ipythonng.cli:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
package-dir = {"" = "."}
|
|
22
|
+
include-package-data = true
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.dynamic]
|
|
25
|
+
version = { attr = "ipythonng.__version__" }
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
include = ["ipythonng*"]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
import kittytgp.core as kittycore
|
|
5
|
+
import pytest
|
|
6
|
+
from IPython.terminal.interactiveshell import TerminalInteractiveShell
|
|
7
|
+
from kittytgp import build_render_bytes
|
|
8
|
+
from kittytgp.core import PLACEHOLDER
|
|
9
|
+
from traitlets.config import Config
|
|
10
|
+
|
|
11
|
+
from ipythonng import load_ipython_extension
|
|
12
|
+
|
|
13
|
+
PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAQAAABi6S9dAAAADElEQVR42mNkYPhfDwADhgGAff/3fwAAAABJRU5ErkJggg=="
|
|
14
|
+
PNG_BYTES = base64.b64decode(PNG_B64)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeTerminal(io.StringIO):
|
|
18
|
+
def isatty(self): return True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GeometryProbe:
|
|
22
|
+
def fileno(self): return 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def shell(tmp_path):
|
|
27
|
+
TerminalInteractiveShell.clear_instance()
|
|
28
|
+
config = Config()
|
|
29
|
+
config.TerminalInteractiveShell.simple_prompt = True
|
|
30
|
+
config.HistoryManager.hist_file = str(tmp_path / "history.sqlite")
|
|
31
|
+
shell = TerminalInteractiveShell.instance(config=config)
|
|
32
|
+
shell.execution_count = 1
|
|
33
|
+
shell.history_manager.outputs.clear()
|
|
34
|
+
shell.history_manager.output_hist_reprs.clear()
|
|
35
|
+
shell.history_manager.exceptions.clear()
|
|
36
|
+
shell._ipythonng_stream = FakeTerminal()
|
|
37
|
+
load_ipython_extension(shell)
|
|
38
|
+
try: yield shell
|
|
39
|
+
finally:
|
|
40
|
+
shell.history_manager.writeout_cache()
|
|
41
|
+
shell.history_manager.end_session()
|
|
42
|
+
shell._atexit_once = lambda: None
|
|
43
|
+
TerminalInteractiveShell.clear_instance()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_history_flattens_streams_markdown_images_and_results(shell):
|
|
47
|
+
shell.run_cell(
|
|
48
|
+
"""
|
|
49
|
+
from IPython.display import Markdown, Image, display
|
|
50
|
+
import base64
|
|
51
|
+
print("alpha")
|
|
52
|
+
display(Markdown("# Heading"))
|
|
53
|
+
display(Image(data=base64.b64decode(%r), format="png"))
|
|
54
|
+
print("omega")
|
|
55
|
+
42
|
|
56
|
+
"""
|
|
57
|
+
% PNG_B64,
|
|
58
|
+
store_history=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
(_, _, (_, output)) = list(shell.history_manager.get_range(output=True))[-1]
|
|
62
|
+
assert output == "alpha\n# Heading\n[image/png]\nomega\n42"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_output_history_persists_flattened_output_across_sessions(shell):
|
|
66
|
+
shell.history_manager.db_log_output = True
|
|
67
|
+
result = shell.run_cell(
|
|
68
|
+
"""
|
|
69
|
+
from IPython.display import Markdown, display
|
|
70
|
+
print("alpha")
|
|
71
|
+
display(Markdown("## Saved"))
|
|
72
|
+
""",
|
|
73
|
+
store_history=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
shell.history_manager.writeout_cache()
|
|
77
|
+
shell.history_manager.reset()
|
|
78
|
+
|
|
79
|
+
execution_count = result.execution_count
|
|
80
|
+
entries = list(shell.history_manager.get_range(-1, execution_count, execution_count + 1, output=True))
|
|
81
|
+
assert entries[0][2][1] == "alpha\n## Saved"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_tracebacks_are_included_in_flattened_output(shell):
|
|
85
|
+
shell.history_manager.db_log_output = True
|
|
86
|
+
shell.run_cell("1/0", store_history=True)
|
|
87
|
+
|
|
88
|
+
(_, _, (_, output)) = list(shell.history_manager.get_range(output=True))[-1]
|
|
89
|
+
assert "ZeroDivisionError" in output
|
|
90
|
+
assert "division by zero" in output
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_kitty_rendering_matches_kittytgp_outside_tmux(shell, monkeypatch):
|
|
94
|
+
monkeypatch.delenv("TMUX", raising=False)
|
|
95
|
+
monkeypatch.setattr(kittycore.secrets, "randbelow", lambda _: 0x123456 - 1)
|
|
96
|
+
|
|
97
|
+
shell.run_cell(
|
|
98
|
+
"""
|
|
99
|
+
from IPython.display import Image, display
|
|
100
|
+
import base64
|
|
101
|
+
display(Image(data=base64.b64decode(%r), format="png"))
|
|
102
|
+
"""
|
|
103
|
+
% PNG_B64,
|
|
104
|
+
store_history=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
rendered = shell._ipythonng_stream.getvalue()
|
|
108
|
+
expected = build_render_bytes(PNG_BYTES, out=GeometryProbe(), cell_width_px=8, cell_height_px=16).decode("utf-8")
|
|
109
|
+
assert rendered == expected
|
|
110
|
+
assert PLACEHOLDER in rendered
|
|
111
|
+
assert "\x1bPtmux;" not in rendered
|
|
112
|
+
assert "[image/png]" not in rendered
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_execute_result_images_start_on_new_line(shell, monkeypatch):
|
|
116
|
+
monkeypatch.delenv("TMUX", raising=False)
|
|
117
|
+
monkeypatch.setattr(kittycore.secrets, "randbelow", lambda _: 0x222222 - 1)
|
|
118
|
+
|
|
119
|
+
shell.run_cell(
|
|
120
|
+
"""
|
|
121
|
+
from IPython.display import Image
|
|
122
|
+
import base64
|
|
123
|
+
Image(data=base64.b64decode(%r), format="png")
|
|
124
|
+
"""
|
|
125
|
+
% PNG_B64,
|
|
126
|
+
store_history=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
rendered = shell._ipythonng_stream.getvalue()
|
|
130
|
+
expected = build_render_bytes(PNG_BYTES, out=GeometryProbe(), cell_width_px=8, cell_height_px=16).decode("utf-8")
|
|
131
|
+
assert rendered == "\n" + expected
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_kitty_rendering_matches_kittytgp_inside_tmux(shell, monkeypatch):
|
|
135
|
+
monkeypatch.setenv("TMUX", "/tmp/tmux")
|
|
136
|
+
monkeypatch.setattr(kittycore.secrets, "randbelow", lambda _: 0x654321 - 1)
|
|
137
|
+
|
|
138
|
+
shell.run_cell(
|
|
139
|
+
"""
|
|
140
|
+
from IPython.display import Image, display
|
|
141
|
+
import base64
|
|
142
|
+
display(Image(data=base64.b64decode(%r), format="png"))
|
|
143
|
+
"""
|
|
144
|
+
% PNG_B64,
|
|
145
|
+
store_history=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
rendered = shell._ipythonng_stream.getvalue()
|
|
149
|
+
expected = build_render_bytes(PNG_BYTES, out=GeometryProbe(), cell_width_px=8, cell_height_px=16).decode("utf-8")
|
|
150
|
+
assert rendered == expected
|
|
151
|
+
assert PLACEHOLDER in rendered
|
|
152
|
+
assert "\x1bPtmux;" in rendered
|
|
153
|
+
assert "[image/png]" not in rendered
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_matplotlib_inline_plots_render_via_image_png(shell, monkeypatch, tmp_path):
|
|
157
|
+
pytest.importorskip("matplotlib")
|
|
158
|
+
mplconfig = tmp_path / "mplconfig"
|
|
159
|
+
mplconfig.mkdir()
|
|
160
|
+
monkeypatch.setenv("MPLCONFIGDIR", str(mplconfig))
|
|
161
|
+
monkeypatch.delenv("TMUX", raising=False)
|
|
162
|
+
monkeypatch.setattr(kittycore.secrets, "randbelow", lambda _: 0x333333 - 1)
|
|
163
|
+
|
|
164
|
+
shell.run_line_magic("matplotlib", "inline")
|
|
165
|
+
shell.run_cell("import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])", store_history=True)
|
|
166
|
+
|
|
167
|
+
rendered = shell._ipythonng_stream.getvalue()
|
|
168
|
+
(_, _, (_, output)) = list(shell.history_manager.get_range(output=True))[-1]
|
|
169
|
+
assert PLACEHOLDER in rendered
|
|
170
|
+
assert "\x1b_G" in rendered
|
|
171
|
+
assert "matplotlib.lines.Line2D" in output
|
|
172
|
+
assert "[image/png]" in output
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_matplotlib_inline_magic_suppresses_no_event_loop_message(shell, monkeypatch, tmp_path, capsys):
|
|
176
|
+
pytest.importorskip("matplotlib")
|
|
177
|
+
mplconfig = tmp_path / "mplconfig"
|
|
178
|
+
mplconfig.mkdir()
|
|
179
|
+
monkeypatch.setenv("MPLCONFIGDIR", str(mplconfig))
|
|
180
|
+
|
|
181
|
+
capsys.readouterr()
|
|
182
|
+
shell.run_line_magic("matplotlib", "inline")
|
|
183
|
+
captured = capsys.readouterr()
|
|
184
|
+
assert "No event loop hook running." not in captured.out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_matplotlib_inline_after_prior_plot_renders_future_plots(shell, monkeypatch, tmp_path, capsys):
|
|
188
|
+
pytest.importorskip("matplotlib")
|
|
189
|
+
mplconfig = tmp_path / "mplconfig"
|
|
190
|
+
mplconfig.mkdir()
|
|
191
|
+
monkeypatch.setenv("MPLCONFIGDIR", str(mplconfig))
|
|
192
|
+
monkeypatch.delenv("TMUX", raising=False)
|
|
193
|
+
monkeypatch.setattr(kittycore.secrets, "randbelow", lambda _: 0x444444 - 1)
|
|
194
|
+
|
|
195
|
+
shell.run_cell("import matplotlib\nmatplotlib.use('agg')\nimport matplotlib.pyplot as plt\nplt.plot([1, 2, 3])", store_history=True)
|
|
196
|
+
shell._ipythonng_stream.seek(0)
|
|
197
|
+
shell._ipythonng_stream.truncate(0)
|
|
198
|
+
|
|
199
|
+
capsys.readouterr()
|
|
200
|
+
shell.run_line_magic("matplotlib", "inline")
|
|
201
|
+
captured = capsys.readouterr()
|
|
202
|
+
assert "No event loop hook running." not in captured.out
|
|
203
|
+
|
|
204
|
+
shell.run_cell("plt.plot([4, 5, 6])", store_history=True)
|
|
205
|
+
|
|
206
|
+
rendered = shell._ipythonng_stream.getvalue()
|
|
207
|
+
output_types = [o.output_type for o in shell.history_manager.outputs.get(shell.execution_count - 1, [])]
|
|
208
|
+
assert PLACEHOLDER in rendered
|
|
209
|
+
assert "\x1b_G" in rendered
|
|
210
|
+
assert "display_data" in output_types
|