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.
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: panda-cli
3
+ Version: 0.1.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
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,6 @@
1
+ from .base_tree import BaseCommand, BaseGroup
2
+ from .base_option import Option
3
+
4
+ __all__ = ["BaseCommand", "BaseGroup", "Option"]
5
+
6
+ __version__ = "0.1.0"
@@ -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
+ )
@@ -0,0 +1,6 @@
1
+ from .base import BaseCommand, BaseGroup
2
+ from .option import Option
3
+
4
+ __all__ = ["BaseCommand", "BaseGroup", "Option"]
5
+
6
+ __version__ = "0.1.0"