nbcat 0.1.0__tar.gz → 0.8.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.
- {nbcat-0.1.0 → nbcat-0.8.1}/Makefile +2 -1
- {nbcat-0.1.0 → nbcat-0.8.1}/PKG-INFO +3 -4
- {nbcat-0.1.0 → nbcat-0.8.1}/pyproject.toml +4 -5
- nbcat-0.8.1/src/nbcat/__init__.py +1 -0
- nbcat-0.8.1/src/nbcat/enums.py +16 -0
- nbcat-0.8.1/src/nbcat/exceptions.py +10 -0
- nbcat-0.8.1/src/nbcat/main.py +168 -0
- nbcat-0.8.1/src/nbcat/schemas.py +95 -0
- nbcat-0.8.1/tests/assets/invalid.ipynb +310 -0
- nbcat-0.8.1/tests/assets/many_tracebacks.ipynb +46 -0
- nbcat-0.8.1/tests/assets/no_min_version.ipynb +20 -0
- nbcat-0.8.1/tests/assets/test3.ipynb +150 -0
- nbcat-0.8.1/tests/assets/test3_no_metadata.ipynb +147 -0
- nbcat-0.8.1/tests/assets/test3_no_min_version.ipynb +12 -0
- nbcat-0.8.1/tests/assets/test3_no_worksheets.ipynb +7 -0
- nbcat-0.8.1/tests/assets/test3_worksheet_with_no_cells.ipynb +12 -0
- nbcat-0.8.1/tests/assets/test4.5.ipynb +168 -0
- nbcat-0.8.1/tests/assets/test4.ipynb +312 -0
- nbcat-0.8.1/tests/assets/test4custom.ipynb +47 -0
- nbcat-0.8.1/tests/assets/test4docinfo.ipynb +319 -0
- nbcat-0.8.1/tests/assets/test4jupyter_metadata.ipynb +30 -0
- nbcat-0.8.1/tests/assets/test4jupyter_metadata_timings.ipynb +61 -0
- nbcat-0.8.1/tests/test_read_notebook.py +66 -0
- nbcat-0.8.1/uv.lock +600 -0
- nbcat-0.1.0/src/nbcat/__init__.py +0 -1
- nbcat-0.1.0/src/nbcat/cli.py +0 -73
- nbcat-0.1.0/src/nbcat/nbcat.py +0 -22
- nbcat-0.1.0/src/nbcat/settings.py +0 -16
- nbcat-0.1.0/tests/test_nbcat.py +0 -36
- nbcat-0.1.0/uv.lock +0 -963
- {nbcat-0.1.0 → nbcat-0.8.1}/.github/workflows/ci.yml +0 -0
- {nbcat-0.1.0 → nbcat-0.8.1}/.gitignore +0 -0
- {nbcat-0.1.0 → nbcat-0.8.1}/LICENSE +0 -0
- {nbcat-0.1.0 → nbcat-0.8.1}/README.md +0 -0
- {nbcat-0.1.0 → nbcat-0.8.1}/src/nbcat/py.typed +0 -0
- {nbcat-0.1.0 → nbcat-0.8.1}/tests/conftest.py +0 -0
@@ -33,9 +33,10 @@ deploy: build ## Publish package
|
|
33
33
|
$(UV) publish --token $(token)
|
34
34
|
|
35
35
|
lint: ## Run linter over code base and auto resole minor issues
|
36
|
-
$(RUFF) check
|
36
|
+
$(RUFF) check
|
37
37
|
|
38
38
|
format: ## Format the source code according to defined coding style
|
39
|
+
$(RUFF) check --fix-only
|
39
40
|
$(RUFF) format
|
40
41
|
|
41
42
|
clean: ## Remove all file artifacts
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: nbcat
|
3
|
-
Version: 0.1
|
3
|
+
Version: 0.8.1
|
4
4
|
Summary: cat for jupyter notebooks
|
5
5
|
Project-URL: Homepage, https://github.com/akopdev/nbcat
|
6
6
|
Project-URL: Repository, https://github.com/akopdev/nbcat
|
@@ -29,15 +29,14 @@ License: MIT License
|
|
29
29
|
SOFTWARE.
|
30
30
|
License-File: LICENSE
|
31
31
|
Requires-Python: >=3.10
|
32
|
-
Requires-Dist: aiohttp
|
33
32
|
Requires-Dist: pydantic
|
33
|
+
Requires-Dist: requests
|
34
34
|
Requires-Dist: rich
|
35
35
|
Provides-Extra: dev
|
36
|
-
Requires-Dist: aioresponses; extra == 'dev'
|
37
36
|
Requires-Dist: pytest; extra == 'dev'
|
38
|
-
Requires-Dist: pytest-asyncio; extra == 'dev'
|
39
37
|
Requires-Dist: pytest-cov; extra == 'dev'
|
40
38
|
Requires-Dist: pytest-mock; extra == 'dev'
|
39
|
+
Requires-Dist: pytest-responses; extra == 'dev'
|
41
40
|
Requires-Dist: ruff; extra == 'dev'
|
42
41
|
Description-Content-Type: text/markdown
|
43
42
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "nbcat"
|
3
|
-
version = "0.1
|
3
|
+
version = "0.8.1"
|
4
4
|
description = "cat for jupyter notebooks"
|
5
5
|
authors = [
|
6
6
|
{ name = "Akop Kesheshyan", email = "devnull@akop.dev" }
|
@@ -12,17 +12,16 @@ license = {file = "LICENSE"}
|
|
12
12
|
readme = "README.md"
|
13
13
|
requires-python = ">=3.10"
|
14
14
|
dependencies = [
|
15
|
-
"
|
15
|
+
"requests",
|
16
16
|
"pydantic",
|
17
17
|
"rich",
|
18
18
|
]
|
19
19
|
|
20
20
|
[project.optional-dependencies]
|
21
21
|
dev = [
|
22
|
-
"aioresponses",
|
23
22
|
"ruff",
|
24
23
|
"pytest",
|
25
|
-
"pytest-
|
24
|
+
"pytest-responses",
|
26
25
|
"pytest-mock",
|
27
26
|
"pytest-cov",
|
28
27
|
]
|
@@ -32,7 +31,7 @@ Homepage = "https://github.com/akopdev/nbcat"
|
|
32
31
|
Repository = "https://github.com/akopdev/nbcat"
|
33
32
|
|
34
33
|
[project.scripts]
|
35
|
-
nbcat = "nbcat.
|
34
|
+
nbcat = "nbcat.main:main"
|
36
35
|
|
37
36
|
[pytest]
|
38
37
|
mock_use_standalone_module = true
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.8.1"
|
@@ -0,0 +1,16 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
3
|
+
|
4
|
+
class CellType(str, Enum):
|
5
|
+
MARKDOWN = "markdown"
|
6
|
+
CODE = "code"
|
7
|
+
RAW = "raw"
|
8
|
+
HEADING = "heading"
|
9
|
+
|
10
|
+
|
11
|
+
class OutputType(str, Enum):
|
12
|
+
STREAM = "stream"
|
13
|
+
DISPLAY_DATA = "display_data"
|
14
|
+
EXECUTE_RESULT = "execute_result"
|
15
|
+
ERROR = "error"
|
16
|
+
PYOUT = "pyout"
|
@@ -0,0 +1,10 @@
|
|
1
|
+
class InvalidNotebookFormatError(Exception):
|
2
|
+
"""Raised when the file is not a valid .ipynb or URL."""
|
3
|
+
|
4
|
+
|
5
|
+
class NotebookNotFoundError(Exception):
|
6
|
+
"""Raised when the file or URL is not reachable."""
|
7
|
+
|
8
|
+
|
9
|
+
class UnsupportedNotebookTypeError(Exception):
|
10
|
+
"""Raised when the file exists but is not a .ipynb document."""
|
@@ -0,0 +1,168 @@
|
|
1
|
+
import argparse
|
2
|
+
import sys
|
3
|
+
from pathlib import Path
|
4
|
+
|
5
|
+
import requests
|
6
|
+
from pydantic import ValidationError
|
7
|
+
from rich import box
|
8
|
+
from rich.console import Console, RenderableType
|
9
|
+
from rich.markdown import Markdown
|
10
|
+
from rich.panel import Panel
|
11
|
+
from rich.syntax import Syntax
|
12
|
+
from rich.table import Table
|
13
|
+
from rich.text import Text
|
14
|
+
|
15
|
+
from . import __version__
|
16
|
+
from .enums import CellType
|
17
|
+
from .exceptions import (
|
18
|
+
InvalidNotebookFormatError,
|
19
|
+
NotebookNotFoundError,
|
20
|
+
UnsupportedNotebookTypeError,
|
21
|
+
)
|
22
|
+
from .schemas import Cell, Notebook
|
23
|
+
|
24
|
+
console = Console()
|
25
|
+
|
26
|
+
|
27
|
+
def read_notebook(fp: str) -> Notebook:
|
28
|
+
"""
|
29
|
+
Load and parse a Jupyter notebook from a local file or remote URL.
|
30
|
+
|
31
|
+
Args:
|
32
|
+
fp (str): Path to a local `.ipynb` file or a URL pointing to a notebook.
|
33
|
+
|
34
|
+
Returns
|
35
|
+
-------
|
36
|
+
Notebook: A validated Notebook instance parsed from JSON content.
|
37
|
+
|
38
|
+
Raises
|
39
|
+
------
|
40
|
+
NotebookNotFoundError: If the file path or URL is unreachable.
|
41
|
+
UnsupportedNotebookTypeError: If the file exists but isn't a `.ipynb` file.
|
42
|
+
InvalidNotebookFormatError: If the file content is invalid JSON or doesn't match schema.
|
43
|
+
"""
|
44
|
+
path = Path(fp)
|
45
|
+
if path.exists():
|
46
|
+
if path.suffix != ".ipynb":
|
47
|
+
raise UnsupportedNotebookTypeError(f"Unsupported file type: {path.suffix}")
|
48
|
+
content = path.read_text(encoding="utf-8")
|
49
|
+
elif fp.startswith("http://") or fp.startswith("https://"):
|
50
|
+
try:
|
51
|
+
with requests.Session() as req:
|
52
|
+
res = req.get(fp, timeout=5)
|
53
|
+
res.raise_for_status()
|
54
|
+
content = res.text
|
55
|
+
except requests.RequestException as e:
|
56
|
+
raise NotebookNotFoundError(f"Unable to fetch remote notebook: {e}")
|
57
|
+
else:
|
58
|
+
raise NotebookNotFoundError(f"Notebook not found: {fp}")
|
59
|
+
try:
|
60
|
+
return Notebook.model_validate_json(content)
|
61
|
+
except ValidationError as e:
|
62
|
+
raise InvalidNotebookFormatError(f"Invalid notebook: {e}")
|
63
|
+
|
64
|
+
|
65
|
+
def render_cell(cell: Cell) -> list[tuple[str | None, RenderableType]]:
|
66
|
+
"""
|
67
|
+
Render the content of a notebook cell for display.
|
68
|
+
|
69
|
+
Depending on the cell type, the function returns a formatted object
|
70
|
+
that can be rendered in a terminal using the `rich` library.
|
71
|
+
|
72
|
+
Args:
|
73
|
+
cell (Cell): The notebook cell containing source content and type metadata.
|
74
|
+
|
75
|
+
Returns
|
76
|
+
-------
|
77
|
+
Markdown | Panel | Text | None: A Rich renderable for Markdown, Code, or Raw cells.
|
78
|
+
Returns None if the cell type is unrecognized or unsupported.
|
79
|
+
"""
|
80
|
+
|
81
|
+
def _render_markdown(input: str) -> Markdown:
|
82
|
+
return Markdown(input)
|
83
|
+
|
84
|
+
def _render_code(input: str) -> Panel:
|
85
|
+
return Panel(Syntax(input, "python", line_numbers=True, theme="ansi_dark"), box=box.SQUARE)
|
86
|
+
|
87
|
+
def _render_raw(input: str) -> Text:
|
88
|
+
return Text(input)
|
89
|
+
|
90
|
+
def _render_heading(input: str) -> Text:
|
91
|
+
return Text(input)
|
92
|
+
|
93
|
+
RENDERERS = {
|
94
|
+
CellType.MARKDOWN: _render_markdown,
|
95
|
+
CellType.CODE: _render_code,
|
96
|
+
CellType.RAW: _render_raw,
|
97
|
+
CellType.HEADING: _render_heading,
|
98
|
+
}
|
99
|
+
|
100
|
+
rows: list[tuple[str | None, RenderableType]] = []
|
101
|
+
renderer = RENDERERS.get(cell.cell_type)
|
102
|
+
source = renderer(cell.input) if renderer else None
|
103
|
+
if source:
|
104
|
+
label = f"[green][{cell.execution_count}][/]:" if cell.execution_count else None
|
105
|
+
rows.append(
|
106
|
+
(
|
107
|
+
label,
|
108
|
+
source,
|
109
|
+
)
|
110
|
+
)
|
111
|
+
|
112
|
+
for o in cell.outputs:
|
113
|
+
if o.output:
|
114
|
+
label = f"[blue][{o.execution_count}][/]:" if o.execution_count else None
|
115
|
+
rows.append(
|
116
|
+
(
|
117
|
+
label,
|
118
|
+
o.output,
|
119
|
+
)
|
120
|
+
)
|
121
|
+
return rows
|
122
|
+
|
123
|
+
|
124
|
+
def print_notebook(nb: Notebook):
|
125
|
+
"""
|
126
|
+
Print the notebook to the console with formatted cell inputs and outputs.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
nb (Notebook): A Notebook object containing a list of cells.
|
130
|
+
"""
|
131
|
+
if not nb.cells:
|
132
|
+
console.print("[bold red]Notebook contains no cells.")
|
133
|
+
return
|
134
|
+
|
135
|
+
layout = Table.grid(padding=1)
|
136
|
+
layout.add_column(no_wrap=True, width=6)
|
137
|
+
layout.add_column()
|
138
|
+
|
139
|
+
for cell in nb.cells:
|
140
|
+
for label, content in render_cell(cell):
|
141
|
+
layout.add_row(label, content)
|
142
|
+
|
143
|
+
console.print(layout)
|
144
|
+
|
145
|
+
|
146
|
+
def main():
|
147
|
+
parser = argparse.ArgumentParser(
|
148
|
+
description="cat for Jupyter Notebooks",
|
149
|
+
argument_default=argparse.SUPPRESS,
|
150
|
+
)
|
151
|
+
parser.add_argument("file", help="Path or URL to a .ipynb notebook", type=str)
|
152
|
+
parser.add_argument(
|
153
|
+
"--version",
|
154
|
+
help="print version information and quite",
|
155
|
+
action="version",
|
156
|
+
version=__version__,
|
157
|
+
)
|
158
|
+
|
159
|
+
try:
|
160
|
+
args = parser.parse_args()
|
161
|
+
notebook = read_notebook(args.file)
|
162
|
+
print_notebook(notebook)
|
163
|
+
except Exception as e:
|
164
|
+
sys.exit(f"nbcat: {e}")
|
165
|
+
|
166
|
+
|
167
|
+
if __name__ == "__main__":
|
168
|
+
main()
|
@@ -0,0 +1,95 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from pydantic import BaseModel, computed_field, model_validator
|
4
|
+
|
5
|
+
from .enums import CellType, OutputType
|
6
|
+
from .exceptions import InvalidNotebookFormatError
|
7
|
+
|
8
|
+
|
9
|
+
class BaseOutput(BaseModel):
|
10
|
+
output_type: OutputType
|
11
|
+
execution_count: int | None = None
|
12
|
+
|
13
|
+
|
14
|
+
class StreamOutput(BaseOutput):
|
15
|
+
name: str
|
16
|
+
text: list[str] | str
|
17
|
+
|
18
|
+
@computed_field
|
19
|
+
@property
|
20
|
+
def output(self) -> str:
|
21
|
+
if isinstance(self.text, list):
|
22
|
+
return "".join(self.text)
|
23
|
+
return self.text
|
24
|
+
|
25
|
+
|
26
|
+
class DisplayDataOutput(BaseOutput):
|
27
|
+
data: dict[str, Any]
|
28
|
+
|
29
|
+
@computed_field
|
30
|
+
@property
|
31
|
+
def output(self) -> str:
|
32
|
+
# TODO: add support for rich display outputs
|
33
|
+
return ""
|
34
|
+
|
35
|
+
|
36
|
+
class ErrorOutput(BaseOutput):
|
37
|
+
ename: str
|
38
|
+
evalue: str
|
39
|
+
traceback: list[str]
|
40
|
+
|
41
|
+
@computed_field
|
42
|
+
@property
|
43
|
+
def output(self) -> str:
|
44
|
+
return "\n".join(self.traceback)
|
45
|
+
|
46
|
+
|
47
|
+
class PyoutDataOutput(BaseOutput):
|
48
|
+
text: list[str]
|
49
|
+
|
50
|
+
@computed_field
|
51
|
+
@property
|
52
|
+
def output(self) -> str:
|
53
|
+
return "\n".join(self.text)
|
54
|
+
|
55
|
+
|
56
|
+
class Cell(BaseModel):
|
57
|
+
cell_type: CellType
|
58
|
+
source: list[str] | str
|
59
|
+
level: int | None = None
|
60
|
+
execution_count: int | None = None
|
61
|
+
outputs: list[StreamOutput | DisplayDataOutput | ErrorOutput | PyoutDataOutput] = []
|
62
|
+
|
63
|
+
@model_validator(mode="before")
|
64
|
+
@classmethod
|
65
|
+
def handle_format_versions(cls, data: dict[str, Any]) -> dict[str, Any]:
|
66
|
+
if data.get("input"):
|
67
|
+
data["source"] = data["input"]
|
68
|
+
return data
|
69
|
+
|
70
|
+
@computed_field
|
71
|
+
@property
|
72
|
+
def input(self) -> str:
|
73
|
+
if self.cell_type == CellType.HEADING and self.level is not None:
|
74
|
+
return f"{'#' * self.level} {self.source}"
|
75
|
+
|
76
|
+
if isinstance(self.source, list):
|
77
|
+
return "".join(self.source)
|
78
|
+
|
79
|
+
return self.source
|
80
|
+
|
81
|
+
|
82
|
+
class Notebook(BaseModel):
|
83
|
+
cells: list[Cell] = []
|
84
|
+
nbformat: int
|
85
|
+
|
86
|
+
@model_validator(mode="before")
|
87
|
+
@classmethod
|
88
|
+
def handle_format_versions(cls, data: dict[str, Any]) -> dict[str, Any]:
|
89
|
+
if data.get("worksheets"):
|
90
|
+
try:
|
91
|
+
data["cells"] = data.get("worksheets")[0].get("cells", [])
|
92
|
+
except (KeyError, IndexError, TypeError) as e:
|
93
|
+
print(e)
|
94
|
+
raise InvalidNotebookFormatError(f"Invalid v3 notebook structure: {e}")
|
95
|
+
return data
|