nbcat 0.1.0__tar.gz → 0.8.2__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 (37) hide show
  1. {nbcat-0.1.0 → nbcat-0.8.2}/Makefile +2 -1
  2. {nbcat-0.1.0 → nbcat-0.8.2}/PKG-INFO +3 -4
  3. {nbcat-0.1.0 → nbcat-0.8.2}/pyproject.toml +4 -5
  4. nbcat-0.8.2/src/nbcat/__init__.py +1 -0
  5. nbcat-0.8.2/src/nbcat/enums.py +16 -0
  6. nbcat-0.8.2/src/nbcat/exceptions.py +10 -0
  7. nbcat-0.8.2/src/nbcat/main.py +168 -0
  8. nbcat-0.8.2/src/nbcat/schemas.py +94 -0
  9. nbcat-0.8.2/tests/assets/invalid.ipynb +310 -0
  10. nbcat-0.8.2/tests/assets/many_tracebacks.ipynb +46 -0
  11. nbcat-0.8.2/tests/assets/no_min_version.ipynb +20 -0
  12. nbcat-0.8.2/tests/assets/test3.ipynb +150 -0
  13. nbcat-0.8.2/tests/assets/test3_no_metadata.ipynb +147 -0
  14. nbcat-0.8.2/tests/assets/test3_no_min_version.ipynb +12 -0
  15. nbcat-0.8.2/tests/assets/test3_no_worksheets.ipynb +7 -0
  16. nbcat-0.8.2/tests/assets/test3_worksheet_with_no_cells.ipynb +12 -0
  17. nbcat-0.8.2/tests/assets/test4.5.ipynb +168 -0
  18. nbcat-0.8.2/tests/assets/test4.ipynb +312 -0
  19. nbcat-0.8.2/tests/assets/test4custom.ipynb +47 -0
  20. nbcat-0.8.2/tests/assets/test4docinfo.ipynb +319 -0
  21. nbcat-0.8.2/tests/assets/test4jupyter_metadata.ipynb +30 -0
  22. nbcat-0.8.2/tests/assets/test4jupyter_metadata_timings.ipynb +61 -0
  23. nbcat-0.8.2/tests/test_read_notebook.py +66 -0
  24. nbcat-0.8.2/tests/test_render_cell.py +67 -0
  25. nbcat-0.8.2/uv.lock +600 -0
  26. nbcat-0.1.0/src/nbcat/__init__.py +0 -1
  27. nbcat-0.1.0/src/nbcat/cli.py +0 -73
  28. nbcat-0.1.0/src/nbcat/nbcat.py +0 -22
  29. nbcat-0.1.0/src/nbcat/settings.py +0 -16
  30. nbcat-0.1.0/tests/test_nbcat.py +0 -36
  31. nbcat-0.1.0/uv.lock +0 -963
  32. {nbcat-0.1.0 → nbcat-0.8.2}/.github/workflows/ci.yml +0 -0
  33. {nbcat-0.1.0 → nbcat-0.8.2}/.gitignore +0 -0
  34. {nbcat-0.1.0 → nbcat-0.8.2}/LICENSE +0 -0
  35. {nbcat-0.1.0 → nbcat-0.8.2}/README.md +0 -0
  36. {nbcat-0.1.0 → nbcat-0.8.2}/src/nbcat/py.typed +0 -0
  37. {nbcat-0.1.0 → nbcat-0.8.2}/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.2
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.2"
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.2"
@@ -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,94 @@
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
+ text: list[str] | str
16
+
17
+ @computed_field
18
+ @property
19
+ def output(self) -> str:
20
+ if isinstance(self.text, list):
21
+ return "".join(self.text)
22
+ return self.text
23
+
24
+
25
+ class DisplayDataOutput(BaseOutput):
26
+ data: dict[str, Any]
27
+
28
+ @computed_field
29
+ @property
30
+ def output(self) -> str:
31
+ # TODO: add support for rich display outputs
32
+ return ""
33
+
34
+
35
+ class ErrorOutput(BaseOutput):
36
+ ename: str
37
+ evalue: str
38
+ traceback: list[str]
39
+
40
+ @computed_field
41
+ @property
42
+ def output(self) -> str:
43
+ return "\n".join(self.traceback)
44
+
45
+
46
+ class PyoutDataOutput(BaseOutput):
47
+ text: list[str]
48
+
49
+ @computed_field
50
+ @property
51
+ def output(self) -> str:
52
+ return "\n".join(self.text)
53
+
54
+
55
+ class Cell(BaseModel):
56
+ cell_type: CellType
57
+ source: list[str] | str
58
+ level: int | None = None
59
+ execution_count: int | None = None
60
+ outputs: list[StreamOutput | DisplayDataOutput | ErrorOutput | PyoutDataOutput] = []
61
+
62
+ @model_validator(mode="before")
63
+ @classmethod
64
+ def handle_format_versions(cls, data: dict[str, Any]) -> dict[str, Any]:
65
+ if data.get("input"):
66
+ data["source"] = data["input"]
67
+ return data
68
+
69
+ @computed_field
70
+ @property
71
+ def input(self) -> str:
72
+ if self.cell_type == CellType.HEADING and self.level is not None:
73
+ return f"{'#' * self.level} {self.source}"
74
+
75
+ if isinstance(self.source, list):
76
+ return "".join(self.source)
77
+
78
+ return self.source
79
+
80
+
81
+ class Notebook(BaseModel):
82
+ cells: list[Cell] = []
83
+ nbformat: int
84
+
85
+ @model_validator(mode="before")
86
+ @classmethod
87
+ def handle_format_versions(cls, data: dict[str, Any]) -> dict[str, Any]:
88
+ if data.get("worksheets"):
89
+ try:
90
+ data["cells"] = data.get("worksheets")[0].get("cells", [])
91
+ except (KeyError, IndexError, TypeError) as e:
92
+ print(e)
93
+ raise InvalidNotebookFormatError(f"Invalid v3 notebook structure: {e}")
94
+ return data