panda-cli 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.
- panda_cli-0.1.0/.python-version +1 -0
- panda_cli-0.1.0/PKG-INFO +8 -0
- panda_cli-0.1.0/README.md +0 -0
- panda_cli-0.1.0/pyproject.toml +28 -0
- panda_cli-0.1.0/src/_temp/__init__.py +6 -0
- panda_cli-0.1.0/src/_temp/base_option.py +99 -0
- panda_cli-0.1.0/src/_temp/base_tree.py +127 -0
- panda_cli-0.1.0/src/_temp/option_contstructor.py +56 -0
- panda_cli-0.1.0/src/_temp/py.typed +0 -0
- panda_cli-0.1.0/src/_temp/tools.py +54 -0
- panda_cli-0.1.0/src/clipy1 copy/base_tree.py +127 -0
- panda_cli-0.1.0/src/clipy1 copy/option_contstructor.py +56 -0
- panda_cli-0.1.0/src/pandpa-cli/__init__.py +6 -0
- panda_cli-0.1.0/src/pandpa-cli/base.py +177 -0
- panda_cli-0.1.0/src/pandpa-cli/completion.py +0 -0
- panda_cli-0.1.0/src/pandpa-cli/option.py +167 -0
- panda_cli-0.1.0/src/pandpa-cli/parameters.py +27 -0
- panda_cli-0.1.0/src/pandpa-cli/py.typed +0 -0
- panda_cli-0.1.0/tests/test.py +34 -0
- panda_cli-0.1.0/uv.lock +746 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
panda_cli-0.1.0/PKG-INFO
ADDED
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "panda-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ email = "meleshuk0804@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"click>=8.3.1",
|
|
12
|
+
"pydantic>=2.12.5",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/panda-cli"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"mypy>=1.19.1",
|
|
26
|
+
"ruff>=0.15.4",
|
|
27
|
+
"twine>=6.2.0",
|
|
28
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
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]
|
|
@@ -0,0 +1,127 @@
|
|
|
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)
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
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)
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
)
|