nbsee 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.
- nbsee-0.1.0/LICENSE +22 -0
- nbsee-0.1.0/MANIFEST.in +7 -0
- nbsee-0.1.0/PKG-INFO +83 -0
- nbsee-0.1.0/README.md +59 -0
- nbsee-0.1.0/docs/DESIGN.md +73 -0
- nbsee-0.1.0/docs/TODO.md +47 -0
- nbsee-0.1.0/pyproject.toml +55 -0
- nbsee-0.1.0/setup.cfg +4 -0
- nbsee-0.1.0/src/nbsee/__init__.py +1 -0
- nbsee-0.1.0/src/nbsee/__main__.py +116 -0
- nbsee-0.1.0/src/nbsee/textual_app.py +610 -0
- nbsee-0.1.0/src/nbsee.egg-info/PKG-INFO +83 -0
- nbsee-0.1.0/src/nbsee.egg-info/SOURCES.txt +16 -0
- nbsee-0.1.0/src/nbsee.egg-info/dependency_links.txt +1 -0
- nbsee-0.1.0/src/nbsee.egg-info/entry_points.txt +2 -0
- nbsee-0.1.0/src/nbsee.egg-info/requires.txt +2 -0
- nbsee-0.1.0/src/nbsee.egg-info/top_level.txt +1 -0
- nbsee-0.1.0/tests/test_notebook_parsing.py +45 -0
nbsee-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 nbsee contributors
|
|
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.
|
|
22
|
+
|
nbsee-0.1.0/MANIFEST.in
ADDED
nbsee-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nbsee
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rich/Textual terminal viewer for Jupyter notebooks focused on copying code and text outputs
|
|
5
|
+
Author: Yoga Yudistira
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AnakTeka/nbsee
|
|
8
|
+
Project-URL: Repository, https://github.com/AnakTeka/nbsee
|
|
9
|
+
Project-URL: Issues, https://github.com/AnakTeka/nbsee/issues
|
|
10
|
+
Keywords: jupyter,notebook,cli,tui,rich,textual
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: rich>=13.7
|
|
22
|
+
Requires-Dist: textual>=0.58
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
nbsee
|
|
26
|
+
=====
|
|
27
|
+
|
|
28
|
+
Simple CLI viewer for Jupyter notebooks focused on fast reading and easy copying of code and text outputs from the terminal.
|
|
29
|
+
|
|
30
|
+
Status: planning/design. See docs/TODO.md for the working task list.
|
|
31
|
+
|
|
32
|
+
Goals (MVP)
|
|
33
|
+
- Interactive terminal viewer for `.ipynb`.
|
|
34
|
+
- Navigate cells with keys; focus input vs output.
|
|
35
|
+
- Code cells: show source and text/plain outputs only.
|
|
36
|
+
- Easy copying from the viewer using the keyboard (xclip).
|
|
37
|
+
- Ignore images/HTML-rich outputs for now.
|
|
38
|
+
|
|
39
|
+
Quick Concept (not implemented yet)
|
|
40
|
+
- `nbsee notebook.ipynb` — open an interactive viewer.
|
|
41
|
+
|
|
42
|
+
Key bindings (proposed)
|
|
43
|
+
- `j`/`k`: move to next/previous cell
|
|
44
|
+
- Arrow Up/Down: scroll the view line-by-line
|
|
45
|
+
- PageUp/PageDown: page scroll
|
|
46
|
+
- Ctrl+u / Ctrl+d: half-page scroll up/down
|
|
47
|
+
- `Tab`: toggle focus between input (code/markdown) and output (text/plain)
|
|
48
|
+
- If a cell has no text output, `Tab` does nothing and shows a brief message.
|
|
49
|
+
- `y`: copy the currently focused region to clipboard using `xclip -selection clipboard`
|
|
50
|
+
- `z`: hide/show all outputs (fold outputs)
|
|
51
|
+
- `Shift+V`: start/stop visual selection for inputs; use `j`/`k` to expand/shrink; `y` copies all selected code cells
|
|
52
|
+
- `/`: search (inputs+outputs). Enter to submit, `Esc` cancels
|
|
53
|
+
- `n` / `N`: next/prev match
|
|
54
|
+
- `gg` / `G`: jump to first / last cell
|
|
55
|
+
- `G`/`gg`: jump to last/first cell (optional)
|
|
56
|
+
- `q`: quit
|
|
57
|
+
|
|
58
|
+
Notes
|
|
59
|
+
- Copying uses Linux X11 clipboard via `xclip`. Wayland/macOS not targeted in MVP.
|
|
60
|
+
- When an output is not available (e.g., no text/plain), copy will show an inline message.
|
|
61
|
+
- Python syntax highlighting is built-in via Rich.
|
|
62
|
+
|
|
63
|
+
Install (PyPI)
|
|
64
|
+
- `python -m pip install nbsee`
|
|
65
|
+
- Run: `nbsee path/to/notebook.ipynb`
|
|
66
|
+
|
|
67
|
+
Development (uv)
|
|
68
|
+
- Quick run without installing: `uv run -m nbsee path/to/notebook.ipynb`
|
|
69
|
+
- Run tests: `uv run pytest`
|
|
70
|
+
- Lint/format check: `uv run ruff check .` and `uv run ruff format --check .`
|
|
71
|
+
- Build distributions: `uv build`
|
|
72
|
+
- Release: push a tag like `v0.1.0` to trigger `.github/workflows/publish.yml` (requires PyPI trusted publishing setup).
|
|
73
|
+
- Install editable and use `nbsee` command:
|
|
74
|
+
- Create venv: `uv venv`
|
|
75
|
+
- Activate: `source .venv/bin/activate`
|
|
76
|
+
- Install: `uv pip install -e .`
|
|
77
|
+
- Run: `nbsee path/to/notebook.ipynb`
|
|
78
|
+
- Clipboard requirement: `xclip` on Linux/X11 must be installed and available in `PATH`.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
Docs
|
|
82
|
+
- Design: `docs/DESIGN.md`
|
|
83
|
+
- TODOs & roadmap: `docs/TODO.md`
|
nbsee-0.1.0/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
nbsee
|
|
2
|
+
=====
|
|
3
|
+
|
|
4
|
+
Simple CLI viewer for Jupyter notebooks focused on fast reading and easy copying of code and text outputs from the terminal.
|
|
5
|
+
|
|
6
|
+
Status: planning/design. See docs/TODO.md for the working task list.
|
|
7
|
+
|
|
8
|
+
Goals (MVP)
|
|
9
|
+
- Interactive terminal viewer for `.ipynb`.
|
|
10
|
+
- Navigate cells with keys; focus input vs output.
|
|
11
|
+
- Code cells: show source and text/plain outputs only.
|
|
12
|
+
- Easy copying from the viewer using the keyboard (xclip).
|
|
13
|
+
- Ignore images/HTML-rich outputs for now.
|
|
14
|
+
|
|
15
|
+
Quick Concept (not implemented yet)
|
|
16
|
+
- `nbsee notebook.ipynb` — open an interactive viewer.
|
|
17
|
+
|
|
18
|
+
Key bindings (proposed)
|
|
19
|
+
- `j`/`k`: move to next/previous cell
|
|
20
|
+
- Arrow Up/Down: scroll the view line-by-line
|
|
21
|
+
- PageUp/PageDown: page scroll
|
|
22
|
+
- Ctrl+u / Ctrl+d: half-page scroll up/down
|
|
23
|
+
- `Tab`: toggle focus between input (code/markdown) and output (text/plain)
|
|
24
|
+
- If a cell has no text output, `Tab` does nothing and shows a brief message.
|
|
25
|
+
- `y`: copy the currently focused region to clipboard using `xclip -selection clipboard`
|
|
26
|
+
- `z`: hide/show all outputs (fold outputs)
|
|
27
|
+
- `Shift+V`: start/stop visual selection for inputs; use `j`/`k` to expand/shrink; `y` copies all selected code cells
|
|
28
|
+
- `/`: search (inputs+outputs). Enter to submit, `Esc` cancels
|
|
29
|
+
- `n` / `N`: next/prev match
|
|
30
|
+
- `gg` / `G`: jump to first / last cell
|
|
31
|
+
- `G`/`gg`: jump to last/first cell (optional)
|
|
32
|
+
- `q`: quit
|
|
33
|
+
|
|
34
|
+
Notes
|
|
35
|
+
- Copying uses Linux X11 clipboard via `xclip`. Wayland/macOS not targeted in MVP.
|
|
36
|
+
- When an output is not available (e.g., no text/plain), copy will show an inline message.
|
|
37
|
+
- Python syntax highlighting is built-in via Rich.
|
|
38
|
+
|
|
39
|
+
Install (PyPI)
|
|
40
|
+
- `python -m pip install nbsee`
|
|
41
|
+
- Run: `nbsee path/to/notebook.ipynb`
|
|
42
|
+
|
|
43
|
+
Development (uv)
|
|
44
|
+
- Quick run without installing: `uv run -m nbsee path/to/notebook.ipynb`
|
|
45
|
+
- Run tests: `uv run pytest`
|
|
46
|
+
- Lint/format check: `uv run ruff check .` and `uv run ruff format --check .`
|
|
47
|
+
- Build distributions: `uv build`
|
|
48
|
+
- Release: push a tag like `v0.1.0` to trigger `.github/workflows/publish.yml` (requires PyPI trusted publishing setup).
|
|
49
|
+
- Install editable and use `nbsee` command:
|
|
50
|
+
- Create venv: `uv venv`
|
|
51
|
+
- Activate: `source .venv/bin/activate`
|
|
52
|
+
- Install: `uv pip install -e .`
|
|
53
|
+
- Run: `nbsee path/to/notebook.ipynb`
|
|
54
|
+
- Clipboard requirement: `xclip` on Linux/X11 must be installed and available in `PATH`.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
Docs
|
|
58
|
+
- Design: `docs/DESIGN.md`
|
|
59
|
+
- TODOs & roadmap: `docs/TODO.md`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Design Notes
|
|
2
|
+
============
|
|
3
|
+
|
|
4
|
+
Problem
|
|
5
|
+
- Quickly view Jupyter notebook cells in a terminal and easily copy either the code or the text/plain output.
|
|
6
|
+
- Avoid heavy rendering; keep it fast and focused on text.
|
|
7
|
+
|
|
8
|
+
Constraints / Non-goals (MVP)
|
|
9
|
+
- Do not render images, rich HTML, plots, or dataframes (treat them as placeholders or omit).
|
|
10
|
+
- Do not execute notebooks; viewing only.
|
|
11
|
+
- No in-place editing of notebooks.
|
|
12
|
+
|
|
13
|
+
CLI UX (proposed)
|
|
14
|
+
- `nbsee <notebook.ipynb>`: open an interactive viewer.
|
|
15
|
+
- Multi-cell list layout with vertical scrolling provides context similar to a notebook editor.
|
|
16
|
+
- Each cell renders: header `[idx/type]`, Input, separator, Output, and spacing.
|
|
17
|
+
- Selection (J/K) highlights a cell header; focus marker (Tab) toggles Input/Output of the selected cell for copying.
|
|
18
|
+
- Exit codes: 0 on success; non-zero on IO/parse errors.
|
|
19
|
+
|
|
20
|
+
Keymap (MVP)
|
|
21
|
+
- `J` / `K`: next / previous cell (change selection)
|
|
22
|
+
- Arrow Up/Down: scroll the view line-by-line
|
|
23
|
+
- PageUp/PageDown: page scroll
|
|
24
|
+
- `Tab`: toggle focus between Input and Output for selected cell (no-op if no text output)
|
|
25
|
+
- `Ctrl+u` / `Ctrl+d`: scroll up/down by half a page
|
|
26
|
+
- `z`: toggle hide/show of all outputs (global fold)
|
|
27
|
+
- `Shift+V`: enter/exit visual selection for inputs; `j`/`k` adjust the range; `y` yanks (copies) all selected code cells to clipboard
|
|
28
|
+
- `/`: open a search prompt (inputs + outputs, case-insensitive)
|
|
29
|
+
- `n` / `N`: jump to next/previous match, wrapping around
|
|
30
|
+
- `gg` / `G`: jump to first/last cell
|
|
31
|
+
- `y`: copy focused region to clipboard using `xclip -selection clipboard`
|
|
32
|
+
- `G` / `gg`: jump to last / first cell (optional)
|
|
33
|
+
- `q`: quit
|
|
34
|
+
|
|
35
|
+
Copying Workflow
|
|
36
|
+
- Interactive: pressing `C` copies the currently focused region (Input or Output) to the clipboard via `xclip -selection clipboard` on Linux/X11.
|
|
37
|
+
- If Output is focused but the cell has no text/plain output, show a temporary inline message and do nothing.
|
|
38
|
+
- Piping-based copying is not required in MVP; we optimize for the interactive flow.
|
|
39
|
+
|
|
40
|
+
Rendering
|
|
41
|
+
- Multi-cell stacked view; soft-wrap long lines to terminal width.
|
|
42
|
+
- Rich/Textual UI only: panels with clear separation and high-quality `Syntax` highlighting.
|
|
43
|
+
- No horizontal scrolling in MVP; lines are wrapped.
|
|
44
|
+
|
|
45
|
+
Parsing
|
|
46
|
+
- Use Python stdlib `json` for MVP (avoid deps); consider `nbformat` later for robustness.
|
|
47
|
+
- Fields: `cells[]`, `cell_type`, `source`, `outputs[]`, `output_type`, `text`, `data["text/plain"]`.
|
|
48
|
+
- Be robust to missing fields.
|
|
49
|
+
|
|
50
|
+
Performance
|
|
51
|
+
- Stream parsing is not necessary for typical sizes.
|
|
52
|
+
- Avoid expensive re-renders; redraw only on navigation or copy feedback.
|
|
53
|
+
|
|
54
|
+
Structure (proposed)
|
|
55
|
+
- `nbsee/` package with `__main__.py` entry point.
|
|
56
|
+
- Single UI: Rich/Textual-based TUI with `textual_app.py`.
|
|
57
|
+
- `pyproject.toml` exposes console script `nbsee` and depends on `rich` and `textual`.
|
|
58
|
+
|
|
59
|
+
Error Handling
|
|
60
|
+
- Clear messages for: file not found, invalid JSON, not a notebook, cell out of range, missing output.
|
|
61
|
+
- In-view ephemeral notices for copy failures (e.g., xclip missing or no output to copy).
|
|
62
|
+
|
|
63
|
+
Future Ideas (post-MVP)
|
|
64
|
+
- Multi-cell list pane with scrolling and preview.
|
|
65
|
+
- Rich/HTML output fallback with `rich`/`textual`.
|
|
66
|
+
- Image placeholders with metadata (e.g., [image output: 800x600]).
|
|
67
|
+
- Search/filter cells by content or tags.
|
|
68
|
+
- Copy multiple cells or ranges.
|
|
69
|
+
|
|
70
|
+
Developer Workflow (uv)
|
|
71
|
+
- Quick run without install: `uv run -m nbsee path/to/notebook.ipynb`
|
|
72
|
+
- Create venv: `uv venv && source .venv/bin/activate`
|
|
73
|
+
- Install editable: `uv pip install -e .`
|
nbsee-0.1.0/docs/TODO.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
nbsee TODOs
|
|
2
|
+
============
|
|
3
|
+
|
|
4
|
+
MVP Scope (Interactive viewer)
|
|
5
|
+
- [x] CLI skeleton: `nbsee <path.ipynb>` opens TUI
|
|
6
|
+
- [x] Validate path and read file
|
|
7
|
+
- [x] Parse notebook JSON (cells, types, sources)
|
|
8
|
+
- [x] Multi-cell stacked layout with scrolling
|
|
9
|
+
- [x] Render Input region (code/markdown/raw)
|
|
10
|
+
- [x] Render Output region for code cells (text/plain only)
|
|
11
|
+
- [x] Focus toggling: `Tab` cycles Input/Output
|
|
12
|
+
- [x] Navigation: `J`/`K` select cells; `↑/↓` scroll; `PgUp/PgDn` page
|
|
13
|
+
- [x] Copy: `y` copies focused region via `xclip -selection clipboard`
|
|
14
|
+
- [x] Feedback: status line for copy success/failure
|
|
15
|
+
- [ ] Handle errors (file not found, bad JSON, not a notebook, bad cell index) — basic done, refine messages
|
|
16
|
+
- [ ] Tests for parsing and selection logic
|
|
17
|
+
- [ ] Basic docs and examples
|
|
18
|
+
|
|
19
|
+
Rich/Textual UI
|
|
20
|
+
- [x] Use Rich/Textual as the default (and only) UI
|
|
21
|
+
- [ ] Style tuning for panels, themes, and readability
|
|
22
|
+
- [ ] Add cell collapse/expand interactions
|
|
23
|
+
- [ ] Add search ("/") and go-to (":N")
|
|
24
|
+
- [x] Global fold toggle for outputs (`z`)
|
|
25
|
+
- [x] Visual selection of inputs with `Shift+V`, adjust with `j/k`, yank with `y`
|
|
26
|
+
|
|
27
|
+
Copying Experience
|
|
28
|
+
- [ ] Linux/X11 clipboard via `xclip -selection clipboard`
|
|
29
|
+
- [ ] Detect missing `xclip` and show helpful message
|
|
30
|
+
- [ ] Optional later: Wayland (`wl-copy`) and macOS (`pbcopy`) support
|
|
31
|
+
|
|
32
|
+
Rendering
|
|
33
|
+
- [x] Simple, readable headers and focus markers (Rich panels)
|
|
34
|
+
- [x] Python syntax highlighting via Rich `Syntax`
|
|
35
|
+
|
|
36
|
+
Packaging
|
|
37
|
+
- [ ] `pyproject.toml` with console script entry `nbsee`
|
|
38
|
+
- [x] Depend on `rich` and `textual` directly (no extras)
|
|
39
|
+
- [ ] Document `uv` workflow (run, venv, install editable)
|
|
40
|
+
|
|
41
|
+
Post-MVP / Later
|
|
42
|
+
- [ ] Cell range display `--range A:B`
|
|
43
|
+
- [ ] Colorized code (Pygments)
|
|
44
|
+
- [ ] Rich outputs (HTML/images) placeholders
|
|
45
|
+
- [ ] Interactive TUI mode (Textual)
|
|
46
|
+
- [ ] Search/filter cells
|
|
47
|
+
- [ ] Copy multiple cells/ranges
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nbsee"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Rich/Textual terminal viewer for Jupyter notebooks focused on copying code and text outputs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Yoga Yudistira" }]
|
|
14
|
+
keywords = ["jupyter", "notebook", "cli", "tui", "rich", "textual"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Topic :: Utilities",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["rich>=13.7", "textual>=0.58"]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
nbsee = "nbsee.__main__:main"
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/AnakTeka/nbsee"
|
|
31
|
+
Repository = "https://github.com/AnakTeka/nbsee"
|
|
32
|
+
Issues = "https://github.com/AnakTeka/nbsee/issues"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
test = ["pytest>=9.0.0"]
|
|
37
|
+
lint = ["ruff>=0.8.0"]
|
|
38
|
+
dev = [{ include-group = "test" }, { include-group = "lint" }]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
package-dir = { "" = "src" }
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
line-length = 88
|
|
52
|
+
exclude = ["*.ipynb"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["E", "F", "I", "UP", "B"]
|
nbsee-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _read_file(path: str) -> dict[str, Any]:
|
|
11
|
+
with open(path, encoding="utf-8") as f:
|
|
12
|
+
return json.load(f)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _as_text(value: Any) -> str:
|
|
16
|
+
if value is None:
|
|
17
|
+
return ""
|
|
18
|
+
if isinstance(value, list):
|
|
19
|
+
return "".join(str(x) for x in value)
|
|
20
|
+
return str(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cell_input_text(cell: dict[str, Any]) -> str:
|
|
24
|
+
return _as_text(cell.get("source", ""))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _cell_output_text(cell: dict[str, Any]) -> str:
|
|
28
|
+
if cell.get("cell_type") != "code":
|
|
29
|
+
return ""
|
|
30
|
+
outs: list[str] = []
|
|
31
|
+
for out in cell.get("outputs", []) or []:
|
|
32
|
+
data = out.get("data") if isinstance(out, dict) else None
|
|
33
|
+
if isinstance(data, dict) and "text/plain" in data:
|
|
34
|
+
outs.append(_as_text(data.get("text/plain")))
|
|
35
|
+
continue
|
|
36
|
+
if out.get("output_type") == "stream" and "text" in out:
|
|
37
|
+
outs.append(_as_text(out.get("text")))
|
|
38
|
+
continue
|
|
39
|
+
if out.get("output_type") == "error" and "traceback" in out:
|
|
40
|
+
tb = out.get("traceback")
|
|
41
|
+
if isinstance(tb, list):
|
|
42
|
+
parts = [str(x) for x in tb]
|
|
43
|
+
# Jupyter tracebacks are often lists of lines. Sometimes newlines are
|
|
44
|
+
# embedded, sometimes they're separate elements; preserve embedded ones.
|
|
45
|
+
if any("\n" in p for p in parts):
|
|
46
|
+
outs.append("".join(parts))
|
|
47
|
+
else:
|
|
48
|
+
outs.append("\n".join(parts))
|
|
49
|
+
else:
|
|
50
|
+
outs.append(_as_text(tb))
|
|
51
|
+
continue
|
|
52
|
+
return "\n".join(x for x in outs if x)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_notebook(path: str) -> list[dict[str, Any]]:
|
|
56
|
+
nb = _read_file(path)
|
|
57
|
+
if (
|
|
58
|
+
not isinstance(nb, dict)
|
|
59
|
+
or "cells" not in nb
|
|
60
|
+
or not isinstance(nb["cells"], list)
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("Not a valid .ipynb: missing cells[]")
|
|
63
|
+
return nb["cells"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _copy_to_clipboard(text: str) -> tuple[bool, str]:
|
|
67
|
+
if not text:
|
|
68
|
+
return False, "Nothing to copy"
|
|
69
|
+
if shutil.which("xclip") is None:
|
|
70
|
+
return False, "xclip not found"
|
|
71
|
+
try:
|
|
72
|
+
p = subprocess.Popen(
|
|
73
|
+
["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE
|
|
74
|
+
)
|
|
75
|
+
assert p.stdin is not None
|
|
76
|
+
p.stdin.write(text.encode("utf-8"))
|
|
77
|
+
p.stdin.close()
|
|
78
|
+
rc = p.wait(timeout=5)
|
|
79
|
+
if rc != 0:
|
|
80
|
+
return False, f"xclip failed (code {rc})"
|
|
81
|
+
return True, f"Copied {len(text)} chars to clipboard"
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return False, "xclip not found"
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return False, f"Copy failed: {e}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: list[str] | None = None) -> int:
|
|
89
|
+
parser = argparse.ArgumentParser(
|
|
90
|
+
description="Rich/Textual viewer for Jupyter notebooks"
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument("path", help="Path to .ipynb file")
|
|
93
|
+
args = parser.parse_args(argv)
|
|
94
|
+
|
|
95
|
+
if not os.path.exists(args.path):
|
|
96
|
+
sys.stderr.write(f"File not found: {args.path}\n")
|
|
97
|
+
return 2
|
|
98
|
+
try:
|
|
99
|
+
cells = _load_notebook(args.path)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
sys.stderr.write(f"Failed to read notebook: {e}\n")
|
|
102
|
+
return 2
|
|
103
|
+
if not cells:
|
|
104
|
+
sys.stderr.write("Notebook has no cells\n")
|
|
105
|
+
return 1
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
from .textual_app import run_textual_app
|
|
109
|
+
except Exception as e: # pragma: no cover
|
|
110
|
+
sys.stderr.write(f"Rich/Textual UI not available: {e}\n")
|
|
111
|
+
return 2
|
|
112
|
+
return run_textual_app(cells)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from rich import box as rich_box
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.syntax import Syntax
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import VerticalScroll
|
|
14
|
+
from textual.reactive import reactive
|
|
15
|
+
from textual.widgets import Footer, Input, Static
|
|
16
|
+
|
|
17
|
+
_HAS_TEXTUAL = True
|
|
18
|
+
except Exception as e: # pragma: no cover
|
|
19
|
+
_HAS_TEXTUAL = False
|
|
20
|
+
_IMPORT_ERROR = e
|
|
21
|
+
|
|
22
|
+
from .__main__ import _cell_input_text, _cell_output_text, _copy_to_clipboard
|
|
23
|
+
|
|
24
|
+
_ANSI_CSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
25
|
+
_ANSI_OSC_RE = re.compile(r"\x1b\].*?(?:\x07|\x1b\\)")
|
|
26
|
+
_ANSI_SGR_RE = re.compile(r"\x1b\[([0-9;]*)m")
|
|
27
|
+
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _strip_sgr_background(text: str) -> str:
|
|
31
|
+
def repl(match: re.Match[str]) -> str:
|
|
32
|
+
raw = match.group(1)
|
|
33
|
+
if raw == "":
|
|
34
|
+
return match.group(0)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
params = [int(p) for p in raw.split(";") if p != ""]
|
|
38
|
+
except ValueError:
|
|
39
|
+
return match.group(0)
|
|
40
|
+
|
|
41
|
+
keep: list[int] = []
|
|
42
|
+
i = 0
|
|
43
|
+
while i < len(params):
|
|
44
|
+
p = params[i]
|
|
45
|
+
# Drop background colors:
|
|
46
|
+
# - 40-47, 100-107, 49
|
|
47
|
+
# - 48;5;n and 48;2;r;g;b
|
|
48
|
+
if p == 48:
|
|
49
|
+
if i + 1 < len(params) and params[i + 1] == 5:
|
|
50
|
+
i += 3
|
|
51
|
+
continue
|
|
52
|
+
if i + 1 < len(params) and params[i + 1] == 2:
|
|
53
|
+
i += 5
|
|
54
|
+
continue
|
|
55
|
+
i += 1
|
|
56
|
+
continue
|
|
57
|
+
if p == 49 or 40 <= p <= 47 or 100 <= p <= 107:
|
|
58
|
+
i += 1
|
|
59
|
+
continue
|
|
60
|
+
keep.append(p)
|
|
61
|
+
i += 1
|
|
62
|
+
|
|
63
|
+
if not keep:
|
|
64
|
+
return ""
|
|
65
|
+
return f"\x1b[{';'.join(str(p) for p in keep)}m"
|
|
66
|
+
|
|
67
|
+
return _ANSI_SGR_RE.sub(repl, text)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _sanitize_ansi_text(text: str) -> str:
|
|
71
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
72
|
+
text = _ANSI_OSC_RE.sub("", text)
|
|
73
|
+
|
|
74
|
+
def csi_repl(match: re.Match[str]) -> str:
|
|
75
|
+
seq = match.group(0)
|
|
76
|
+
return seq if seq.endswith("m") else ""
|
|
77
|
+
|
|
78
|
+
text = _ANSI_CSI_RE.sub(csi_repl, text)
|
|
79
|
+
text = re.sub(r"\x1b(?!\[[0-9;]*m)", "", text)
|
|
80
|
+
text = _CONTROL_RE.sub("", text)
|
|
81
|
+
# IPython tracebacks sometimes render "Traceback ...Cell ..." without a newline.
|
|
82
|
+
text = re.sub(r"(Traceback \(most recent call last\))\s*Cell", r"\1\nCell", text)
|
|
83
|
+
return text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ensure_textual_available():
|
|
87
|
+
if not _HAS_TEXTUAL:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
f"Rich/Textual UI requested but not available: {_IMPORT_ERROR}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if _HAS_TEXTUAL:
|
|
94
|
+
|
|
95
|
+
class CellView(Static):
|
|
96
|
+
selected: bool = reactive(False)
|
|
97
|
+
focus_region: str = reactive("input") # input|output
|
|
98
|
+
outputs_hidden: bool = reactive(False)
|
|
99
|
+
in_visual: bool = reactive(False) # in multi-cell visual selection range
|
|
100
|
+
|
|
101
|
+
def __init__(self, index: int, total: int, cell: dict[str, Any]):
|
|
102
|
+
super().__init__(id=f"cell-{index}")
|
|
103
|
+
self.index = index
|
|
104
|
+
self.total = total
|
|
105
|
+
self.cell = cell
|
|
106
|
+
|
|
107
|
+
def _render_input(self) -> Syntax | Text:
|
|
108
|
+
ctype = self.cell.get("cell_type")
|
|
109
|
+
source = _cell_input_text(self.cell)
|
|
110
|
+
if ctype == "code":
|
|
111
|
+
# Use Rich Syntax highlighter for Python
|
|
112
|
+
return Syntax(source or "", "python", theme="monokai", word_wrap=True)
|
|
113
|
+
# Markdown/raw rendered as plain text for now
|
|
114
|
+
return Text(source or "", style="default")
|
|
115
|
+
|
|
116
|
+
def _render_output(self) -> Text:
|
|
117
|
+
txt = _cell_output_text(self.cell)
|
|
118
|
+
if not (txt or "").strip():
|
|
119
|
+
t = Text("[no text output]", style="grey50 italic")
|
|
120
|
+
t.no_wrap = False
|
|
121
|
+
t.overflow = "fold"
|
|
122
|
+
return t
|
|
123
|
+
|
|
124
|
+
txt = txt or ""
|
|
125
|
+
if self._has_error_output():
|
|
126
|
+
cleaned = _strip_sgr_background(_sanitize_ansi_text(txt))
|
|
127
|
+
t = Text.from_ansi(f"{cleaned}\x1b[0m")
|
|
128
|
+
elif "\x1b" in txt:
|
|
129
|
+
cleaned = _sanitize_ansi_text(txt)
|
|
130
|
+
t = Text.from_ansi(f"{cleaned}\x1b[0m")
|
|
131
|
+
else:
|
|
132
|
+
t = Text(_sanitize_ansi_text(txt), style="grey70")
|
|
133
|
+
t.no_wrap = False
|
|
134
|
+
t.overflow = "fold"
|
|
135
|
+
return t
|
|
136
|
+
|
|
137
|
+
def _has_error_output(self) -> bool:
|
|
138
|
+
try:
|
|
139
|
+
for out in self.cell.get("outputs", []) or []:
|
|
140
|
+
if out.get("output_type") == "error":
|
|
141
|
+
return True
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
txt = _cell_output_text(self.cell) or ""
|
|
145
|
+
return "Traceback (most recent call last)" in txt
|
|
146
|
+
|
|
147
|
+
def render(self):
|
|
148
|
+
# Header: color indicates selection
|
|
149
|
+
# Header styling
|
|
150
|
+
if self.selected:
|
|
151
|
+
header_style = "magenta bold"
|
|
152
|
+
prefix = "▶ "
|
|
153
|
+
elif self.in_visual:
|
|
154
|
+
header_style = "yellow"
|
|
155
|
+
prefix = "◆ "
|
|
156
|
+
else:
|
|
157
|
+
header_style = "grey50"
|
|
158
|
+
prefix = " "
|
|
159
|
+
cell_type = self.cell.get("cell_type", "?")
|
|
160
|
+
header_text = f"[ {self.index + 1}/{self.total} | {cell_type} ]"
|
|
161
|
+
header = Text(prefix + header_text, style=header_style)
|
|
162
|
+
|
|
163
|
+
# Input and Output as rounded panels with clear distinction
|
|
164
|
+
input_border = (
|
|
165
|
+
"cyan" if (self.selected and self.focus_region == "input") else "grey50"
|
|
166
|
+
)
|
|
167
|
+
if self.selected and self.focus_region == "output":
|
|
168
|
+
output_border = "cyan"
|
|
169
|
+
elif self._has_error_output():
|
|
170
|
+
output_border = "red"
|
|
171
|
+
else:
|
|
172
|
+
output_border = "grey37"
|
|
173
|
+
|
|
174
|
+
input_panel = Panel(
|
|
175
|
+
self._render_input(),
|
|
176
|
+
box=rich_box.ROUNDED,
|
|
177
|
+
border_style=input_border,
|
|
178
|
+
padding=(0, 1),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
parts = [header, input_panel]
|
|
182
|
+
out_text = _cell_output_text(self.cell)
|
|
183
|
+
if out_text.strip():
|
|
184
|
+
if self.outputs_hidden:
|
|
185
|
+
out_render = Text("[output hidden]", style="grey37 italic")
|
|
186
|
+
else:
|
|
187
|
+
out_render = self._render_output()
|
|
188
|
+
title = "Error" if self._has_error_output() else "Output"
|
|
189
|
+
output_panel = Panel(
|
|
190
|
+
out_render,
|
|
191
|
+
title=title,
|
|
192
|
+
box=rich_box.ROUNDED,
|
|
193
|
+
border_style=output_border,
|
|
194
|
+
padding=(0, 1),
|
|
195
|
+
)
|
|
196
|
+
parts.append(output_panel)
|
|
197
|
+
|
|
198
|
+
from rich.console import Group
|
|
199
|
+
|
|
200
|
+
return Group(*parts)
|
|
201
|
+
|
|
202
|
+
class NotebookApp(App):
|
|
203
|
+
CSS = """
|
|
204
|
+
Screen { layers: base; }
|
|
205
|
+
VerticalScroll { padding: 0 1; }
|
|
206
|
+
#searchbar { dock: top; padding: 0 1; }
|
|
207
|
+
#footer { dock: bottom; }
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
BINDINGS = [
|
|
211
|
+
Binding("q", "quit", "Quit"),
|
|
212
|
+
Binding("j", "next_cell", "Next cell"),
|
|
213
|
+
Binding("k", "prev_cell", "Prev cell"),
|
|
214
|
+
# Override Textual's default focus-next so Tab toggles input/output
|
|
215
|
+
Binding("tab", "toggle_focus", "Toggle focus", priority=True),
|
|
216
|
+
Binding(
|
|
217
|
+
"shift+tab",
|
|
218
|
+
"toggle_focus",
|
|
219
|
+
"Toggle focus",
|
|
220
|
+
priority=True,
|
|
221
|
+
show=False,
|
|
222
|
+
),
|
|
223
|
+
Binding("y", "copy", "Copy"),
|
|
224
|
+
Binding("ctrl+d", "half_down", "Half down"),
|
|
225
|
+
Binding("ctrl+u", "half_up", "Half up"),
|
|
226
|
+
Binding("z", "toggle_outputs", "Hide/show outputs"),
|
|
227
|
+
Binding("shift+v", "toggle_visual", "Visual (inputs)"),
|
|
228
|
+
Binding("/", "search_prompt", "Search"),
|
|
229
|
+
Binding("escape", "cancel_search", "Cancel search", show=False),
|
|
230
|
+
Binding("n", "search_next", "Next match"),
|
|
231
|
+
Binding("shift+n", "search_prev", "Prev match", show=False),
|
|
232
|
+
Binding("G", "go_last", "Last cell"),
|
|
233
|
+
Binding("g", "go_prefix", "g (prefix)", show=False),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
def __init__(self, cells: list[dict[str, Any]]):
|
|
237
|
+
super().__init__()
|
|
238
|
+
self.cells = cells
|
|
239
|
+
self.selected = 0
|
|
240
|
+
self._cell_views: list[CellView] = []
|
|
241
|
+
self.hide_outputs = False
|
|
242
|
+
self.visual_mode = False
|
|
243
|
+
self.visual_anchor = 0
|
|
244
|
+
self.search_query: str = ""
|
|
245
|
+
# For handling "gg" sequence
|
|
246
|
+
self._g_pending = False
|
|
247
|
+
self._g_timer = None
|
|
248
|
+
|
|
249
|
+
def compose(self) -> ComposeResult:
|
|
250
|
+
total = len(self.cells)
|
|
251
|
+
views: list[CellView] = []
|
|
252
|
+
for i, cell in enumerate(self.cells):
|
|
253
|
+
cv = CellView(i, total, cell)
|
|
254
|
+
views.append(cv)
|
|
255
|
+
self._cell_views = views
|
|
256
|
+
yield VerticalScroll(*views, id="cells")
|
|
257
|
+
yield Footer(id="footer")
|
|
258
|
+
|
|
259
|
+
def on_mount(self) -> None:
|
|
260
|
+
self._update_selection(0)
|
|
261
|
+
|
|
262
|
+
def action_next_cell(self) -> None:
|
|
263
|
+
if self.selected < len(self._cell_views) - 1:
|
|
264
|
+
self._update_selection(self.selected + 1)
|
|
265
|
+
if self.visual_mode:
|
|
266
|
+
self._update_visual_flags()
|
|
267
|
+
|
|
268
|
+
def action_prev_cell(self) -> None:
|
|
269
|
+
if self.selected > 0:
|
|
270
|
+
self._update_selection(self.selected - 1)
|
|
271
|
+
if self.visual_mode:
|
|
272
|
+
self._update_visual_flags()
|
|
273
|
+
|
|
274
|
+
def _scroll(self, delta: int) -> int:
|
|
275
|
+
try:
|
|
276
|
+
sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
|
|
277
|
+
except Exception:
|
|
278
|
+
return 0
|
|
279
|
+
# Determine current y offset and target
|
|
280
|
+
current_y = 0
|
|
281
|
+
try:
|
|
282
|
+
off = getattr(sc, "scroll_offset", None)
|
|
283
|
+
if off is not None and hasattr(off, "y"):
|
|
284
|
+
current_y = int(off.y)
|
|
285
|
+
else:
|
|
286
|
+
current_y = int(getattr(sc, "scroll_y", 0))
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
target = max(0, current_y + int(delta))
|
|
290
|
+
# Perform scroll using best available API
|
|
291
|
+
try:
|
|
292
|
+
sc.scroll_to(y=target, animate=False)
|
|
293
|
+
return target
|
|
294
|
+
except TypeError:
|
|
295
|
+
try:
|
|
296
|
+
sc.scroll_to(0, target, animate=False)
|
|
297
|
+
return target
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
# Try duration=0 as a fallback to disable animation
|
|
303
|
+
try:
|
|
304
|
+
sc.scroll_to(y=target, duration=0)
|
|
305
|
+
return target
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
try:
|
|
309
|
+
sc.scroll_y = target # type: ignore[attr-defined]
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
return target
|
|
313
|
+
|
|
314
|
+
def _visible_cells(self) -> list[int]:
|
|
315
|
+
# Return indices of cells currently intersecting the viewport
|
|
316
|
+
try:
|
|
317
|
+
sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
|
|
318
|
+
except Exception:
|
|
319
|
+
return []
|
|
320
|
+
try:
|
|
321
|
+
sy = int(getattr(sc.scroll_offset, "y", getattr(sc, "scroll_y", 0)))
|
|
322
|
+
except Exception:
|
|
323
|
+
sy = int(getattr(sc, "scroll_y", 0))
|
|
324
|
+
try:
|
|
325
|
+
vh = (
|
|
326
|
+
int(getattr(sc.size, "height", 0))
|
|
327
|
+
or int(getattr(self.size, "height", 0))
|
|
328
|
+
or 20
|
|
329
|
+
)
|
|
330
|
+
except Exception:
|
|
331
|
+
vh = 20
|
|
332
|
+
vis: list[int] = []
|
|
333
|
+
for i, cv in enumerate(self._cell_views):
|
|
334
|
+
try:
|
|
335
|
+
y = int(getattr(cv.region, "y", 0))
|
|
336
|
+
h = max(1, int(getattr(cv.region, "height", 1)))
|
|
337
|
+
except Exception:
|
|
338
|
+
continue
|
|
339
|
+
if (y < sy + vh) and (y + h > sy):
|
|
340
|
+
vis.append(i)
|
|
341
|
+
return vis
|
|
342
|
+
|
|
343
|
+
def action_half_down(self) -> None:
|
|
344
|
+
try:
|
|
345
|
+
sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
|
|
346
|
+
height = (
|
|
347
|
+
int(getattr(sc.size, "height", 0))
|
|
348
|
+
or int(getattr(self.size, "height", 0))
|
|
349
|
+
or 20
|
|
350
|
+
)
|
|
351
|
+
except Exception:
|
|
352
|
+
height = 20
|
|
353
|
+
delta = max(5, height // 2)
|
|
354
|
+
if self.visual_mode:
|
|
355
|
+
self._set_visual(False)
|
|
356
|
+
self._scroll(delta)
|
|
357
|
+
vis = self._visible_cells()
|
|
358
|
+
step = max(1, len(vis) // 2 or 1)
|
|
359
|
+
new_idx = min(len(self._cell_views) - 1, self.selected + step)
|
|
360
|
+
self._update_selection(new_idx, ensure_visible=False)
|
|
361
|
+
|
|
362
|
+
def action_half_up(self) -> None:
|
|
363
|
+
try:
|
|
364
|
+
sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
|
|
365
|
+
height = (
|
|
366
|
+
int(getattr(sc.size, "height", 0))
|
|
367
|
+
or int(getattr(self.size, "height", 0))
|
|
368
|
+
or 20
|
|
369
|
+
)
|
|
370
|
+
except Exception:
|
|
371
|
+
height = 20
|
|
372
|
+
delta = -max(5, height // 2)
|
|
373
|
+
if self.visual_mode:
|
|
374
|
+
self._set_visual(False)
|
|
375
|
+
self._scroll(delta)
|
|
376
|
+
vis = self._visible_cells()
|
|
377
|
+
step = max(1, len(vis) // 2 or 1)
|
|
378
|
+
new_idx = max(0, self.selected - step)
|
|
379
|
+
self._update_selection(new_idx, ensure_visible=False)
|
|
380
|
+
|
|
381
|
+
# --- Search ---
|
|
382
|
+
def _cell_text(self, idx: int) -> str:
|
|
383
|
+
cell = self.cells[idx]
|
|
384
|
+
return f"{_cell_input_text(cell)}\n{_cell_output_text(cell)}"
|
|
385
|
+
|
|
386
|
+
def _search_from(self, start: int, step: int) -> int:
|
|
387
|
+
if not self.search_query:
|
|
388
|
+
return -1
|
|
389
|
+
q = self.search_query.lower()
|
|
390
|
+
n = len(self.cells)
|
|
391
|
+
i = start
|
|
392
|
+
for _ in range(n):
|
|
393
|
+
text = self._cell_text(i).lower()
|
|
394
|
+
if q in text:
|
|
395
|
+
return i
|
|
396
|
+
i = (i + step) % n
|
|
397
|
+
return -1
|
|
398
|
+
|
|
399
|
+
def action_search_prompt(self) -> None:
|
|
400
|
+
# Open a simple input bar at the top
|
|
401
|
+
if self.query("#searchbar"):
|
|
402
|
+
sb = self.query_one("#searchbar", Input)
|
|
403
|
+
self.call_after_refresh(sb.focus)
|
|
404
|
+
return
|
|
405
|
+
sb = Input(placeholder="/ search", id="searchbar")
|
|
406
|
+
sb.value = self.search_query
|
|
407
|
+
self.mount(sb)
|
|
408
|
+
self.call_after_refresh(sb.focus)
|
|
409
|
+
|
|
410
|
+
def action_cancel_search(self) -> None:
|
|
411
|
+
if self.query("#searchbar"):
|
|
412
|
+
try:
|
|
413
|
+
self.query_one("#searchbar", Input).remove()
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
418
|
+
if event.input.id == "searchbar":
|
|
419
|
+
self.search_query = event.value or ""
|
|
420
|
+
event.input.remove()
|
|
421
|
+
if self.search_query:
|
|
422
|
+
self.action_search_next()
|
|
423
|
+
else:
|
|
424
|
+
self.notify("Search cleared")
|
|
425
|
+
|
|
426
|
+
def on_input_blurred(self, event: Input.Blurred) -> None:
|
|
427
|
+
if event.input.id == "searchbar":
|
|
428
|
+
# Remove the bar when focus leaves
|
|
429
|
+
try:
|
|
430
|
+
event.input.remove()
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
def action_search_next(self) -> None:
|
|
435
|
+
if not self.search_query:
|
|
436
|
+
self.action_search_prompt()
|
|
437
|
+
return
|
|
438
|
+
start = (self.selected + 1) % len(self.cells)
|
|
439
|
+
idx = self._search_from(start, +1)
|
|
440
|
+
if idx >= 0:
|
|
441
|
+
self._update_selection(idx)
|
|
442
|
+
if self.visual_mode:
|
|
443
|
+
self._update_visual_flags()
|
|
444
|
+
self.notify(f"Found at cell {idx + 1}")
|
|
445
|
+
else:
|
|
446
|
+
self.notify("No match")
|
|
447
|
+
|
|
448
|
+
def action_search_prev(self) -> None:
|
|
449
|
+
if not self.search_query:
|
|
450
|
+
self.action_search_prompt()
|
|
451
|
+
return
|
|
452
|
+
start = (self.selected - 1) % len(self.cells)
|
|
453
|
+
idx = self._search_from(start, -1)
|
|
454
|
+
if idx >= 0:
|
|
455
|
+
self._update_selection(idx)
|
|
456
|
+
if self.visual_mode:
|
|
457
|
+
self._update_visual_flags()
|
|
458
|
+
self.notify(f"Found at cell {idx + 1}")
|
|
459
|
+
else:
|
|
460
|
+
self.notify("No match")
|
|
461
|
+
|
|
462
|
+
# --- Go to first/last (gg / G) ---
|
|
463
|
+
def action_go_last(self) -> None:
|
|
464
|
+
last = len(self._cell_views) - 1
|
|
465
|
+
self._update_selection(last)
|
|
466
|
+
if self.visual_mode:
|
|
467
|
+
self._update_visual_flags()
|
|
468
|
+
|
|
469
|
+
def _g_timeout(self) -> None:
|
|
470
|
+
self._g_pending = False
|
|
471
|
+
self._g_timer = None
|
|
472
|
+
|
|
473
|
+
def action_go_prefix(self) -> None:
|
|
474
|
+
# Handle the "gg" sequence with a short timeout
|
|
475
|
+
if self._g_pending:
|
|
476
|
+
if self._g_timer is not None:
|
|
477
|
+
try:
|
|
478
|
+
self._g_timer.pause()
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
self._g_timer = None
|
|
482
|
+
self._g_pending = False
|
|
483
|
+
self._update_selection(0)
|
|
484
|
+
if self.visual_mode:
|
|
485
|
+
self._update_visual_flags()
|
|
486
|
+
return
|
|
487
|
+
# Arm pending state and timer
|
|
488
|
+
self._g_pending = True
|
|
489
|
+
try:
|
|
490
|
+
self._g_timer = self.set_timer(0.7, self._g_timeout)
|
|
491
|
+
except Exception:
|
|
492
|
+
self._g_timer = None
|
|
493
|
+
|
|
494
|
+
def action_toggle_focus(self) -> None:
|
|
495
|
+
if self.visual_mode:
|
|
496
|
+
# In visual mode we always operate on input text; ignore toggling
|
|
497
|
+
self.notify(
|
|
498
|
+
"Visual mode: focus locked to Input", severity="information"
|
|
499
|
+
)
|
|
500
|
+
return
|
|
501
|
+
cv = self._cell_views[self.selected]
|
|
502
|
+
# Only allow switching to output if there is text output
|
|
503
|
+
if cv.focus_region == "input":
|
|
504
|
+
txt = _cell_output_text(cv.cell)
|
|
505
|
+
if not txt.strip():
|
|
506
|
+
# No output available; stay on input (optional hint)
|
|
507
|
+
self.notify("No text output for this cell", severity="warning")
|
|
508
|
+
return
|
|
509
|
+
cv.focus_region = "output"
|
|
510
|
+
else:
|
|
511
|
+
cv.focus_region = "input"
|
|
512
|
+
cv.refresh()
|
|
513
|
+
|
|
514
|
+
def action_copy(self) -> None:
|
|
515
|
+
# If in visual mode, copy all selected inputs
|
|
516
|
+
if self.visual_mode:
|
|
517
|
+
a, b = sorted((self.visual_anchor, self.selected))
|
|
518
|
+
pieces: list[str] = []
|
|
519
|
+
count = 0
|
|
520
|
+
for i in range(a, b + 1):
|
|
521
|
+
cell = self.cells[i]
|
|
522
|
+
if cell.get("cell_type") != "code":
|
|
523
|
+
continue
|
|
524
|
+
src = _cell_input_text(cell)
|
|
525
|
+
if src.strip():
|
|
526
|
+
pieces.append(src.rstrip("\n"))
|
|
527
|
+
count += 1
|
|
528
|
+
text = "\n\n".join(pieces)
|
|
529
|
+
if not text:
|
|
530
|
+
self.notify("No code in selection", severity="warning")
|
|
531
|
+
return
|
|
532
|
+
ok, msg = _copy_to_clipboard(text)
|
|
533
|
+
self.notify(f"Copied {count} cell(s): {msg}")
|
|
534
|
+
# Exit visual mode after yank
|
|
535
|
+
self._set_visual(False)
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
# Normal single-cell copy of focused region
|
|
539
|
+
cv = self._cell_views[self.selected]
|
|
540
|
+
cell = cv.cell
|
|
541
|
+
if cv.focus_region == "input":
|
|
542
|
+
text = _cell_input_text(cell)
|
|
543
|
+
else:
|
|
544
|
+
text = _cell_output_text(cell)
|
|
545
|
+
if not text.strip():
|
|
546
|
+
self.notify("No text output to copy", severity="warning")
|
|
547
|
+
return
|
|
548
|
+
ok, msg = _copy_to_clipboard(text)
|
|
549
|
+
self.notify(msg)
|
|
550
|
+
|
|
551
|
+
def action_toggle_outputs(self) -> None:
|
|
552
|
+
# Toggle global hide/show for outputs
|
|
553
|
+
self.hide_outputs = not self.hide_outputs
|
|
554
|
+
for cv in self._cell_views:
|
|
555
|
+
cv.outputs_hidden = self.hide_outputs
|
|
556
|
+
cv.refresh()
|
|
557
|
+
|
|
558
|
+
def _update_selection(
|
|
559
|
+
self, new_index: int, ensure_visible: bool = True
|
|
560
|
+
) -> None:
|
|
561
|
+
self._cell_views[self.selected].selected = False
|
|
562
|
+
self._cell_views[self.selected].refresh()
|
|
563
|
+
self.selected = new_index
|
|
564
|
+
sel = self._cell_views[self.selected]
|
|
565
|
+
sel.selected = True
|
|
566
|
+
sel.refresh()
|
|
567
|
+
# scroll into view
|
|
568
|
+
if ensure_visible:
|
|
569
|
+
self.call_after_refresh(lambda: sel.scroll_visible())
|
|
570
|
+
|
|
571
|
+
def _update_visual_flags(self) -> None:
|
|
572
|
+
# Mark cells in visual range
|
|
573
|
+
for i, cv in enumerate(self._cell_views):
|
|
574
|
+
in_range = self.visual_mode and (
|
|
575
|
+
min(self.visual_anchor, self.selected)
|
|
576
|
+
<= i
|
|
577
|
+
<= max(self.visual_anchor, self.selected)
|
|
578
|
+
)
|
|
579
|
+
cv.in_visual = in_range
|
|
580
|
+
cv.refresh()
|
|
581
|
+
|
|
582
|
+
def _set_visual(self, on: bool) -> None:
|
|
583
|
+
self.visual_mode = on
|
|
584
|
+
if on:
|
|
585
|
+
self.visual_anchor = self.selected
|
|
586
|
+
# Update flags
|
|
587
|
+
self._update_visual_flags()
|
|
588
|
+
# Ensure focus is Input in visual mode
|
|
589
|
+
if on:
|
|
590
|
+
self._cell_views[self.selected].focus_region = "input"
|
|
591
|
+
else:
|
|
592
|
+
# Clear markers
|
|
593
|
+
for cv in self._cell_views:
|
|
594
|
+
cv.in_visual = False
|
|
595
|
+
cv.refresh()
|
|
596
|
+
|
|
597
|
+
def action_toggle_visual(self) -> None:
|
|
598
|
+
self._set_visual(not self.visual_mode)
|
|
599
|
+
self.notify("Visual mode" if self.visual_mode else "Visual off")
|
|
600
|
+
|
|
601
|
+
def run_textual_app(cells: list[dict[str, Any]]) -> int:
|
|
602
|
+
ensure_textual_available()
|
|
603
|
+
app = NotebookApp(cells)
|
|
604
|
+
app.run()
|
|
605
|
+
return 0
|
|
606
|
+
else:
|
|
607
|
+
|
|
608
|
+
def run_textual_app(cells: list[dict[str, Any]]) -> int: # pragma: no cover
|
|
609
|
+
ensure_textual_available()
|
|
610
|
+
return 1
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nbsee
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Rich/Textual terminal viewer for Jupyter notebooks focused on copying code and text outputs
|
|
5
|
+
Author: Yoga Yudistira
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AnakTeka/nbsee
|
|
8
|
+
Project-URL: Repository, https://github.com/AnakTeka/nbsee
|
|
9
|
+
Project-URL: Issues, https://github.com/AnakTeka/nbsee/issues
|
|
10
|
+
Keywords: jupyter,notebook,cli,tui,rich,textual
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: rich>=13.7
|
|
22
|
+
Requires-Dist: textual>=0.58
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
nbsee
|
|
26
|
+
=====
|
|
27
|
+
|
|
28
|
+
Simple CLI viewer for Jupyter notebooks focused on fast reading and easy copying of code and text outputs from the terminal.
|
|
29
|
+
|
|
30
|
+
Status: planning/design. See docs/TODO.md for the working task list.
|
|
31
|
+
|
|
32
|
+
Goals (MVP)
|
|
33
|
+
- Interactive terminal viewer for `.ipynb`.
|
|
34
|
+
- Navigate cells with keys; focus input vs output.
|
|
35
|
+
- Code cells: show source and text/plain outputs only.
|
|
36
|
+
- Easy copying from the viewer using the keyboard (xclip).
|
|
37
|
+
- Ignore images/HTML-rich outputs for now.
|
|
38
|
+
|
|
39
|
+
Quick Concept (not implemented yet)
|
|
40
|
+
- `nbsee notebook.ipynb` — open an interactive viewer.
|
|
41
|
+
|
|
42
|
+
Key bindings (proposed)
|
|
43
|
+
- `j`/`k`: move to next/previous cell
|
|
44
|
+
- Arrow Up/Down: scroll the view line-by-line
|
|
45
|
+
- PageUp/PageDown: page scroll
|
|
46
|
+
- Ctrl+u / Ctrl+d: half-page scroll up/down
|
|
47
|
+
- `Tab`: toggle focus between input (code/markdown) and output (text/plain)
|
|
48
|
+
- If a cell has no text output, `Tab` does nothing and shows a brief message.
|
|
49
|
+
- `y`: copy the currently focused region to clipboard using `xclip -selection clipboard`
|
|
50
|
+
- `z`: hide/show all outputs (fold outputs)
|
|
51
|
+
- `Shift+V`: start/stop visual selection for inputs; use `j`/`k` to expand/shrink; `y` copies all selected code cells
|
|
52
|
+
- `/`: search (inputs+outputs). Enter to submit, `Esc` cancels
|
|
53
|
+
- `n` / `N`: next/prev match
|
|
54
|
+
- `gg` / `G`: jump to first / last cell
|
|
55
|
+
- `G`/`gg`: jump to last/first cell (optional)
|
|
56
|
+
- `q`: quit
|
|
57
|
+
|
|
58
|
+
Notes
|
|
59
|
+
- Copying uses Linux X11 clipboard via `xclip`. Wayland/macOS not targeted in MVP.
|
|
60
|
+
- When an output is not available (e.g., no text/plain), copy will show an inline message.
|
|
61
|
+
- Python syntax highlighting is built-in via Rich.
|
|
62
|
+
|
|
63
|
+
Install (PyPI)
|
|
64
|
+
- `python -m pip install nbsee`
|
|
65
|
+
- Run: `nbsee path/to/notebook.ipynb`
|
|
66
|
+
|
|
67
|
+
Development (uv)
|
|
68
|
+
- Quick run without installing: `uv run -m nbsee path/to/notebook.ipynb`
|
|
69
|
+
- Run tests: `uv run pytest`
|
|
70
|
+
- Lint/format check: `uv run ruff check .` and `uv run ruff format --check .`
|
|
71
|
+
- Build distributions: `uv build`
|
|
72
|
+
- Release: push a tag like `v0.1.0` to trigger `.github/workflows/publish.yml` (requires PyPI trusted publishing setup).
|
|
73
|
+
- Install editable and use `nbsee` command:
|
|
74
|
+
- Create venv: `uv venv`
|
|
75
|
+
- Activate: `source .venv/bin/activate`
|
|
76
|
+
- Install: `uv pip install -e .`
|
|
77
|
+
- Run: `nbsee path/to/notebook.ipynb`
|
|
78
|
+
- Clipboard requirement: `xclip` on Linux/X11 must be installed and available in `PATH`.
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
Docs
|
|
82
|
+
- Design: `docs/DESIGN.md`
|
|
83
|
+
- TODOs & roadmap: `docs/TODO.md`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
docs/DESIGN.md
|
|
6
|
+
docs/TODO.md
|
|
7
|
+
src/nbsee/__init__.py
|
|
8
|
+
src/nbsee/__main__.py
|
|
9
|
+
src/nbsee/textual_app.py
|
|
10
|
+
src/nbsee.egg-info/PKG-INFO
|
|
11
|
+
src/nbsee.egg-info/SOURCES.txt
|
|
12
|
+
src/nbsee.egg-info/dependency_links.txt
|
|
13
|
+
src/nbsee.egg-info/entry_points.txt
|
|
14
|
+
src/nbsee.egg-info/requires.txt
|
|
15
|
+
src/nbsee.egg-info/top_level.txt
|
|
16
|
+
tests/test_notebook_parsing.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nbsee
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from nbsee.__main__ import _as_text, _cell_input_text, _cell_output_text, _load_notebook
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_as_text_handles_none_and_lists():
|
|
9
|
+
assert _as_text(None) == ""
|
|
10
|
+
assert _as_text(["a", "b", 3]) == "ab3"
|
|
11
|
+
assert _as_text("x") == "x"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_cell_input_text_reads_source():
|
|
15
|
+
assert _cell_input_text({"source": "print(1)\n"}) == "print(1)\n"
|
|
16
|
+
assert _cell_input_text({"source": ["a\n", "b\n"]}) == "a\nb\n"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_cell_output_text_supports_text_plain_stream_and_error():
|
|
20
|
+
cell = {
|
|
21
|
+
"cell_type": "code",
|
|
22
|
+
"outputs": [
|
|
23
|
+
{"output_type": "execute_result", "data": {"text/plain": ["1", "\n"]}},
|
|
24
|
+
{"output_type": "stream", "name": "stdout", "text": ["hello", "\n"]},
|
|
25
|
+
{"output_type": "error", "traceback": ["Traceback...", "\n", "Boom"]},
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
assert _cell_output_text(cell) == "1\n\nhello\n\nTraceback...\nBoom"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_cell_output_text_ignores_non_code_cells():
|
|
32
|
+
assert (
|
|
33
|
+
_cell_output_text({"cell_type": "markdown", "outputs": [{"text": "x"}]}) == ""
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_load_notebook_requires_cells_list(tmp_path):
|
|
38
|
+
good = tmp_path / "good.ipynb"
|
|
39
|
+
good.write_text(json.dumps({"cells": [{"cell_type": "markdown", "source": "hi"}]}))
|
|
40
|
+
assert _load_notebook(str(good)) == [{"cell_type": "markdown", "source": "hi"}]
|
|
41
|
+
|
|
42
|
+
bad = tmp_path / "bad.ipynb"
|
|
43
|
+
bad.write_text(json.dumps({"metadata": {}}))
|
|
44
|
+
with pytest.raises(ValueError, match="missing cells"):
|
|
45
|
+
_load_notebook(str(bad))
|