panda-cli 0.1.2__tar.gz → 0.2.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.2.0/.gitignore +162 -0
- panda_cli-0.2.0/PKG-INFO +112 -0
- panda_cli-0.2.0/README.md +102 -0
- panda_cli-0.2.0/makefile +25 -0
- {panda_cli-0.1.2 → panda_cli-0.2.0}/pyproject.toml +1 -1
- panda_cli-0.2.0/src/panda_cli/__init__.py +6 -0
- {panda_cli-0.1.2 → panda_cli-0.2.0}/src/panda_cli/base.py +22 -13
- {panda_cli-0.1.2 → panda_cli-0.2.0}/src/panda_cli/option.py +24 -11
- {panda_cli-0.1.2 → panda_cli-0.2.0}/src/panda_cli/parameters.py +3 -4
- panda_cli-0.2.0/src/panda_cli/utils.py +11 -0
- {panda_cli-0.1.2 → panda_cli-0.2.0}/uv.lock +1 -1
- panda_cli-0.1.2/PKG-INFO +0 -8
- panda_cli-0.1.2/README.md +0 -0
- panda_cli-0.1.2/_temp/__init__.py +0 -6
- panda_cli-0.1.2/_temp/base_option.py +0 -99
- panda_cli-0.1.2/_temp/base_tree.py +0 -127
- panda_cli-0.1.2/_temp/option_contstructor.py +0 -56
- panda_cli-0.1.2/_temp/tools.py +0 -54
- panda_cli-0.1.2/src/panda_cli/__init__.py +0 -6
- panda_cli-0.1.2/src/panda_cli/py.typed +0 -0
- panda_cli-0.1.2/tests/test.py +0 -34
- {panda_cli-0.1.2 → panda_cli-0.2.0}/.python-version +0 -0
- {panda_cli-0.1.2 → panda_cli-0.2.0}/src/panda_cli/completion.py +0 -0
- {panda_cli-0.1.2/_temp → panda_cli-0.2.0/src/panda_cli}/py.typed +0 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
*.log
|
|
2
|
+
*.pot
|
|
3
|
+
*.pyc
|
|
4
|
+
__pycache__/
|
|
5
|
+
__pycache__
|
|
6
|
+
local_settings.py
|
|
7
|
+
db.sqlite3
|
|
8
|
+
db.sqlite3-journal
|
|
9
|
+
media
|
|
10
|
+
.env
|
|
11
|
+
*.py[cod]
|
|
12
|
+
*$py.class
|
|
13
|
+
|
|
14
|
+
*.so
|
|
15
|
+
.idea/
|
|
16
|
+
.DS_Store
|
|
17
|
+
.Python
|
|
18
|
+
build/
|
|
19
|
+
develop-eggs/
|
|
20
|
+
dist/
|
|
21
|
+
downloads/
|
|
22
|
+
eggs/
|
|
23
|
+
.eggs/
|
|
24
|
+
lib/
|
|
25
|
+
lib64/
|
|
26
|
+
parts/
|
|
27
|
+
sdist/
|
|
28
|
+
var/
|
|
29
|
+
wheels/
|
|
30
|
+
share/python-wheels/
|
|
31
|
+
*.egg-info/
|
|
32
|
+
.installed.cfg
|
|
33
|
+
*.egg
|
|
34
|
+
MANIFEST
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
*.manifest
|
|
38
|
+
*.spec
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
pip-log.txt
|
|
42
|
+
pip-delete-this-directory.txt
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
htmlcov/
|
|
46
|
+
.tox/
|
|
47
|
+
.nox/
|
|
48
|
+
.coverage
|
|
49
|
+
.coverage.*
|
|
50
|
+
.cache
|
|
51
|
+
nosetests.xml
|
|
52
|
+
coverage.xml
|
|
53
|
+
*.cover
|
|
54
|
+
*.py,cover
|
|
55
|
+
.hypothesis/
|
|
56
|
+
.pytest_cache/
|
|
57
|
+
cover/
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
*.mo
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
instance/
|
|
65
|
+
.webassets-cache
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
.scrapy
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
docs/_build/
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
.pybuilder/
|
|
75
|
+
target/
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
.ipynb_checkpoints
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
profile_default/
|
|
82
|
+
ipython_config.py
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
.pdm.toml
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__pypackages__/
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
celerybeat-schedule
|
|
92
|
+
celerybeat.pid
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
*.sage.py
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
.venv
|
|
100
|
+
venv/
|
|
101
|
+
env.bak/
|
|
102
|
+
venv.bak/
|
|
103
|
+
|
|
104
|
+
.spyderproject
|
|
105
|
+
.spyproject
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
.ropeproject
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
/site
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
.mypy_cache/
|
|
115
|
+
.dmypy.json
|
|
116
|
+
dmypy.json
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
.pyre/
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
.pytype/
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
cython_debug/
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
.idea/**/workspace.xml
|
|
129
|
+
.idea/**/tasks.xml
|
|
130
|
+
.idea/**/usage.statistics.xml
|
|
131
|
+
.idea/**/dictionaries
|
|
132
|
+
.idea/**/shelf
|
|
133
|
+
.idea
|
|
134
|
+
.DS_Store
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
.idea/**/aws.xml
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
.idea/**/contentModel.xml
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
.idea/**/dataSources/
|
|
144
|
+
.idea/**/dataSources.ids
|
|
145
|
+
.idea/**/dataSources.local.xml
|
|
146
|
+
.idea/**/sqlDataSources.xml
|
|
147
|
+
.idea/**/dynamic.xml
|
|
148
|
+
.idea/**/uiDesigner.xml
|
|
149
|
+
.idea/**/dbnavigator.xml
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
.idea/**/gradle.xml
|
|
153
|
+
.idea/**/libraries
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
cmake-build-*/
|
|
157
|
+
.stignore
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_temp
|
|
161
|
+
dist
|
|
162
|
+
tests
|
panda_cli-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: panda-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: meleshuk0804@gmail.com
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: click>=8.3.1
|
|
8
|
+
Requires-Dist: pydantic>=2.12.5
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# panda-cli
|
|
12
|
+
|
|
13
|
+
A lightweight declarative CLI framework built on top of Click and Pydantic.
|
|
14
|
+
|
|
15
|
+
Define commands as Python classes, describe arguments with type hints,
|
|
16
|
+
and build CLI trees using simple class composition.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Declarative CLI commands
|
|
23
|
+
- Type-safe arguments
|
|
24
|
+
- Automatic CLI tree generation
|
|
25
|
+
- Built-in validation via Pydantic
|
|
26
|
+
- Minimal boilerplate
|
|
27
|
+
- Nested command groups
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install panda-cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Quick Example
|
|
39
|
+
```python
|
|
40
|
+
from panda_cli import BaseCommand, Option, BaseGroup
|
|
41
|
+
from panda_cli import Config
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebCli(BaseCommand):
|
|
45
|
+
""" Web server """ # automaticly add to web --help
|
|
46
|
+
__cli_name__ = "web"
|
|
47
|
+
|
|
48
|
+
config=Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
|
|
49
|
+
|
|
50
|
+
host: str = Option(
|
|
51
|
+
"--host",
|
|
52
|
+
default="127.0.0.1",
|
|
53
|
+
description="Host to listen on",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
port: int = Option(
|
|
57
|
+
"--port",
|
|
58
|
+
default=8000,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
|
|
62
|
+
|
|
63
|
+
map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
|
|
64
|
+
|
|
65
|
+
def __exec__(self) -> None:
|
|
66
|
+
""" Automaticly runs when command is called """
|
|
67
|
+
|
|
68
|
+
print(f"Host: {self.host}")
|
|
69
|
+
print(f"Port: {self.port}")
|
|
70
|
+
print(f"Methods: {self.methods}")
|
|
71
|
+
print(f"Map: {self.map}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MainCli(BaseGroup):
|
|
75
|
+
__cli_name__ = "main"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
web: WebCli
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
MainCli.run()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
main()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## CLI
|
|
91
|
+
```bash
|
|
92
|
+
uv run -m src --help
|
|
93
|
+
|
|
94
|
+
Options:
|
|
95
|
+
--help Show this message and exit.
|
|
96
|
+
|
|
97
|
+
Commands:
|
|
98
|
+
web Web server
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv run tests/test.py web --help
|
|
103
|
+
|
|
104
|
+
Web server
|
|
105
|
+
|
|
106
|
+
Options:
|
|
107
|
+
--host TEXT Host to listen on
|
|
108
|
+
--port INTEGER
|
|
109
|
+
--methods Iterable TEXT... [required]
|
|
110
|
+
--map TEXT=TEXT [required]
|
|
111
|
+
--help Show this message and exit.
|
|
112
|
+
```
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
- Minimal boilerplate
|
|
17
|
+
- Nested command groups
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install panda-cli
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Quick Example
|
|
29
|
+
```python
|
|
30
|
+
from panda_cli import BaseCommand, Option, BaseGroup
|
|
31
|
+
from panda_cli import Config
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WebCli(BaseCommand):
|
|
35
|
+
""" Web server """ # automaticly add to web --help
|
|
36
|
+
__cli_name__ = "web"
|
|
37
|
+
|
|
38
|
+
config=Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
|
|
39
|
+
|
|
40
|
+
host: str = Option(
|
|
41
|
+
"--host",
|
|
42
|
+
default="127.0.0.1",
|
|
43
|
+
description="Host to listen on",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
port: int = Option(
|
|
47
|
+
"--port",
|
|
48
|
+
default=8000,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
|
|
52
|
+
|
|
53
|
+
map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
|
|
54
|
+
|
|
55
|
+
def __exec__(self) -> None:
|
|
56
|
+
""" Automaticly runs when command is called """
|
|
57
|
+
|
|
58
|
+
print(f"Host: {self.host}")
|
|
59
|
+
print(f"Port: {self.port}")
|
|
60
|
+
print(f"Methods: {self.methods}")
|
|
61
|
+
print(f"Map: {self.map}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MainCli(BaseGroup):
|
|
65
|
+
__cli_name__ = "main"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
web: WebCli
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main():
|
|
72
|
+
MainCli.run()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
## CLI
|
|
81
|
+
```bash
|
|
82
|
+
uv run -m src --help
|
|
83
|
+
|
|
84
|
+
Options:
|
|
85
|
+
--help Show this message and exit.
|
|
86
|
+
|
|
87
|
+
Commands:
|
|
88
|
+
web Web server
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv run tests/test.py web --help
|
|
93
|
+
|
|
94
|
+
Web server
|
|
95
|
+
|
|
96
|
+
Options:
|
|
97
|
+
--host TEXT Host to listen on
|
|
98
|
+
--port INTEGER
|
|
99
|
+
--methods Iterable TEXT... [required]
|
|
100
|
+
--map TEXT=TEXT [required]
|
|
101
|
+
--help Show this message and exit.
|
|
102
|
+
```
|
panda_cli-0.2.0/makefile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.PHONY: major minor patch
|
|
2
|
+
|
|
3
|
+
PYPROJECT := pyproject.toml
|
|
4
|
+
INIT_FILE := src/panda_cli/__init__.py
|
|
5
|
+
|
|
6
|
+
# Читаем текущую версию из pyproject.toml
|
|
7
|
+
CURRENT := $(shell grep '^version = ' $(PYPROJECT) | sed 's/version = "\(.*\)"/\1/')
|
|
8
|
+
MAJOR := $(shell echo $(CURRENT) | cut -d. -f1)
|
|
9
|
+
MINOR := $(shell echo $(CURRENT) | cut -d. -f2)
|
|
10
|
+
PATCH := $(shell echo $(CURRENT) | cut -d. -f3)
|
|
11
|
+
|
|
12
|
+
define bump
|
|
13
|
+
@echo UPDATED "$(CURRENT) → $(1)"
|
|
14
|
+
@sed -i 's/^version = ".*"/version = "$(1)"/' $(PYPROJECT)
|
|
15
|
+
@sed -i 's/^__version__ = ".*"/__version__ = "$(1)"/' $(INIT_FILE)
|
|
16
|
+
endef
|
|
17
|
+
|
|
18
|
+
major:
|
|
19
|
+
$(call bump,$(shell echo $$(($(MAJOR)+1))).0.0)
|
|
20
|
+
|
|
21
|
+
minor:
|
|
22
|
+
$(call bump,$(MAJOR).$(shell echo $$(($(MINOR)+1))).0)
|
|
23
|
+
|
|
24
|
+
patch:
|
|
25
|
+
$(call bump,$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))))
|
|
@@ -4,7 +4,7 @@ import click
|
|
|
4
4
|
from pydantic.fields import FieldInfo
|
|
5
5
|
from pydantic_core import PydanticUndefined
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from panda_cli.option import (
|
|
8
8
|
get_field_option_meta,
|
|
9
9
|
get_metavar,
|
|
10
10
|
is_bool_flag,
|
|
@@ -14,7 +14,7 @@ from clipy.option import (
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class Config(BaseModel):
|
|
17
|
-
|
|
17
|
+
fields_name_to_flag: bool = True
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class Base(BaseModel):
|
|
@@ -32,7 +32,7 @@ class Base(BaseModel):
|
|
|
32
32
|
"""Run CLI."""
|
|
33
33
|
raise NotImplementedError
|
|
34
34
|
|
|
35
|
-
def
|
|
35
|
+
def __exec__(self) -> None:
|
|
36
36
|
"""Command entry point."""
|
|
37
37
|
raise NotImplementedError
|
|
38
38
|
|
|
@@ -46,8 +46,9 @@ class Base(BaseModel):
|
|
|
46
46
|
meta = get_field_option_meta(field)
|
|
47
47
|
|
|
48
48
|
flags = list(meta.get("param_decls", None) or ())
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
field_get_flag = f"--{field_name.replace('_', '-')}"
|
|
50
|
+
if cls.config.fields_name_to_flag and field_get_flag not in flags:
|
|
51
|
+
flags.append(field_get_flag)
|
|
51
52
|
|
|
52
53
|
has_default = field.default is not PydanticUndefined
|
|
53
54
|
default = (
|
|
@@ -61,10 +62,9 @@ class Base(BaseModel):
|
|
|
61
62
|
is_flag = meta.get("is_flag", is_bool_flag(annotation) and not multiple)
|
|
62
63
|
click_type = resolve_click_type(annotation)
|
|
63
64
|
|
|
64
|
-
|
|
65
65
|
return click.Option(
|
|
66
66
|
param_decls=flags,
|
|
67
|
-
type=None if is_flag else click_type,
|
|
67
|
+
type=None if is_flag else click_type,
|
|
68
68
|
default=default,
|
|
69
69
|
required=required,
|
|
70
70
|
multiple=multiple,
|
|
@@ -108,14 +108,24 @@ class BaseCommand(Base):
|
|
|
108
108
|
|
|
109
109
|
@classmethod
|
|
110
110
|
def _build_command(cls, _field_name: str | None = None) -> click.Command:
|
|
111
|
+
hints = t.get_type_hints(cls)
|
|
112
|
+
|
|
111
113
|
def _callback(**kwargs: t.Any) -> t.Any:
|
|
112
114
|
cleaned = {
|
|
113
115
|
k: v
|
|
114
116
|
for k, v in kwargs.items()
|
|
115
117
|
if v is not None or cls.model_fields[k].default is not PydanticUndefined
|
|
116
118
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
converted: dict[str, t.Any] = {}
|
|
120
|
+
for k, v in cleaned.items():
|
|
121
|
+
ann = hints.get(k)
|
|
122
|
+
if t.get_origin(ann) is dict and isinstance(v, tuple):
|
|
123
|
+
converted[k] = dict(v)
|
|
124
|
+
else:
|
|
125
|
+
converted[k] = v
|
|
126
|
+
|
|
127
|
+
instance = cls(**converted)
|
|
128
|
+
return instance.__exec__()
|
|
119
129
|
|
|
120
130
|
return click.Command(
|
|
121
131
|
name=cls._cli_name(_field_name),
|
|
@@ -150,8 +160,7 @@ class BaseGroup(Base):
|
|
|
150
160
|
else:
|
|
151
161
|
group_params.append(cls._field_to_option(field_name, field, ann))
|
|
152
162
|
|
|
153
|
-
def
|
|
154
|
-
# берём только поля-опции, без подкоманд
|
|
163
|
+
def __exec__(**kwargs: t.Any) -> t.Any:
|
|
155
164
|
filtered = {k: v for k, v in kwargs.items() if k in option_fields}
|
|
156
165
|
if not filtered:
|
|
157
166
|
return
|
|
@@ -162,12 +171,12 @@ class BaseGroup(Base):
|
|
|
162
171
|
converted[k] = dict(v)
|
|
163
172
|
else:
|
|
164
173
|
converted[k] = v
|
|
165
|
-
return cls(**converted)()
|
|
174
|
+
return cls(**converted).__exec__()
|
|
166
175
|
|
|
167
176
|
group = click.Group(
|
|
168
177
|
name=cls._cli_name(_field_name),
|
|
169
178
|
params=group_params,
|
|
170
|
-
callback=
|
|
179
|
+
callback=__exec__,
|
|
171
180
|
invoke_without_command=True,
|
|
172
181
|
help=cls.__doc__,
|
|
173
182
|
)
|
|
@@ -6,7 +6,7 @@ from pydantic import Field
|
|
|
6
6
|
from pydantic.fields import FieldInfo
|
|
7
7
|
from pydantic_core import PydanticUndefined
|
|
8
8
|
|
|
9
|
-
from
|
|
9
|
+
from panda_cli.parameters import DictParamType
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
_CLICK_META_KEY: t.Final[str] = "__click_meta__"
|
|
@@ -96,6 +96,7 @@ _TYPE_MAP_CLICK: t.Final[dict[t.Any, t.Any]] = {
|
|
|
96
96
|
bytes: click.STRING,
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
|
|
99
100
|
def resolve_click_type(annotation: t.Any) -> click.ParamType:
|
|
100
101
|
origin = t.get_origin(annotation)
|
|
101
102
|
|
|
@@ -110,11 +111,15 @@ def resolve_click_type(annotation: t.Any) -> click.ParamType:
|
|
|
110
111
|
if origin in (list, tuple, set, frozenset):
|
|
111
112
|
inner = t.get_args(annotation) # type: ignore
|
|
112
113
|
return resolve_click_type(inner[0]) if inner else click.STRING
|
|
113
|
-
|
|
114
|
+
|
|
114
115
|
if origin is dict:
|
|
115
116
|
d_args = t.get_args(annotation)
|
|
116
117
|
key_t = _TYPE_MAP_CLICK.get(d_args[0], click.STRING) if d_args else click.STRING
|
|
117
|
-
val_t =
|
|
118
|
+
val_t = (
|
|
119
|
+
_TYPE_MAP_CLICK.get(d_args[1], click.STRING)
|
|
120
|
+
if len(d_args) > 1
|
|
121
|
+
else click.STRING
|
|
122
|
+
)
|
|
118
123
|
return DictParamType(key_t, val_t)
|
|
119
124
|
|
|
120
125
|
return _TYPE_MAP_CLICK.get(annotation, click.STRING)
|
|
@@ -144,24 +149,32 @@ def get_metavar(annotation: t.Any) -> str | None:
|
|
|
144
149
|
u_args = [a for a in t.get_args(annotation) if a is not type(None)]
|
|
145
150
|
if len(u_args) == 1:
|
|
146
151
|
return get_metavar(u_args[0])
|
|
147
|
-
parts = [
|
|
148
|
-
_TYPE_MAP_CLICK.get(a, click.STRING).name.upper()
|
|
149
|
-
for a in u_args
|
|
150
|
-
]
|
|
152
|
+
parts = [_TYPE_MAP_CLICK.get(a, click.STRING).name.upper() for a in u_args]
|
|
151
153
|
return " | ".join(parts) if parts else None
|
|
152
154
|
|
|
153
|
-
|
|
154
155
|
# dict[K, V] → KEY=VALUE
|
|
155
156
|
if origin is dict:
|
|
156
157
|
d_args = t.get_args(annotation)
|
|
157
|
-
k =
|
|
158
|
-
|
|
158
|
+
k = (
|
|
159
|
+
_TYPE_MAP_CLICK.get(d_args[0], click.STRING).name.upper()
|
|
160
|
+
if d_args
|
|
161
|
+
else "KEY"
|
|
162
|
+
)
|
|
163
|
+
v = (
|
|
164
|
+
_TYPE_MAP_CLICK.get(d_args[1], click.STRING).name.upper()
|
|
165
|
+
if len(d_args) > 1
|
|
166
|
+
else "VALUE"
|
|
167
|
+
)
|
|
159
168
|
return f"{k}={v}"
|
|
160
169
|
|
|
161
170
|
# list[X] / set[X] → TYPE...
|
|
162
171
|
if origin in (list, set, frozenset):
|
|
163
172
|
it_args = t.get_args(annotation)
|
|
164
|
-
inner =
|
|
173
|
+
inner = (
|
|
174
|
+
_TYPE_MAP_CLICK.get(it_args[0], click.STRING).name.upper()
|
|
175
|
+
if it_args
|
|
176
|
+
else "TEXT"
|
|
177
|
+
)
|
|
165
178
|
return f"Iterable {inner}..."
|
|
166
179
|
|
|
167
180
|
return None
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import click
|
|
2
2
|
import typing as t
|
|
3
3
|
|
|
4
|
+
|
|
4
5
|
class DictParamType(click.ParamType):
|
|
5
|
-
"""KEY=VALUE пары, можно передавать multiple раз."""
|
|
6
|
-
|
|
7
6
|
name = "KEY=VALUE"
|
|
8
|
-
|
|
7
|
+
|
|
9
8
|
def __init__(self, key_type: click.ParamType, val_type: click.ParamType) -> None:
|
|
10
9
|
self.key_type = key_type
|
|
11
10
|
self.val_type = val_type
|
|
@@ -24,4 +23,4 @@ class DictParamType(click.ParamType):
|
|
|
24
23
|
return (
|
|
25
24
|
self.key_type.convert(key.strip(), param, ctx),
|
|
26
25
|
self.val_type.convert(val.strip(), param, ctx),
|
|
27
|
-
)
|
|
26
|
+
)
|
panda_cli-0.1.2/PKG-INFO
DELETED
panda_cli-0.1.2/README.md
DELETED
|
File without changes
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import typing as t
|
|
4
|
-
|
|
5
|
-
from pydantic.fields import FieldInfo
|
|
6
|
-
from pydantic_core import PydanticUndefined
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# ---------------------------------------------------------------------------
|
|
10
|
-
# Option — симбиоз pydantic.Field + click метаданных
|
|
11
|
-
# ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
_CLICK_META_KEY = "__click_meta__"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class OptionMeta(t.TypedDict, total=False):
|
|
17
|
-
flags: t.Sequence[str]
|
|
18
|
-
"""Click-специфичные параметры, хранящиеся внутри FieldInfo.metadata."""
|
|
19
|
-
show_default: bool | str | None
|
|
20
|
-
prompt: bool | str
|
|
21
|
-
confirmation_prompt: bool | str
|
|
22
|
-
prompt_required: bool
|
|
23
|
-
hide_input: bool
|
|
24
|
-
is_flag: bool | None
|
|
25
|
-
flag_value: t.Any
|
|
26
|
-
multiple: bool
|
|
27
|
-
count: bool
|
|
28
|
-
allow_from_autoenv: bool
|
|
29
|
-
hidden: bool
|
|
30
|
-
show_choices: bool
|
|
31
|
-
show_envvar: bool
|
|
32
|
-
deprecated: bool | str
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def Option(
|
|
36
|
-
*flags: str,
|
|
37
|
-
# pydantic Field-совместимые
|
|
38
|
-
default: t.Any = PydanticUndefined,
|
|
39
|
-
description: str | None = None,
|
|
40
|
-
# click-специфичные
|
|
41
|
-
show_default: bool | str | None = None,
|
|
42
|
-
prompt: bool | str = False,
|
|
43
|
-
confirmation_prompt: bool | str = False,
|
|
44
|
-
prompt_required: bool = True,
|
|
45
|
-
hide_input: bool = False,
|
|
46
|
-
is_flag: bool | None = None,
|
|
47
|
-
flag_value: t.Any | None = None,
|
|
48
|
-
multiple: bool = False,
|
|
49
|
-
count: bool = False,
|
|
50
|
-
allow_from_autoenv: bool = True,
|
|
51
|
-
hidden: bool = False,
|
|
52
|
-
show_choices: bool = True,
|
|
53
|
-
show_envvar: bool = False,
|
|
54
|
-
deprecated: bool | str = False,
|
|
55
|
-
**field_kwargs: t.Any,
|
|
56
|
-
) -> t.Any:
|
|
57
|
-
"""
|
|
58
|
-
Замена/расширение pydantic.Field с поддержкой click-параметров.
|
|
59
|
-
Можно использовать вместо Field, сохраняя все возможности Pydantic.
|
|
60
|
-
"""
|
|
61
|
-
from pydantic import Field
|
|
62
|
-
|
|
63
|
-
click_meta: OptionMeta = dict( # type: ignore[assignment]
|
|
64
|
-
flags=flags,
|
|
65
|
-
show_default=show_default,
|
|
66
|
-
prompt=prompt,
|
|
67
|
-
confirmation_prompt=confirmation_prompt,
|
|
68
|
-
prompt_required=prompt_required,
|
|
69
|
-
hide_input=hide_input,
|
|
70
|
-
is_flag=is_flag,
|
|
71
|
-
flag_value=flag_value,
|
|
72
|
-
multiple=multiple,
|
|
73
|
-
count=count,
|
|
74
|
-
allow_from_autoenv=allow_from_autoenv,
|
|
75
|
-
hidden=hidden,
|
|
76
|
-
show_choices=show_choices,
|
|
77
|
-
show_envvar=show_envvar,
|
|
78
|
-
deprecated=deprecated,
|
|
79
|
-
)
|
|
80
|
-
# if param_decls:
|
|
81
|
-
# click_meta["param_decls"] = param_decls
|
|
82
|
-
|
|
83
|
-
kw: dict[str, t.Any] = dict(description=description, **field_kwargs)
|
|
84
|
-
if default is not PydanticUndefined:
|
|
85
|
-
kw["default"] = default
|
|
86
|
-
|
|
87
|
-
field = Field(**kw)
|
|
88
|
-
# Прячем click-мету в metadata списке FieldInfo
|
|
89
|
-
object.__setattr__(
|
|
90
|
-
field, "metadata", [*field.metadata, {_CLICK_META_KEY: click_meta}]
|
|
91
|
-
)
|
|
92
|
-
return field
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def get_click_meta(field: FieldInfo) -> OptionMeta:
|
|
96
|
-
for item in field.metadata:
|
|
97
|
-
if isinstance(item, dict) and _CLICK_META_KEY in item:
|
|
98
|
-
return item[_CLICK_META_KEY]
|
|
99
|
-
return {} # type: ignore[return-value]
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import typing as t
|
|
4
|
-
import collections.abc as cabc
|
|
5
|
-
|
|
6
|
-
import click
|
|
7
|
-
from pydantic import BaseModel, ConfigDict
|
|
8
|
-
|
|
9
|
-
from _temp.option_contstructor import field_to_click_option
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class BaseCommand(BaseModel):
|
|
13
|
-
"""
|
|
14
|
-
Базовый класс команды.
|
|
15
|
-
Поля (Option / Field) автоматически становятся click-опциями.
|
|
16
|
-
Переопределите __call__ для логики команды.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
20
|
-
|
|
21
|
-
# -- внутреннее построение ----------------------------------------------
|
|
22
|
-
|
|
23
|
-
@classmethod
|
|
24
|
-
def _build_click_params(cls) -> list[click.Option]:
|
|
25
|
-
hints = t.get_type_hints(cls)
|
|
26
|
-
params: list[click.Option] = []
|
|
27
|
-
for name, field in cls.model_fields.items():
|
|
28
|
-
ann = hints.get(name, field.annotation)
|
|
29
|
-
# Пропускаем поля, тип которых сам является BaseCommand/BaseGroup
|
|
30
|
-
if isinstance(ann, type) and issubclass(ann, (BaseCommand, BaseGroup)):
|
|
31
|
-
continue
|
|
32
|
-
params.append(field_to_click_option(name, field, ann))
|
|
33
|
-
return params
|
|
34
|
-
|
|
35
|
-
@classmethod
|
|
36
|
-
def _build_click_command(cls, field_name: str) -> click.Command:
|
|
37
|
-
def _callback(**kwargs: t.Any) -> t.Any:
|
|
38
|
-
instance = cls(**kwargs)
|
|
39
|
-
return instance()
|
|
40
|
-
|
|
41
|
-
return click.Command(
|
|
42
|
-
name=cls._cli_name(field_name),
|
|
43
|
-
params=cls._build_click_params(), # type: ignore
|
|
44
|
-
callback=_callback,
|
|
45
|
-
help=cls.__doc__,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
@classmethod
|
|
49
|
-
def _cli_name(cls, field_name: str) -> str:
|
|
50
|
-
# Можно переопределить через ClassVar или оставить авто
|
|
51
|
-
override = getattr(cls, "__cli_name__", None)
|
|
52
|
-
return override or field_name
|
|
53
|
-
|
|
54
|
-
# -- публичный интерфейс ------------------------------------------------
|
|
55
|
-
|
|
56
|
-
def __call__(self) -> t.Any:
|
|
57
|
-
"""Логика команды. Переопределите в подклассе."""
|
|
58
|
-
raise NotImplementedError(f"{self.__class__.__name__}.__call__ not implemented")
|
|
59
|
-
|
|
60
|
-
@classmethod
|
|
61
|
-
def run(cls, args: cabc.Sequence[str] | None = None) -> None:
|
|
62
|
-
"""Запустить команду как самостоятельный CLI."""
|
|
63
|
-
cls._build_click_command()(standalone_mode=True, args=args)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
class BaseGroup(BaseModel):
|
|
67
|
-
"""
|
|
68
|
-
Базовый класс группы.
|
|
69
|
-
Поля, тип которых — подкласс BaseCommand, автоматически становятся
|
|
70
|
-
подкомандами. Остальные поля — групповые опции.
|
|
71
|
-
|
|
72
|
-
Пример::
|
|
73
|
-
|
|
74
|
-
class CLI(BaseGroup):
|
|
75
|
-
__cli_name__ = "my-cli"
|
|
76
|
-
|
|
77
|
-
deploy: DeployCommand
|
|
78
|
-
migrate: MigrateCommand
|
|
79
|
-
"""
|
|
80
|
-
|
|
81
|
-
__cli_name__: t.ClassVar[str | None] = None
|
|
82
|
-
|
|
83
|
-
model_config = {"arbitrary_types_allowed": True}
|
|
84
|
-
|
|
85
|
-
@classmethod
|
|
86
|
-
def _cli_name(cls, field_name: str | None) -> str:
|
|
87
|
-
override = getattr(cls, "__cli_name__", None)
|
|
88
|
-
return override or field_name or cls.__name__.lower().removesuffix("group").removesuffix("cli")
|
|
89
|
-
|
|
90
|
-
@classmethod
|
|
91
|
-
def _build_click_group(cls, _field_name: str | None = None) -> click.Group:
|
|
92
|
-
hints = t.get_type_hints(cls)
|
|
93
|
-
|
|
94
|
-
group_params: list[click.Option] = []
|
|
95
|
-
subcommands: list[click.Command] = []
|
|
96
|
-
|
|
97
|
-
for field_name, field in cls.model_fields.items():
|
|
98
|
-
ann = hints.get(field_name, field.annotation)
|
|
99
|
-
# Поле-команда → подкоманда группы
|
|
100
|
-
if isinstance(ann, type) and issubclass(ann, BaseCommand):
|
|
101
|
-
subcommands.append(ann._build_click_command(field_name))
|
|
102
|
-
# Поле-группа → вложенная группа
|
|
103
|
-
elif isinstance(ann, type) and issubclass(ann, BaseGroup):
|
|
104
|
-
subcommands.append(ann._build_click_group(field_name))
|
|
105
|
-
# Всё остальное → групповая опция
|
|
106
|
-
else:
|
|
107
|
-
group_params.append(field_to_click_option(field_name, field, ann))
|
|
108
|
-
|
|
109
|
-
def _callback(**kwargs: t.Any) -> None:
|
|
110
|
-
pass
|
|
111
|
-
|
|
112
|
-
grp = click.Group(
|
|
113
|
-
name=cls._cli_name(_field_name),
|
|
114
|
-
params=group_params,
|
|
115
|
-
callback=_callback,
|
|
116
|
-
invoke_without_command=True,
|
|
117
|
-
help=cls.__doc__,
|
|
118
|
-
)
|
|
119
|
-
for cmd in subcommands:
|
|
120
|
-
grp.add_command(cmd)
|
|
121
|
-
|
|
122
|
-
return grp
|
|
123
|
-
|
|
124
|
-
@classmethod
|
|
125
|
-
def run(cls, args: cabc.Sequence[str] | None = None) -> None:
|
|
126
|
-
"""Запустить группу как корневой CLI."""
|
|
127
|
-
cls._build_click_group()(standalone_mode=True, args=args)
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import typing as t
|
|
4
|
-
|
|
5
|
-
import click
|
|
6
|
-
from pydantic.fields import FieldInfo
|
|
7
|
-
from pydantic_core import PydanticUndefined
|
|
8
|
-
|
|
9
|
-
from _temp.base_option import get_click_meta
|
|
10
|
-
from _temp.tools import _is_bool_flag, _is_multiple, _resolve_click_type
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def field_to_click_option(
|
|
14
|
-
field_name: str,
|
|
15
|
-
field: FieldInfo,
|
|
16
|
-
annotation: t.Any,
|
|
17
|
-
) -> click.Option:
|
|
18
|
-
meta = get_click_meta(field)
|
|
19
|
-
|
|
20
|
-
flags = list(meta.get("flags", ()))
|
|
21
|
-
flags.append(f"--{field_name.replace('_', '-')}")
|
|
22
|
-
|
|
23
|
-
has_default = field.default is not PydanticUndefined
|
|
24
|
-
default = (
|
|
25
|
-
field.default
|
|
26
|
-
if has_default
|
|
27
|
-
else (field.default_factory() if field.default_factory else None) # type: ignore
|
|
28
|
-
)
|
|
29
|
-
required = not has_default and field.default_factory is None # type: ignore
|
|
30
|
-
|
|
31
|
-
multiple = meta.get("multiple", _is_multiple(annotation))
|
|
32
|
-
is_flag = meta.get("is_flag", _is_bool_flag(annotation) and not multiple)
|
|
33
|
-
click_type = _resolve_click_type(annotation)
|
|
34
|
-
|
|
35
|
-
return click.Option(
|
|
36
|
-
param_decls=flags,
|
|
37
|
-
type=None if is_flag else click_type, # ← ключевой фикс
|
|
38
|
-
default=default,
|
|
39
|
-
required=required,
|
|
40
|
-
multiple=multiple,
|
|
41
|
-
is_flag=is_flag,
|
|
42
|
-
# ← не передаём flag_value вообще если None
|
|
43
|
-
**({"flag_value": meta["flag_value"]} if meta.get("flag_value") is not None else {}),
|
|
44
|
-
help=field.description,
|
|
45
|
-
show_default=meta.get("show_default", has_default),
|
|
46
|
-
prompt=meta.get("prompt", False),
|
|
47
|
-
confirmation_prompt=meta.get("confirmation_prompt", False),
|
|
48
|
-
prompt_required=meta.get("prompt_required", True),
|
|
49
|
-
hide_input=meta.get("hide_input", False),
|
|
50
|
-
count=meta.get("count", False),
|
|
51
|
-
allow_from_autoenv=meta.get("allow_from_autoenv", True),
|
|
52
|
-
hidden=meta.get("hidden", False),
|
|
53
|
-
show_choices=meta.get("show_choices", True),
|
|
54
|
-
show_envvar=meta.get("show_envvar", False),
|
|
55
|
-
deprecated=meta.get("deprecated", False),
|
|
56
|
-
)
|
panda_cli-0.1.2/_temp/tools.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import types
|
|
4
|
-
import typing as t
|
|
5
|
-
|
|
6
|
-
import click
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
_TYPE_MAP_CLICK: t.Final[dict[t.Any, t.Any]] = {
|
|
11
|
-
str: click.STRING,
|
|
12
|
-
int: click.INT,
|
|
13
|
-
float: click.FLOAT,
|
|
14
|
-
bool: click.BOOL,
|
|
15
|
-
bytes: click.STRING,
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _resolve_click_type(annotation: t.Any) -> click.ParamType:
|
|
20
|
-
origin = t.get_origin(annotation)
|
|
21
|
-
|
|
22
|
-
# Optional[X] | X | None
|
|
23
|
-
if origin in (t.Union, types.UnionType):
|
|
24
|
-
args = [a for a in t.get_args(annotation) if a is not type(None)]
|
|
25
|
-
if len(args) == 1:
|
|
26
|
-
return _resolve_click_type(args[0])
|
|
27
|
-
|
|
28
|
-
# Literal["a", "b"] → Choice
|
|
29
|
-
if origin is t.Literal:
|
|
30
|
-
return click.Choice([str(c) for c in t.get_args(annotation)])
|
|
31
|
-
|
|
32
|
-
# list[X] / set[X] → тип элемента (multiple обрабатывается отдельно)
|
|
33
|
-
if origin in (list, tuple, set, frozenset):
|
|
34
|
-
args = t.get_args(annotation) # type: ignore
|
|
35
|
-
return _resolve_click_type(args[0]) if args else click.STRING
|
|
36
|
-
|
|
37
|
-
return _TYPE_MAP_CLICK.get(annotation, click.STRING)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _is_multiple(annotation: t.Any) -> bool:
|
|
41
|
-
origin = t.get_origin(annotation)
|
|
42
|
-
if origin in (t.Union, types.UnionType):
|
|
43
|
-
args = [a for a in t.get_args(annotation) if a is not type(None)]
|
|
44
|
-
return _is_multiple(args[0]) if len(args) == 1 else False
|
|
45
|
-
return origin in (list, tuple, set, frozenset)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _is_bool_flag(annotation: t.Any) -> bool:
|
|
49
|
-
origin = t.get_origin(annotation)
|
|
50
|
-
if origin in (t.Union, types.UnionType):
|
|
51
|
-
args = [a for a in t.get_args(annotation) if a is not type(None)]
|
|
52
|
-
return _is_bool_flag(args[0]) if len(args) == 1 else False
|
|
53
|
-
return annotation is bool
|
|
54
|
-
|
|
File without changes
|
panda_cli-0.1.2/tests/test.py
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
from clipy import BaseCommand, BaseGroup, Option
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class WebCommand(BaseCommand):
|
|
5
|
-
"""21312"""
|
|
6
|
-
host: str = "localhost"
|
|
7
|
-
port: int = 8000
|
|
8
|
-
is_bitch: bool = True
|
|
9
|
-
# doom: dict[str, str]
|
|
10
|
-
# door: list[str]
|
|
11
|
-
|
|
12
|
-
def __call__(self) -> None:
|
|
13
|
-
print(self.host, self.port, self.dim)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class BotCommand(BaseCommand):
|
|
17
|
-
"""21312"""
|
|
18
|
-
host: str = "localhost"
|
|
19
|
-
port: int = 8000
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class MainCli(BaseGroup):
|
|
23
|
-
__cli_name__ = "my-cli"
|
|
24
|
-
|
|
25
|
-
web: WebCommand
|
|
26
|
-
bot: BotCommand
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def main() -> None:
|
|
30
|
-
MainCli.run()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if __name__ == "__main__":
|
|
34
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|