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.
Files changed (36) hide show
  1. {nbcat-0.1.0 → nbcat-0.8.1}/Makefile +2 -1
  2. {nbcat-0.1.0 → nbcat-0.8.1}/PKG-INFO +3 -4
  3. {nbcat-0.1.0 → nbcat-0.8.1}/pyproject.toml +4 -5
  4. nbcat-0.8.1/src/nbcat/__init__.py +1 -0
  5. nbcat-0.8.1/src/nbcat/enums.py +16 -0
  6. nbcat-0.8.1/src/nbcat/exceptions.py +10 -0
  7. nbcat-0.8.1/src/nbcat/main.py +168 -0
  8. nbcat-0.8.1/src/nbcat/schemas.py +95 -0
  9. nbcat-0.8.1/tests/assets/invalid.ipynb +310 -0
  10. nbcat-0.8.1/tests/assets/many_tracebacks.ipynb +46 -0
  11. nbcat-0.8.1/tests/assets/no_min_version.ipynb +20 -0
  12. nbcat-0.8.1/tests/assets/test3.ipynb +150 -0
  13. nbcat-0.8.1/tests/assets/test3_no_metadata.ipynb +147 -0
  14. nbcat-0.8.1/tests/assets/test3_no_min_version.ipynb +12 -0
  15. nbcat-0.8.1/tests/assets/test3_no_worksheets.ipynb +7 -0
  16. nbcat-0.8.1/tests/assets/test3_worksheet_with_no_cells.ipynb +12 -0
  17. nbcat-0.8.1/tests/assets/test4.5.ipynb +168 -0
  18. nbcat-0.8.1/tests/assets/test4.ipynb +312 -0
  19. nbcat-0.8.1/tests/assets/test4custom.ipynb +47 -0
  20. nbcat-0.8.1/tests/assets/test4docinfo.ipynb +319 -0
  21. nbcat-0.8.1/tests/assets/test4jupyter_metadata.ipynb +30 -0
  22. nbcat-0.8.1/tests/assets/test4jupyter_metadata_timings.ipynb +61 -0
  23. nbcat-0.8.1/tests/test_read_notebook.py +66 -0
  24. nbcat-0.8.1/uv.lock +600 -0
  25. nbcat-0.1.0/src/nbcat/__init__.py +0 -1
  26. nbcat-0.1.0/src/nbcat/cli.py +0 -73
  27. nbcat-0.1.0/src/nbcat/nbcat.py +0 -22
  28. nbcat-0.1.0/src/nbcat/settings.py +0 -16
  29. nbcat-0.1.0/tests/test_nbcat.py +0 -36
  30. nbcat-0.1.0/uv.lock +0 -963
  31. {nbcat-0.1.0 → nbcat-0.8.1}/.github/workflows/ci.yml +0 -0
  32. {nbcat-0.1.0 → nbcat-0.8.1}/.gitignore +0 -0
  33. {nbcat-0.1.0 → nbcat-0.8.1}/LICENSE +0 -0
  34. {nbcat-0.1.0 → nbcat-0.8.1}/README.md +0 -0
  35. {nbcat-0.1.0 → nbcat-0.8.1}/src/nbcat/py.typed +0 -0
  36. {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 --fix
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.0
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.0"
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
- "aiohttp",
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-asyncio",
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.cli:main"
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