nbdantic 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.
- nbdantic-0.1.0/PKG-INFO +109 -0
- nbdantic-0.1.0/README.md +94 -0
- nbdantic-0.1.0/pyproject.toml +51 -0
- nbdantic-0.1.0/src/nbdantic/__init__.py +35 -0
- nbdantic-0.1.0/src/nbdantic/grammar.py +194 -0
- nbdantic-0.1.0/src/nbdantic/logger.py +181 -0
- nbdantic-0.1.0/src/nbdantic/nbrs.pyi +27 -0
- nbdantic-0.1.0/src/nbdantic/parser.py +471 -0
- nbdantic-0.1.0/src/nbdantic/py.typed +0 -0
- nbdantic-0.1.0/src/nbdantic/script_runner.py +346 -0
- nbdantic-0.1.0/src/nbdantic/types.py +49 -0
- nbdantic-0.1.0/src/nbdantic/utils.py +45 -0
- nbdantic-0.1.0/src/nbdantic/validators.py +171 -0
- nbdantic-0.1.0/src-rs/.gitignore +72 -0
- nbdantic-0.1.0/src-rs/Cargo.lock +1855 -0
- nbdantic-0.1.0/src-rs/Cargo.toml +13 -0
- nbdantic-0.1.0/src-rs/nbrs.pyi +27 -0
- nbdantic-0.1.0/src-rs/src/lib.rs +137 -0
nbdantic-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nbdantic
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Dist: ansiwrap>=0.8.4
|
|
5
|
+
Requires-Dist: nbformat>=5.10.4
|
|
6
|
+
Requires-Dist: tomlkit>=0.13.0
|
|
7
|
+
Summary: notebook parsing minimum DSL
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
10
|
+
Project-URL: Documentation, https://github.com/ivanbelenky/nbdantic#readme
|
|
11
|
+
Project-URL: Homepage, https://github.com/ivanbelenky/nbdantic
|
|
12
|
+
Project-URL: Issues, https://github.com/ivanbelenky/nbdantic/issues
|
|
13
|
+
Project-URL: Repository, https://github.com/ivanbelenky/nbdantic
|
|
14
|
+
|
|
15
|
+
# nbdantic ◬
|
|
16
|
+
|
|
17
|
+
fully typed context-free grammar for jupyter notebooks. declare expected structure, validate against it.
|
|
18
|
+
|
|
19
|
+
## install
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
uv add nbdantic
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## grammar
|
|
26
|
+
|
|
27
|
+
| production | rule | note |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Notebook | Cell\* Sequence Cell_terminal? | |
|
|
30
|
+
| Sequence | (Element …)+ \| ε | `empty=True` allows ε |
|
|
31
|
+
| Element | Markdown \| Code \| Sequence \| Choice \| Maybe | |
|
|
32
|
+
| Markdown | cell\<markdown\> | terminal |
|
|
33
|
+
| Code | cell\<code\> | terminal |
|
|
34
|
+
| Choice | Element \| Element \| … | first match wins |
|
|
35
|
+
| Maybe | Element \| ε | |
|
|
36
|
+
|
|
37
|
+
### grammar elements
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
|
|
41
|
+
type ParseElement = Markdown | Code | Sequence | Choice | Maybe
|
|
42
|
+
|
|
43
|
+
# Markdown and Code are self explanatory
|
|
44
|
+
# Sequence is None one or more elements (enabling nesting of structures/sequences)
|
|
45
|
+
# Choice lets you pick from a set of Elements (it could be arbitrary sets of nested pattenrs)
|
|
46
|
+
# Maybe allows for expressing Optiona<Element>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
### usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import ast # for custom validation function
|
|
54
|
+
|
|
55
|
+
from nbdantic import Code, Markdown, Maybe, Sequence, JupyterNotebook
|
|
56
|
+
from nbdantic.validators import valid_python, not_empty, at_least
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def only_imports(c):
|
|
60
|
+
tree = ast.parse(c)
|
|
61
|
+
if not all(
|
|
62
|
+
isinstance(n, (ast.Import, ast.ImportFrom, ast.alias, ast.Module)) for n in ast.walk(tree)
|
|
63
|
+
):
|
|
64
|
+
raise ValueError("code should only contain import statements")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Paper(JupyterNotebook):
|
|
68
|
+
structure = Sequence("root", [
|
|
69
|
+
Markdown("title", validators=[not_empty]),
|
|
70
|
+
Code("imports", validators=[valid_python, only_imports]),
|
|
71
|
+
Maybe("abstract", Markdown("abstract_md")),
|
|
72
|
+
Sequence("sections", [
|
|
73
|
+
Markdown("heading"),
|
|
74
|
+
Code("body", validators=[valid_python]),
|
|
75
|
+
], validators=[at_least(2)], empty=True),
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
result = Paper.from_file("paper.ipynb").validate()
|
|
79
|
+
result.raise_if_failed()
|
|
80
|
+
result.cells_map
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
### extra features
|
|
85
|
+
|
|
86
|
+
#### built-in validators
|
|
87
|
+
|
|
88
|
+
`nbdantic.validators` has some common checks, some of them are simple and example like, some of them may prove useful, like
|
|
89
|
+
- `ty_check`
|
|
90
|
+
- `ruff_check`
|
|
91
|
+
- `line_warning`
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
#### script runner
|
|
95
|
+
|
|
96
|
+
`nbdantic.script_runner.UVScriptRunner` spins up isolated uv environments for running code, linting, type checking, whatever you need. validators like `ruff_check` and `ty_check` use it under the hood.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from nbdantic.script_runner import UVScriptRunner
|
|
100
|
+
|
|
101
|
+
with UVScriptRunner(packages=["numpy>=1.24"], python_version="3.13") as runner:
|
|
102
|
+
result = runner.oneshot_python("import numpy; print(numpy.__version__)")
|
|
103
|
+
print(result.stdout)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
### notes
|
|
108
|
+
pep-508/440 parsing backed by [uv-pep508 & uv-pep440 crates](https://github.com/astral-sh/uv) via pyo3. Thanks uv for everything.
|
|
109
|
+
|
nbdantic-0.1.0/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# nbdantic ◬
|
|
2
|
+
|
|
3
|
+
fully typed context-free grammar for jupyter notebooks. declare expected structure, validate against it.
|
|
4
|
+
|
|
5
|
+
## install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
uv add nbdantic
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## grammar
|
|
12
|
+
|
|
13
|
+
| production | rule | note |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| Notebook | Cell\* Sequence Cell_terminal? | |
|
|
16
|
+
| Sequence | (Element …)+ \| ε | `empty=True` allows ε |
|
|
17
|
+
| Element | Markdown \| Code \| Sequence \| Choice \| Maybe | |
|
|
18
|
+
| Markdown | cell\<markdown\> | terminal |
|
|
19
|
+
| Code | cell\<code\> | terminal |
|
|
20
|
+
| Choice | Element \| Element \| … | first match wins |
|
|
21
|
+
| Maybe | Element \| ε | |
|
|
22
|
+
|
|
23
|
+
### grammar elements
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
|
|
27
|
+
type ParseElement = Markdown | Code | Sequence | Choice | Maybe
|
|
28
|
+
|
|
29
|
+
# Markdown and Code are self explanatory
|
|
30
|
+
# Sequence is None one or more elements (enabling nesting of structures/sequences)
|
|
31
|
+
# Choice lets you pick from a set of Elements (it could be arbitrary sets of nested pattenrs)
|
|
32
|
+
# Maybe allows for expressing Optiona<Element>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import ast # for custom validation function
|
|
40
|
+
|
|
41
|
+
from nbdantic import Code, Markdown, Maybe, Sequence, JupyterNotebook
|
|
42
|
+
from nbdantic.validators import valid_python, not_empty, at_least
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def only_imports(c):
|
|
46
|
+
tree = ast.parse(c)
|
|
47
|
+
if not all(
|
|
48
|
+
isinstance(n, (ast.Import, ast.ImportFrom, ast.alias, ast.Module)) for n in ast.walk(tree)
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("code should only contain import statements")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Paper(JupyterNotebook):
|
|
54
|
+
structure = Sequence("root", [
|
|
55
|
+
Markdown("title", validators=[not_empty]),
|
|
56
|
+
Code("imports", validators=[valid_python, only_imports]),
|
|
57
|
+
Maybe("abstract", Markdown("abstract_md")),
|
|
58
|
+
Sequence("sections", [
|
|
59
|
+
Markdown("heading"),
|
|
60
|
+
Code("body", validators=[valid_python]),
|
|
61
|
+
], validators=[at_least(2)], empty=True),
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
result = Paper.from_file("paper.ipynb").validate()
|
|
65
|
+
result.raise_if_failed()
|
|
66
|
+
result.cells_map
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
### extra features
|
|
71
|
+
|
|
72
|
+
#### built-in validators
|
|
73
|
+
|
|
74
|
+
`nbdantic.validators` has some common checks, some of them are simple and example like, some of them may prove useful, like
|
|
75
|
+
- `ty_check`
|
|
76
|
+
- `ruff_check`
|
|
77
|
+
- `line_warning`
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#### script runner
|
|
81
|
+
|
|
82
|
+
`nbdantic.script_runner.UVScriptRunner` spins up isolated uv environments for running code, linting, type checking, whatever you need. validators like `ruff_check` and `ty_check` use it under the hood.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from nbdantic.script_runner import UVScriptRunner
|
|
86
|
+
|
|
87
|
+
with UVScriptRunner(packages=["numpy>=1.24"], python_version="3.13") as runner:
|
|
88
|
+
result = runner.oneshot_python("import numpy; print(numpy.__version__)")
|
|
89
|
+
print(result.stdout)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
### notes
|
|
94
|
+
pep-508/440 parsing backed by [uv-pep508 & uv-pep440 crates](https://github.com/astral-sh/uv) via pyo3. Thanks uv for everything.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.0,<2.0"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nbdantic"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "notebook parsing minimum DSL"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"ansiwrap>=0.8.4",
|
|
13
|
+
"nbformat>=5.10.4",
|
|
14
|
+
"tomlkit>=0.13.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/ivanbelenky/nbdantic"
|
|
19
|
+
Repository = "https://github.com/ivanbelenky/nbdantic"
|
|
20
|
+
Issues = "https://github.com/ivanbelenky/nbdantic/issues"
|
|
21
|
+
Documentation = "https://github.com/ivanbelenky/nbdantic#readme"
|
|
22
|
+
|
|
23
|
+
[tool.maturin]
|
|
24
|
+
manifest-path = "src-rs/Cargo.toml"
|
|
25
|
+
python-source = "src"
|
|
26
|
+
module-name = "nbdantic.nbrs"
|
|
27
|
+
features = ["pyo3/extension-module"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
indent-width = 4
|
|
31
|
+
line-length = 120
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = ["E", "F", "A"]
|
|
35
|
+
ignore = ["E501"]
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"ipython>=8.31.0",
|
|
40
|
+
"coverage>=7.6.12",
|
|
41
|
+
"maturin>=1.0,<2.0",
|
|
42
|
+
"pre-commit>=4.0.1",
|
|
43
|
+
"ipykernel>=6.30.1",
|
|
44
|
+
"pyright>=1.1.407",
|
|
45
|
+
"ty>=0.0.1a7",
|
|
46
|
+
"pytest>=9.0.2",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
norecursedirs = ["scratch"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .grammar import (
|
|
2
|
+
Cell,
|
|
3
|
+
Choice,
|
|
4
|
+
Code,
|
|
5
|
+
Markdown,
|
|
6
|
+
Maybe,
|
|
7
|
+
Sequence,
|
|
8
|
+
)
|
|
9
|
+
from .logger import set_nocolor
|
|
10
|
+
from .parser import (
|
|
11
|
+
JupyterNotebook,
|
|
12
|
+
ValidationFailure,
|
|
13
|
+
ValidationResult,
|
|
14
|
+
)
|
|
15
|
+
from .types import (
|
|
16
|
+
CellNode,
|
|
17
|
+
CellsMap,
|
|
18
|
+
ChoiceResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Cell",
|
|
23
|
+
"CellNode",
|
|
24
|
+
"CellsMap",
|
|
25
|
+
"Choice",
|
|
26
|
+
"ChoiceResult",
|
|
27
|
+
"Code",
|
|
28
|
+
"JupyterNotebook",
|
|
29
|
+
"Markdown",
|
|
30
|
+
"Maybe",
|
|
31
|
+
"Sequence",
|
|
32
|
+
"ValidationFailure",
|
|
33
|
+
"ValidationResult",
|
|
34
|
+
"set_nocolor",
|
|
35
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from textwrap import dedent
|
|
6
|
+
from typing import Any, Callable, ClassVar, Final, Iterator, cast
|
|
7
|
+
|
|
8
|
+
from .logger import cstr
|
|
9
|
+
from .types import CellContent, CellValidator, Label, SequenceValidator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validator_name(v: Callable[..., Any]) -> str:
|
|
13
|
+
return getattr(v, "__name__", type(v).__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_cell_validator(v: CellValidator) -> None:
|
|
17
|
+
try:
|
|
18
|
+
sig = inspect.signature(v)
|
|
19
|
+
except (ValueError, TypeError):
|
|
20
|
+
return
|
|
21
|
+
params = [
|
|
22
|
+
p
|
|
23
|
+
for p in sig.parameters.values()
|
|
24
|
+
if p.default is inspect.Parameter.empty
|
|
25
|
+
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
26
|
+
]
|
|
27
|
+
if len(params) != 1:
|
|
28
|
+
raise TypeError(
|
|
29
|
+
f"CellValidator '{_validator_name(v)}' must accept exactly 1 positional "
|
|
30
|
+
f"parameter (CellContent), got {len(params)}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_sequence_validator(v: SequenceValidator) -> None:
|
|
35
|
+
try:
|
|
36
|
+
sig = inspect.signature(v)
|
|
37
|
+
except (ValueError, TypeError):
|
|
38
|
+
return
|
|
39
|
+
params = [
|
|
40
|
+
p
|
|
41
|
+
for p in sig.parameters.values()
|
|
42
|
+
if p.default is inspect.Parameter.empty
|
|
43
|
+
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
44
|
+
]
|
|
45
|
+
if len(params) != 1:
|
|
46
|
+
raise TypeError(
|
|
47
|
+
f"SequenceValidator '{_validator_name(v)}' must accept exactly 1 positional "
|
|
48
|
+
f"parameter (SequenceContent), got {len(params)}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CellValidationError(Exception):
|
|
53
|
+
def __init__(self, message: str, validator_name: str | None = None) -> None:
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
self.validator_name = validator_name
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SequenceError(Exception):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Cell:
|
|
63
|
+
cell_type: ClassVar[str]
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
label: Label,
|
|
68
|
+
validators: list[CellValidator] | None = None,
|
|
69
|
+
source: CellContent = "",
|
|
70
|
+
) -> None:
|
|
71
|
+
self.label: Final[Label] = label
|
|
72
|
+
self._source = source
|
|
73
|
+
self.validators: list[CellValidator] = []
|
|
74
|
+
for v in validators or []:
|
|
75
|
+
_check_cell_validator(v)
|
|
76
|
+
self.validators.append(v)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def content(self) -> CellContent:
|
|
80
|
+
return self._source
|
|
81
|
+
|
|
82
|
+
def validate(self, x: CellContent) -> None:
|
|
83
|
+
for v in self.validators:
|
|
84
|
+
try:
|
|
85
|
+
v(deepcopy(x))
|
|
86
|
+
except Exception as e:
|
|
87
|
+
doc = f"({v.__doc__})" if v.__doc__ else ""
|
|
88
|
+
raise CellValidationError(
|
|
89
|
+
dedent(f"""
|
|
90
|
+
validation failed for cell '{cstr("yellow", self.label, True)}':\n
|
|
91
|
+
{cstr("magenta", _validator_name(v), True)} {doc} - {e!s}
|
|
92
|
+
"""),
|
|
93
|
+
validator_name=_validator_name(v),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __or__(self, other: Choice | Sequence | Cell | Maybe) -> Choice:
|
|
97
|
+
if isinstance(other, Choice):
|
|
98
|
+
return Choice(
|
|
99
|
+
label=f"{self.label}_or_{'_or_'.join(o.label for o in other.options)}",
|
|
100
|
+
options=cast(list, [self] + other.options),
|
|
101
|
+
)
|
|
102
|
+
return Choice(
|
|
103
|
+
label=f"{self.label}_or_{other.label}",
|
|
104
|
+
options=cast(list, [self, other]),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Markdown(Cell):
|
|
109
|
+
cell_type = "markdown"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Code(Cell):
|
|
113
|
+
cell_type = "code"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Choice:
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
label: Label,
|
|
120
|
+
options: list[Markdown | Code | Sequence | Choice | Maybe],
|
|
121
|
+
) -> None:
|
|
122
|
+
self.label: Final[Label] = label
|
|
123
|
+
self.options: list[Markdown | Code | Sequence | Choice | Maybe] = []
|
|
124
|
+
for opt in options:
|
|
125
|
+
if isinstance(opt, Choice):
|
|
126
|
+
self.options.extend(opt.options)
|
|
127
|
+
else:
|
|
128
|
+
self.options.append(opt)
|
|
129
|
+
|
|
130
|
+
def __or__(self, other: Choice | Sequence | Cell | Maybe) -> Choice:
|
|
131
|
+
if isinstance(other, Choice):
|
|
132
|
+
return Choice(
|
|
133
|
+
label=f"{self.label}_or_{other.label}",
|
|
134
|
+
options=self.options + other.options,
|
|
135
|
+
)
|
|
136
|
+
return Choice(
|
|
137
|
+
label=f"{self.label}_or_{other.label}",
|
|
138
|
+
options=cast(list, self.options + [other]),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Maybe:
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
label: Label,
|
|
146
|
+
element: Markdown | Code | Sequence | Choice,
|
|
147
|
+
) -> None:
|
|
148
|
+
self.label: Final[Label] = label
|
|
149
|
+
self.element: Final[Markdown | Code | Sequence | Choice] = element
|
|
150
|
+
|
|
151
|
+
def __or__(self, other: Choice | Sequence | Cell | Maybe) -> Choice:
|
|
152
|
+
if isinstance(other, Choice):
|
|
153
|
+
return Choice(
|
|
154
|
+
label=f"{self.label}_or_{'_or_'.join(o.label for o in other.options)}",
|
|
155
|
+
options=cast(list, [self] + other.options),
|
|
156
|
+
)
|
|
157
|
+
return Choice(
|
|
158
|
+
label=f"{self.label}_or_{other.label}",
|
|
159
|
+
options=cast(list, [self, other]),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Sequence:
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
label: Label,
|
|
167
|
+
structure: list[Markdown | Code | Sequence | Choice | Maybe],
|
|
168
|
+
validators: list[SequenceValidator] | None = None,
|
|
169
|
+
empty: bool = False,
|
|
170
|
+
) -> None:
|
|
171
|
+
self.label: Final[Label] = label
|
|
172
|
+
self.empty: Final[bool] = empty
|
|
173
|
+
self.structure: Final[list[Markdown | Code | Sequence | Choice | Maybe]] = structure
|
|
174
|
+
self.validators: list[SequenceValidator] = []
|
|
175
|
+
for v in validators or []:
|
|
176
|
+
_check_sequence_validator(v)
|
|
177
|
+
self.validators.append(v)
|
|
178
|
+
|
|
179
|
+
def __iter__(self) -> Iterator[Markdown | Code | Sequence | Choice | Maybe]:
|
|
180
|
+
yield from self.structure
|
|
181
|
+
|
|
182
|
+
def __or__(self, other: Choice | Sequence | Cell | Maybe) -> Choice:
|
|
183
|
+
if isinstance(other, Choice):
|
|
184
|
+
return Choice(
|
|
185
|
+
label=f"{self.label}_or_{'_or_'.join(o.label for o in other.options)}",
|
|
186
|
+
options=cast(list, [self] + other.options),
|
|
187
|
+
)
|
|
188
|
+
return Choice(
|
|
189
|
+
label=f"{self.label}_or_{other.label}",
|
|
190
|
+
options=cast(list, [self, other]),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
type ParseElement = Markdown | Code | Sequence | Choice | Maybe
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
|
|
10
|
+
def _find_module(module: str) -> list[str]:
|
|
11
|
+
spec = importlib.util.find_spec(module)
|
|
12
|
+
return [spec.name] if spec else []
|
|
13
|
+
|
|
14
|
+
def _load_module(_: str, module: str) -> object:
|
|
15
|
+
return importlib.import_module(module)
|
|
16
|
+
|
|
17
|
+
setattr(importlib, "find_module", _find_module)
|
|
18
|
+
setattr(importlib, "load_module", _load_module)
|
|
19
|
+
sys.modules["imp"] = importlib
|
|
20
|
+
|
|
21
|
+
from ansiwrap import wrap as _wrap
|
|
22
|
+
except ImportError:
|
|
23
|
+
from textwrap import wrap as _wrap
|
|
24
|
+
|
|
25
|
+
STATUS_LEN = 88
|
|
26
|
+
|
|
27
|
+
_ANSI: dict[str, str] = {
|
|
28
|
+
"red": "31",
|
|
29
|
+
"green": "32",
|
|
30
|
+
"yellow": "33",
|
|
31
|
+
"blue": "34",
|
|
32
|
+
"magenta": "35",
|
|
33
|
+
"cyan": "36",
|
|
34
|
+
"white": "37",
|
|
35
|
+
"black": "30",
|
|
36
|
+
"orange": "38;5;208",
|
|
37
|
+
"purple": "38;5;141",
|
|
38
|
+
"pink": "38;5;217",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_STATUS_COLORS: dict[str, str] = {
|
|
42
|
+
"STARTED": "yellow",
|
|
43
|
+
"PASSED": "green",
|
|
44
|
+
"FAILED": "red",
|
|
45
|
+
"WARNING": "orange",
|
|
46
|
+
"INFO": "purple",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_nocolor: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_nocolor(enabled: bool = True) -> None:
|
|
53
|
+
global _nocolor
|
|
54
|
+
_nocolor = enabled
|
|
55
|
+
_handler.setFormatter(PlainFormatter() if enabled else StatusFormatter())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LogStatus(Enum):
|
|
59
|
+
STARTED = "STARTED"
|
|
60
|
+
PASSED = "PASSED"
|
|
61
|
+
FAILED = "FAILED"
|
|
62
|
+
WARNING = "WARNING"
|
|
63
|
+
INFO = "INFO"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# NOTE: inspired always by geohotz tinygrad/helpers.py `colored()``
|
|
67
|
+
def cstr(color: str | None, text: object, bold: bool = False) -> str:
|
|
68
|
+
if _nocolor or color is None or color not in _ANSI:
|
|
69
|
+
return str(text)
|
|
70
|
+
b = "1;" if bold else ""
|
|
71
|
+
return f"\033[{b}{_ANSI[color]}m{text}\033[0m"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def ansistrip(s: str) -> str:
|
|
75
|
+
return re.sub(r"\x1b\[(K|.*?m)", "", s)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ansilen(s: str) -> int:
|
|
79
|
+
return len(ansistrip(s))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def word_wrap(s: str, width: int = 80) -> str:
|
|
83
|
+
if ansilen(s) <= width:
|
|
84
|
+
return s
|
|
85
|
+
lines = s.splitlines()
|
|
86
|
+
if len(lines) > 1:
|
|
87
|
+
return "\n".join(word_wrap(line, width) for line in lines)
|
|
88
|
+
i = 0
|
|
89
|
+
while ansilen(s[:i]) < width and i < len(s):
|
|
90
|
+
i += 1
|
|
91
|
+
return s[:i] + "\n" + word_wrap(s[i:], width)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PlainFormatter(logging.Formatter):
|
|
95
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
96
|
+
status: LogStatus | None = getattr(record, "status", None)
|
|
97
|
+
if status is None:
|
|
98
|
+
return super().format(record)
|
|
99
|
+
|
|
100
|
+
msg = record.getMessage()
|
|
101
|
+
lines = msg.split("\n")
|
|
102
|
+
all_chunks: list[str] = []
|
|
103
|
+
for line in lines:
|
|
104
|
+
chunks = _wrap(line, width=STATUS_LEN - 1, break_long_words=False) if line.strip() else [""]
|
|
105
|
+
all_chunks.extend(chunks)
|
|
106
|
+
|
|
107
|
+
parts: list[str] = []
|
|
108
|
+
if status == LogStatus.WARNING:
|
|
109
|
+
parts.append(f"{'-' * (STATUS_LEN - 1)} [{status.value}]")
|
|
110
|
+
|
|
111
|
+
first_line = f"{all_chunks[0]:<{STATUS_LEN - 1}}"
|
|
112
|
+
if status == LogStatus.WARNING:
|
|
113
|
+
parts.append(first_line)
|
|
114
|
+
else:
|
|
115
|
+
parts.append(f"{first_line} [{status.value}]")
|
|
116
|
+
|
|
117
|
+
for chunk in all_chunks[1:]:
|
|
118
|
+
parts.append(chunk)
|
|
119
|
+
|
|
120
|
+
if status == LogStatus.WARNING:
|
|
121
|
+
parts.append("-" * (STATUS_LEN - 1))
|
|
122
|
+
|
|
123
|
+
return "\n".join(parts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class StatusFormatter(logging.Formatter):
|
|
127
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
128
|
+
status: LogStatus | None = getattr(record, "status", None)
|
|
129
|
+
nocolor: bool = getattr(record, "nocolor", False)
|
|
130
|
+
|
|
131
|
+
if status is None:
|
|
132
|
+
return super().format(record)
|
|
133
|
+
|
|
134
|
+
msg = record.getMessage()
|
|
135
|
+
color = _STATUS_COLORS.get(status.value)
|
|
136
|
+
status_tag = cstr(color, f" [{status.value}]", bold=True)
|
|
137
|
+
|
|
138
|
+
lines = msg.split("\n")
|
|
139
|
+
all_chunks: list[str] = []
|
|
140
|
+
for line in lines:
|
|
141
|
+
chunks = _wrap(line, width=STATUS_LEN - 1, break_long_words=False) if line.strip() else [""]
|
|
142
|
+
all_chunks.extend(chunks)
|
|
143
|
+
|
|
144
|
+
text_color = "orange" if status == LogStatus.WARNING and not nocolor else None
|
|
145
|
+
parts: list[str] = []
|
|
146
|
+
|
|
147
|
+
if status == LogStatus.WARNING:
|
|
148
|
+
parts.append(f"{'-' * (STATUS_LEN - 1)}{status_tag}")
|
|
149
|
+
|
|
150
|
+
visible_len = ansilen(all_chunks[0])
|
|
151
|
+
pad = max(STATUS_LEN - 1 - visible_len, 0)
|
|
152
|
+
first_line = all_chunks[0] + " " * pad
|
|
153
|
+
if status == LogStatus.WARNING:
|
|
154
|
+
parts.append(cstr(text_color, first_line))
|
|
155
|
+
else:
|
|
156
|
+
parts.append(cstr(text_color, first_line) + status_tag)
|
|
157
|
+
|
|
158
|
+
for chunk in all_chunks[1:]:
|
|
159
|
+
parts.append(cstr(text_color, chunk))
|
|
160
|
+
|
|
161
|
+
if status == LogStatus.WARNING:
|
|
162
|
+
parts.append("-" * (STATUS_LEN - 1))
|
|
163
|
+
|
|
164
|
+
return "\n".join(parts)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class NbdanticLogger(logging.Logger):
|
|
168
|
+
def status_log(self, msg: str, status: LogStatus, nocolor: bool = False) -> None:
|
|
169
|
+
level = logging.WARNING if status == LogStatus.WARNING else logging.INFO
|
|
170
|
+
self.log(level, msg, extra={"status": status, "nocolor": nocolor})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
logging.setLoggerClass(NbdanticLogger)
|
|
174
|
+
|
|
175
|
+
logger: NbdanticLogger = logging.getLogger("nbdantic") # type: ignore
|
|
176
|
+
|
|
177
|
+
_handler = logging.StreamHandler()
|
|
178
|
+
_handler.setFormatter(StatusFormatter())
|
|
179
|
+
logger.addHandler(_handler)
|
|
180
|
+
logger.setLevel(logging.INFO)
|
|
181
|
+
logger.propagate = False
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
class VersionT(IntEnum):
|
|
4
|
+
Url = ...
|
|
5
|
+
VerSpec = ...
|
|
6
|
+
|
|
7
|
+
class VersionSpecifier:
|
|
8
|
+
@property
|
|
9
|
+
def operator(self) -> str: ...
|
|
10
|
+
@property
|
|
11
|
+
def version(self) -> str: ...
|
|
12
|
+
def __repr__(self) -> str: ...
|
|
13
|
+
def __str__(self) -> str: ...
|
|
14
|
+
|
|
15
|
+
class UVRequirement:
|
|
16
|
+
@property
|
|
17
|
+
def package_name(self) -> str: ...
|
|
18
|
+
@property
|
|
19
|
+
def version_type(self) -> VersionT | None: ...
|
|
20
|
+
@property
|
|
21
|
+
def pkg_spec(self) -> list[VersionSpecifier] | None: ...
|
|
22
|
+
@property
|
|
23
|
+
def version_str(self) -> str | None: ...
|
|
24
|
+
def __repr__(self) -> str: ...
|
|
25
|
+
|
|
26
|
+
def version_specs_from_string(spec_str: str) -> list[VersionSpecifier]: ...
|
|
27
|
+
def package_spec_from_string(spec_str: str) -> UVRequirement: ...
|