panda-cli 0.1.3__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.3 → 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.3 → panda_cli-0.2.0}/src/panda_cli/base.py +21 -11
- {panda_cli-0.1.3 → panda_cli-0.2.0}/src/panda_cli/parameters.py +0 -2
- panda_cli-0.2.0/src/panda_cli/utils.py +11 -0
- {panda_cli-0.1.3 → panda_cli-0.2.0}/uv.lock +1 -1
- panda_cli-0.1.3/PKG-INFO +0 -8
- panda_cli-0.1.3/README.md +0 -0
- panda_cli-0.1.3/_temp/__init__.py +0 -6
- panda_cli-0.1.3/_temp/base_option.py +0 -99
- panda_cli-0.1.3/_temp/base_tree.py +0 -127
- panda_cli-0.1.3/_temp/option_contstructor.py +0 -56
- panda_cli-0.1.3/_temp/tools.py +0 -54
- panda_cli-0.1.3/src/panda_cli/__init__.py +0 -6
- panda_cli-0.1.3/src/panda_cli/py.typed +0 -0
- panda_cli-0.1.3/tests/test.py +0 -34
- {panda_cli-0.1.3 → panda_cli-0.2.0}/.python-version +0 -0
- {panda_cli-0.1.3 → panda_cli-0.2.0}/src/panda_cli/completion.py +0 -0
- {panda_cli-0.1.3 → panda_cli-0.2.0}/src/panda_cli/option.py +0 -0
- {panda_cli-0.1.3/_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))))
|
|
@@ -14,7 +14,7 @@ from panda_cli.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 = (
|
|
@@ -63,7 +64,7 @@ class Base(BaseModel):
|
|
|
63
64
|
|
|
64
65
|
return click.Option(
|
|
65
66
|
param_decls=flags,
|
|
66
|
-
type=None if is_flag else click_type,
|
|
67
|
+
type=None if is_flag else click_type,
|
|
67
68
|
default=default,
|
|
68
69
|
required=required,
|
|
69
70
|
multiple=multiple,
|
|
@@ -107,14 +108,24 @@ class BaseCommand(Base):
|
|
|
107
108
|
|
|
108
109
|
@classmethod
|
|
109
110
|
def _build_command(cls, _field_name: str | None = None) -> click.Command:
|
|
111
|
+
hints = t.get_type_hints(cls)
|
|
112
|
+
|
|
110
113
|
def _callback(**kwargs: t.Any) -> t.Any:
|
|
111
114
|
cleaned = {
|
|
112
115
|
k: v
|
|
113
116
|
for k, v in kwargs.items()
|
|
114
117
|
if v is not None or cls.model_fields[k].default is not PydanticUndefined
|
|
115
118
|
}
|
|
116
|
-
|
|
117
|
-
|
|
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__()
|
|
118
129
|
|
|
119
130
|
return click.Command(
|
|
120
131
|
name=cls._cli_name(_field_name),
|
|
@@ -149,8 +160,7 @@ class BaseGroup(Base):
|
|
|
149
160
|
else:
|
|
150
161
|
group_params.append(cls._field_to_option(field_name, field, ann))
|
|
151
162
|
|
|
152
|
-
def
|
|
153
|
-
# берём только поля-опции, без подкоманд
|
|
163
|
+
def __exec__(**kwargs: t.Any) -> t.Any:
|
|
154
164
|
filtered = {k: v for k, v in kwargs.items() if k in option_fields}
|
|
155
165
|
if not filtered:
|
|
156
166
|
return
|
|
@@ -161,12 +171,12 @@ class BaseGroup(Base):
|
|
|
161
171
|
converted[k] = dict(v)
|
|
162
172
|
else:
|
|
163
173
|
converted[k] = v
|
|
164
|
-
return cls(**converted)()
|
|
174
|
+
return cls(**converted).__exec__()
|
|
165
175
|
|
|
166
176
|
group = click.Group(
|
|
167
177
|
name=cls._cli_name(_field_name),
|
|
168
178
|
params=group_params,
|
|
169
|
-
callback=
|
|
179
|
+
callback=__exec__,
|
|
170
180
|
invoke_without_command=True,
|
|
171
181
|
help=cls.__doc__,
|
|
172
182
|
)
|
panda_cli-0.1.3/PKG-INFO
DELETED
panda_cli-0.1.3/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.3/_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.3/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
|
|
File without changes
|