panda-cli 0.2.0__tar.gz → 0.3.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.
- panda_cli-0.3.0/PKG-INFO +199 -0
- panda_cli-0.3.0/README.md +180 -0
- panda_cli-0.3.0/examples/web.py +47 -0
- {panda_cli-0.2.0 → panda_cli-0.3.0}/makefile +17 -2
- panda_cli-0.3.0/pyproject.toml +83 -0
- panda_cli-0.3.0/src/panda_cli/__init__.py +15 -0
- panda_cli-0.3.0/src/panda_cli/annotations.py +79 -0
- panda_cli-0.3.0/src/panda_cli/backends/__init__.py +5 -0
- panda_cli-0.3.0/src/panda_cli/backends/click_backend.py +202 -0
- panda_cli-0.3.0/src/panda_cli/base.py +151 -0
- panda_cli-0.3.0/src/panda_cli/errors.py +38 -0
- panda_cli-0.3.0/src/panda_cli/introspect.py +179 -0
- panda_cli-0.3.0/src/panda_cli/option.py +142 -0
- panda_cli-0.3.0/src/panda_cli/runtime.py +67 -0
- panda_cli-0.3.0/src/panda_cli/spec.py +144 -0
- {panda_cli-0.2.0 → panda_cli-0.3.0}/uv.lock +152 -1
- panda_cli-0.2.0/PKG-INFO +0 -112
- panda_cli-0.2.0/README.md +0 -102
- panda_cli-0.2.0/pyproject.toml +0 -28
- panda_cli-0.2.0/src/panda_cli/__init__.py +0 -6
- panda_cli-0.2.0/src/panda_cli/base.py +0 -186
- panda_cli-0.2.0/src/panda_cli/completion.py +0 -0
- panda_cli-0.2.0/src/panda_cli/option.py +0 -180
- panda_cli-0.2.0/src/panda_cli/parameters.py +0 -26
- panda_cli-0.2.0/src/panda_cli/utils.py +0 -11
- {panda_cli-0.2.0 → panda_cli-0.3.0}/.gitignore +0 -0
- {panda_cli-0.2.0 → panda_cli-0.3.0}/.python-version +0 -0
- {panda_cli-0.2.0 → panda_cli-0.3.0}/src/panda_cli/py.typed +0 -0
panda_cli-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: panda-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Declarative CLI framework: commands are Pydantic models, built on Click
|
|
5
|
+
Author-email: meleshuk0804@gmail.com
|
|
6
|
+
Keywords: cli,click,command-line,declarative,pydantic
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: click>=8.3.1
|
|
17
|
+
Requires-Dist: pydantic>=2.12.5
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# panda-cli
|
|
21
|
+
|
|
22
|
+
A lightweight declarative CLI framework built on top of Click and Pydantic.
|
|
23
|
+
|
|
24
|
+
Define commands as Python classes, describe arguments with type hints,
|
|
25
|
+
and build CLI trees using simple class composition.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Declarative CLI commands
|
|
32
|
+
- Type-safe arguments
|
|
33
|
+
- Automatic CLI tree generation
|
|
34
|
+
- Built-in validation via Pydantic
|
|
35
|
+
- Validation errors reported in command-line terms (`--port`, not `port`)
|
|
36
|
+
- Minimal boilerplate
|
|
37
|
+
- Nested command groups
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install panda-cli
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
## Quick Example
|
|
49
|
+
```python
|
|
50
|
+
from panda_cli import BaseCommand, Option, BaseGroup
|
|
51
|
+
from panda_cli import Config
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WebCli(BaseCommand):
|
|
55
|
+
""" Web server """ # automaticly add to web --help
|
|
56
|
+
__cli_name__ = "web"
|
|
57
|
+
|
|
58
|
+
config=Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
|
|
59
|
+
|
|
60
|
+
host: str = Option(
|
|
61
|
+
"--host",
|
|
62
|
+
default="127.0.0.1",
|
|
63
|
+
description="Host to listen on",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
port: int = Option(
|
|
67
|
+
"--port",
|
|
68
|
+
default=8000,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
|
|
72
|
+
|
|
73
|
+
map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
|
|
74
|
+
|
|
75
|
+
def __exec__(self) -> None:
|
|
76
|
+
""" Automaticly runs when command is called """
|
|
77
|
+
|
|
78
|
+
print(f"Host: {self.host}")
|
|
79
|
+
print(f"Port: {self.port}")
|
|
80
|
+
print(f"Methods: {self.methods}")
|
|
81
|
+
print(f"Map: {self.map}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MainCli(BaseGroup):
|
|
85
|
+
__cli_name__ = "main"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
web: WebCli
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
MainCli.run()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
## CLI
|
|
101
|
+
```bash
|
|
102
|
+
uv run examples/web.py --help
|
|
103
|
+
|
|
104
|
+
Usage: main [OPTIONS] COMMAND [ARGS]...
|
|
105
|
+
|
|
106
|
+
Options:
|
|
107
|
+
--help Show this message and exit.
|
|
108
|
+
|
|
109
|
+
Commands:
|
|
110
|
+
web Web server
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
uv run examples/web.py web --help
|
|
115
|
+
|
|
116
|
+
Usage: main web [OPTIONS]
|
|
117
|
+
|
|
118
|
+
Web server
|
|
119
|
+
|
|
120
|
+
Options:
|
|
121
|
+
--host TEXT Host to listen on [default: 127.0.0.1]
|
|
122
|
+
--port INTEGER [default: 8000]
|
|
123
|
+
--methods Iterable TEXT... [required]
|
|
124
|
+
--map TEXT=TEXT [required]
|
|
125
|
+
--help Show this message and exit.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
## Validation
|
|
130
|
+
|
|
131
|
+
Any Pydantic validator works, and failures are shown the way a CLI user
|
|
132
|
+
expects them — not as a traceback:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from pydantic import Field
|
|
136
|
+
|
|
137
|
+
class WebCli(BaseCommand):
|
|
138
|
+
__cli_name__ = "web"
|
|
139
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
140
|
+
|
|
141
|
+
def __exec__(self) -> None: ...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
$ web --port 99999
|
|
146
|
+
|
|
147
|
+
Usage: web [OPTIONS]
|
|
148
|
+
Try 'web --help' for help.
|
|
149
|
+
|
|
150
|
+
Error: Invalid value for '--port': Input should be less than or equal to 65535
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
## Environment variables
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
host: str = Option("--host", default="127.0.0.1", envvar="APP_HOST")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
## Embedding into an existing Click app
|
|
162
|
+
|
|
163
|
+
`run()` is the entry point for `console_scripts`, but the built command is
|
|
164
|
+
also available on its own — useful for mounting a panda-cli tree inside a
|
|
165
|
+
Click application, and for testing:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import click
|
|
169
|
+
|
|
170
|
+
cli = click.Group("myapp")
|
|
171
|
+
cli.add_command(MainCli.as_click())
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`MainCli.spec()` returns the same tree as backend-independent dataclasses,
|
|
175
|
+
without touching Click at all.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
## Architecture
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
declaration BaseCommand / BaseGroup — what the user writes
|
|
182
|
+
introspect model -> Spec — the only module that imports pydantic
|
|
183
|
+
backend Spec -> click.Command — the only module that imports click
|
|
184
|
+
runtime argv -> validate -> __exec__
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Subcommands are declared as fields (`web: WebCli`) but are not model fields:
|
|
188
|
+
the metaclass moves them into the command tree before Pydantic sees them, so
|
|
189
|
+
a group stays an ordinary model with its own options.
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
## Development
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
make test # run the test suite
|
|
196
|
+
make cov # coverage
|
|
197
|
+
make regression # tests guarding previously fixed bugs
|
|
198
|
+
make check # lint + tests
|
|
199
|
+
```
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# panda-cli
|
|
2
|
+
|
|
3
|
+
A lightweight declarative CLI framework built on top of Click and Pydantic.
|
|
4
|
+
|
|
5
|
+
Define commands as Python classes, describe arguments with type hints,
|
|
6
|
+
and build CLI trees using simple class composition.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Declarative CLI commands
|
|
13
|
+
- Type-safe arguments
|
|
14
|
+
- Automatic CLI tree generation
|
|
15
|
+
- Built-in validation via Pydantic
|
|
16
|
+
- Validation errors reported in command-line terms (`--port`, not `port`)
|
|
17
|
+
- Minimal boilerplate
|
|
18
|
+
- Nested command groups
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install panda-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Quick Example
|
|
30
|
+
```python
|
|
31
|
+
from panda_cli import BaseCommand, Option, BaseGroup
|
|
32
|
+
from panda_cli import Config
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WebCli(BaseCommand):
|
|
36
|
+
""" Web server """ # automaticly add to web --help
|
|
37
|
+
__cli_name__ = "web"
|
|
38
|
+
|
|
39
|
+
config=Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
|
|
40
|
+
|
|
41
|
+
host: str = Option(
|
|
42
|
+
"--host",
|
|
43
|
+
default="127.0.0.1",
|
|
44
|
+
description="Host to listen on",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
port: int = Option(
|
|
48
|
+
"--port",
|
|
49
|
+
default=8000,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
|
|
53
|
+
|
|
54
|
+
map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
|
|
55
|
+
|
|
56
|
+
def __exec__(self) -> None:
|
|
57
|
+
""" Automaticly runs when command is called """
|
|
58
|
+
|
|
59
|
+
print(f"Host: {self.host}")
|
|
60
|
+
print(f"Port: {self.port}")
|
|
61
|
+
print(f"Methods: {self.methods}")
|
|
62
|
+
print(f"Map: {self.map}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MainCli(BaseGroup):
|
|
66
|
+
__cli_name__ = "main"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
web: WebCli
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main():
|
|
73
|
+
MainCli.run()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
## CLI
|
|
82
|
+
```bash
|
|
83
|
+
uv run examples/web.py --help
|
|
84
|
+
|
|
85
|
+
Usage: main [OPTIONS] COMMAND [ARGS]...
|
|
86
|
+
|
|
87
|
+
Options:
|
|
88
|
+
--help Show this message and exit.
|
|
89
|
+
|
|
90
|
+
Commands:
|
|
91
|
+
web Web server
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv run examples/web.py web --help
|
|
96
|
+
|
|
97
|
+
Usage: main web [OPTIONS]
|
|
98
|
+
|
|
99
|
+
Web server
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
--host TEXT Host to listen on [default: 127.0.0.1]
|
|
103
|
+
--port INTEGER [default: 8000]
|
|
104
|
+
--methods Iterable TEXT... [required]
|
|
105
|
+
--map TEXT=TEXT [required]
|
|
106
|
+
--help Show this message and exit.
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
## Validation
|
|
111
|
+
|
|
112
|
+
Any Pydantic validator works, and failures are shown the way a CLI user
|
|
113
|
+
expects them — not as a traceback:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from pydantic import Field
|
|
117
|
+
|
|
118
|
+
class WebCli(BaseCommand):
|
|
119
|
+
__cli_name__ = "web"
|
|
120
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
121
|
+
|
|
122
|
+
def __exec__(self) -> None: ...
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
$ web --port 99999
|
|
127
|
+
|
|
128
|
+
Usage: web [OPTIONS]
|
|
129
|
+
Try 'web --help' for help.
|
|
130
|
+
|
|
131
|
+
Error: Invalid value for '--port': Input should be less than or equal to 65535
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
## Environment variables
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
host: str = Option("--host", default="127.0.0.1", envvar="APP_HOST")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
## Embedding into an existing Click app
|
|
143
|
+
|
|
144
|
+
`run()` is the entry point for `console_scripts`, but the built command is
|
|
145
|
+
also available on its own — useful for mounting a panda-cli tree inside a
|
|
146
|
+
Click application, and for testing:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
import click
|
|
150
|
+
|
|
151
|
+
cli = click.Group("myapp")
|
|
152
|
+
cli.add_command(MainCli.as_click())
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`MainCli.spec()` returns the same tree as backend-independent dataclasses,
|
|
156
|
+
without touching Click at all.
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
## Architecture
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
declaration BaseCommand / BaseGroup — what the user writes
|
|
163
|
+
introspect model -> Spec — the only module that imports pydantic
|
|
164
|
+
backend Spec -> click.Command — the only module that imports click
|
|
165
|
+
runtime argv -> validate -> __exec__
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Subcommands are declared as fields (`web: WebCli`) but are not model fields:
|
|
169
|
+
the metaclass moves them into the command tree before Pydantic sees them, so
|
|
170
|
+
a group stays an ordinary model with its own options.
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
make test # run the test suite
|
|
177
|
+
make cov # coverage
|
|
178
|
+
make regression # tests guarding previously fixed bugs
|
|
179
|
+
make check # lint + tests
|
|
180
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from panda_cli import BaseCommand, Option, BaseGroup
|
|
2
|
+
from panda_cli import Config
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WebCli(BaseCommand):
|
|
6
|
+
"""Web server""" # automaticly add to web --help
|
|
7
|
+
|
|
8
|
+
__cli_name__ = "web"
|
|
9
|
+
|
|
10
|
+
config = Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
|
|
11
|
+
|
|
12
|
+
host: str = Option(
|
|
13
|
+
"--host",
|
|
14
|
+
default="127.0.0.1",
|
|
15
|
+
description="Host to listen on",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
port: int = Option(
|
|
19
|
+
"--port",
|
|
20
|
+
default=8000,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
|
|
24
|
+
|
|
25
|
+
map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
|
|
26
|
+
|
|
27
|
+
def __exec__(self) -> None:
|
|
28
|
+
"""Automaticly runs when command is called"""
|
|
29
|
+
|
|
30
|
+
print(f"Host: {self.host}")
|
|
31
|
+
print(f"Port: {self.port}")
|
|
32
|
+
print(f"Methods: {self.methods}")
|
|
33
|
+
print(f"Map: {self.map}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MainCli(BaseGroup):
|
|
37
|
+
__cli_name__ = "main"
|
|
38
|
+
|
|
39
|
+
web: WebCli
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
MainCli.run()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: major minor patch
|
|
1
|
+
.PHONY: major minor patch test cov regression lint check
|
|
2
2
|
|
|
3
3
|
PYPROJECT := pyproject.toml
|
|
4
4
|
INIT_FILE := src/panda_cli/__init__.py
|
|
@@ -22,4 +22,19 @@ minor:
|
|
|
22
22
|
$(call bump,$(MAJOR).$(shell echo $$(($(MINOR)+1))).0)
|
|
23
23
|
|
|
24
24
|
patch:
|
|
25
|
-
$(call bump,$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))))
|
|
25
|
+
$(call bump,$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))))
|
|
26
|
+
|
|
27
|
+
test:
|
|
28
|
+
uv run pytest
|
|
29
|
+
|
|
30
|
+
cov:
|
|
31
|
+
uv run pytest --cov
|
|
32
|
+
|
|
33
|
+
regression: # тесты на закрытые баги
|
|
34
|
+
uv run pytest -m regression -rX
|
|
35
|
+
|
|
36
|
+
lint:
|
|
37
|
+
uv run ruff check .
|
|
38
|
+
uv run ruff format --check .
|
|
39
|
+
|
|
40
|
+
check: lint test
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "panda-cli"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Declarative CLI framework: commands are Pydantic models, built on Click"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ email = "meleshuk0804@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
keywords = ["cli", "click", "pydantic", "command-line", "declarative"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"click>=8.3.1",
|
|
23
|
+
"pydantic>=2.12.5",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["hatchling"]
|
|
28
|
+
build-backend = "hatchling.build"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/panda_cli"]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"mypy>=1.19.1",
|
|
37
|
+
"pytest>=9.1.1",
|
|
38
|
+
"pytest-cov>=7.1.0",
|
|
39
|
+
"ruff>=0.15.4",
|
|
40
|
+
"twine>=6.2.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
addopts = "-q --strict-markers --strict-config"
|
|
46
|
+
markers = [
|
|
47
|
+
"regression: тест на конкретный закрытый баг — не удалять при рефакторинге",
|
|
48
|
+
]
|
|
49
|
+
xfail_strict = true
|
|
50
|
+
|
|
51
|
+
[tool.coverage.run]
|
|
52
|
+
source = ["panda_cli"]
|
|
53
|
+
branch = true
|
|
54
|
+
|
|
55
|
+
[tool.coverage.report]
|
|
56
|
+
show_missing = true
|
|
57
|
+
exclude_lines = ["pragma: no cover", "raise NotImplementedError", "if t.TYPE_CHECKING:"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 100
|
|
61
|
+
src = [".", "src"]
|
|
62
|
+
exclude = ["_temp", "dist", ".venv"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
66
|
+
ignore = [
|
|
67
|
+
"RUF003", # кириллица в комментариях
|
|
68
|
+
"RUF001", # кириллица в строках — здесь это нормально
|
|
69
|
+
"RUF002", # кириллица в docstring'ах
|
|
70
|
+
"RUF012", # ClassVar для mutable-атрибутов: ложное срабатывание на полях pydantic
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint.isort]
|
|
74
|
+
known-first-party = ["panda_cli", "tests"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.per-file-ignores]
|
|
77
|
+
# пример повторяет README дословно — форматирование импортов оставлено как в доке
|
|
78
|
+
"examples/*.py" = ["I001"]
|
|
79
|
+
|
|
80
|
+
[tool.mypy]
|
|
81
|
+
python_version = "3.12"
|
|
82
|
+
strict = true
|
|
83
|
+
files = ["src/panda_cli", "tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .base import BaseCommand, BaseGroup, Config
|
|
2
|
+
from .errors import BindingError, DeclarationError, PandaCLIError
|
|
3
|
+
from .option import Option
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"BaseCommand",
|
|
7
|
+
"BaseGroup",
|
|
8
|
+
"BindingError",
|
|
9
|
+
"Config",
|
|
10
|
+
"DeclarationError",
|
|
11
|
+
"Option",
|
|
12
|
+
"PandaCLIError",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Аннотация Python → описание значения параметра.
|
|
2
|
+
|
|
3
|
+
Чистый слой: только `typing`, никакого click и никакого pydantic.
|
|
4
|
+
Развёртка `Optional` живёт здесь в единственном экземпляре — раньше она была
|
|
5
|
+
скопирована в четырёх функциях, и в пятом месте про неё забыли.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import types
|
|
11
|
+
import typing as t
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from panda_cli.spec import (
|
|
15
|
+
Arity,
|
|
16
|
+
ChoiceType,
|
|
17
|
+
PairType,
|
|
18
|
+
ScalarType,
|
|
19
|
+
TypeSpec,
|
|
20
|
+
UnionType,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_SEQUENCE_ORIGINS: t.Final = (list, tuple, set, frozenset)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def unwrap_optional(annotation: t.Any) -> t.Any:
|
|
27
|
+
"""`X | None` → `X`. Всё остальное возвращается как есть."""
|
|
28
|
+
if t.get_origin(annotation) not in (t.Union, types.UnionType):
|
|
29
|
+
return annotation
|
|
30
|
+
args = [a for a in t.get_args(annotation) if a is not type(None)]
|
|
31
|
+
return args[0] if len(args) == 1 else annotation
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Shape:
|
|
36
|
+
"""Что аннотация говорит о параметре."""
|
|
37
|
+
|
|
38
|
+
type: TypeSpec
|
|
39
|
+
arity: Arity
|
|
40
|
+
collect_pairs: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def describe(annotation: t.Any) -> Shape:
|
|
44
|
+
"""Разобрать аннотацию поля в тип значения и арность."""
|
|
45
|
+
ann = unwrap_optional(annotation)
|
|
46
|
+
origin = t.get_origin(ann)
|
|
47
|
+
|
|
48
|
+
if origin is t.Literal:
|
|
49
|
+
choices = tuple(str(c) for c in t.get_args(ann))
|
|
50
|
+
return Shape(ChoiceType(choices), Arity.SINGLE, False)
|
|
51
|
+
|
|
52
|
+
if origin in (t.Union, types.UnionType):
|
|
53
|
+
members = tuple(_scalar(a) for a in t.get_args(ann) if a is not type(None))
|
|
54
|
+
return Shape(UnionType(members), Arity.SINGLE, False)
|
|
55
|
+
|
|
56
|
+
# Голые `dict` / `list` не имеют __origin__, но означают то же самое.
|
|
57
|
+
if origin is dict or ann is dict:
|
|
58
|
+
args = t.get_args(ann)
|
|
59
|
+
key = _scalar(args[0]) if args else ScalarType(str)
|
|
60
|
+
value = _scalar(args[1]) if len(args) > 1 else ScalarType(str)
|
|
61
|
+
return Shape(PairType(key, value), Arity.MULTIPLE, True)
|
|
62
|
+
|
|
63
|
+
if origin in _SEQUENCE_ORIGINS or ann in _SEQUENCE_ORIGINS:
|
|
64
|
+
args = t.get_args(ann)
|
|
65
|
+
inner = describe(args[0]).type if args else ScalarType(str)
|
|
66
|
+
return Shape(inner, Arity.MULTIPLE, False)
|
|
67
|
+
|
|
68
|
+
if ann is bool:
|
|
69
|
+
return Shape(ScalarType(bool), Arity.FLAG, False)
|
|
70
|
+
|
|
71
|
+
return Shape(_scalar(ann), Arity.SINGLE, False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _scalar(annotation: t.Any) -> TypeSpec:
|
|
75
|
+
"""Тип одного значения — без учёта контейнера."""
|
|
76
|
+
ann = unwrap_optional(annotation)
|
|
77
|
+
if t.get_origin(ann) is t.Literal:
|
|
78
|
+
return ChoiceType(tuple(str(c) for c in t.get_args(ann)))
|
|
79
|
+
return ScalarType(ann)
|