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.
@@ -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
@@ -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
+ ```
@@ -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))))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "panda-cli"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,6 @@
1
+ from .base import BaseCommand, BaseGroup, Config
2
+ from .option import Option
3
+
4
+ __all__ = ["BaseCommand", "BaseGroup", "Option", "Config"]
5
+
6
+ __version__ = "0.2.0"
@@ -14,7 +14,7 @@ from panda_cli.option import (
14
14
 
15
15
 
16
16
  class Config(BaseModel):
17
- field_name_to_flag: bool = True
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 __call__(self) -> None:
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
- if cls.config.field_name_to_flag:
50
- flags.append(f"--{field_name.replace('_', '-')}")
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
- instance = cls(**cleaned)
117
- return instance()
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 _callback(**kwargs: t.Any) -> t.Any:
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=_callback,
179
+ callback=__exec__,
170
180
  invoke_without_command=True,
171
181
  help=cls.__doc__,
172
182
  )
@@ -3,8 +3,6 @@ import typing as t
3
3
 
4
4
 
5
5
  class DictParamType(click.ParamType):
6
- """KEY=VALUE пары, можно передавать multiple раз."""
7
-
8
6
  name = "KEY=VALUE"
9
7
 
10
8
  def __init__(self, key_type: click.ParamType, val_type: click.ParamType) -> None:
@@ -0,0 +1,11 @@
1
+ import typing as t
2
+
3
+
4
+ def is_dictable_tuple(obj: t.Any) -> bool:
5
+ if not isinstance(obj, tuple):
6
+ return False
7
+ try:
8
+ dict(obj)
9
+ return True
10
+ except (TypeError, ValueError):
11
+ return False
@@ -445,7 +445,7 @@ wheels = [
445
445
 
446
446
  [[package]]
447
447
  name = "panda-cli"
448
- version = "0.1.3"
448
+ version = "0.1.4"
449
449
  source = { editable = "." }
450
450
  dependencies = [
451
451
  { name = "click" },
panda_cli-0.1.3/PKG-INFO DELETED
@@ -1,8 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: panda-cli
3
- Version: 0.1.3
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
panda_cli-0.1.3/README.md DELETED
File without changes
@@ -1,6 +0,0 @@
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"
@@ -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
- )
@@ -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
-
@@ -1,6 +0,0 @@
1
- from .base import BaseCommand, BaseGroup
2
- from .option import Option
3
-
4
- __all__ = ["BaseCommand", "BaseGroup", "Option"]
5
-
6
- __version__ = "0.1.0"
File without changes
@@ -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