panda-cli 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.2"
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"
@@ -4,7 +4,7 @@ import click
4
4
  from pydantic.fields import FieldInfo
5
5
  from pydantic_core import PydanticUndefined
6
6
 
7
- from clipy.option import (
7
+ from panda_cli.option import (
8
8
  get_field_option_meta,
9
9
  get_metavar,
10
10
  is_bool_flag,
@@ -14,7 +14,7 @@ from clipy.option import (
14
14
 
15
15
 
16
16
  class Config(BaseModel):
17
- 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 = (
@@ -61,10 +62,9 @@ class Base(BaseModel):
61
62
  is_flag = meta.get("is_flag", is_bool_flag(annotation) and not multiple)
62
63
  click_type = resolve_click_type(annotation)
63
64
 
64
-
65
65
  return click.Option(
66
66
  param_decls=flags,
67
- type=None if is_flag else click_type, # ← ключевой фикс
67
+ type=None if is_flag else click_type,
68
68
  default=default,
69
69
  required=required,
70
70
  multiple=multiple,
@@ -108,14 +108,24 @@ class BaseCommand(Base):
108
108
 
109
109
  @classmethod
110
110
  def _build_command(cls, _field_name: str | None = None) -> click.Command:
111
+ hints = t.get_type_hints(cls)
112
+
111
113
  def _callback(**kwargs: t.Any) -> t.Any:
112
114
  cleaned = {
113
115
  k: v
114
116
  for k, v in kwargs.items()
115
117
  if v is not None or cls.model_fields[k].default is not PydanticUndefined
116
118
  }
117
- instance = cls(**cleaned)
118
- 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__()
119
129
 
120
130
  return click.Command(
121
131
  name=cls._cli_name(_field_name),
@@ -150,8 +160,7 @@ class BaseGroup(Base):
150
160
  else:
151
161
  group_params.append(cls._field_to_option(field_name, field, ann))
152
162
 
153
- def _callback(**kwargs: t.Any) -> t.Any:
154
- # берём только поля-опции, без подкоманд
163
+ def __exec__(**kwargs: t.Any) -> t.Any:
155
164
  filtered = {k: v for k, v in kwargs.items() if k in option_fields}
156
165
  if not filtered:
157
166
  return
@@ -162,12 +171,12 @@ class BaseGroup(Base):
162
171
  converted[k] = dict(v)
163
172
  else:
164
173
  converted[k] = v
165
- return cls(**converted)()
174
+ return cls(**converted).__exec__()
166
175
 
167
176
  group = click.Group(
168
177
  name=cls._cli_name(_field_name),
169
178
  params=group_params,
170
- callback=_callback,
179
+ callback=__exec__,
171
180
  invoke_without_command=True,
172
181
  help=cls.__doc__,
173
182
  )
@@ -6,7 +6,7 @@ from pydantic import Field
6
6
  from pydantic.fields import FieldInfo
7
7
  from pydantic_core import PydanticUndefined
8
8
 
9
- from clipy.parameters import DictParamType
9
+ from panda_cli.parameters import DictParamType
10
10
 
11
11
 
12
12
  _CLICK_META_KEY: t.Final[str] = "__click_meta__"
@@ -96,6 +96,7 @@ _TYPE_MAP_CLICK: t.Final[dict[t.Any, t.Any]] = {
96
96
  bytes: click.STRING,
97
97
  }
98
98
 
99
+
99
100
  def resolve_click_type(annotation: t.Any) -> click.ParamType:
100
101
  origin = t.get_origin(annotation)
101
102
 
@@ -110,11 +111,15 @@ def resolve_click_type(annotation: t.Any) -> click.ParamType:
110
111
  if origin in (list, tuple, set, frozenset):
111
112
  inner = t.get_args(annotation) # type: ignore
112
113
  return resolve_click_type(inner[0]) if inner else click.STRING
113
-
114
+
114
115
  if origin is dict:
115
116
  d_args = t.get_args(annotation)
116
117
  key_t = _TYPE_MAP_CLICK.get(d_args[0], click.STRING) if d_args else click.STRING
117
- val_t = _TYPE_MAP_CLICK.get(d_args[1], click.STRING) if len(d_args) > 1 else click.STRING
118
+ val_t = (
119
+ _TYPE_MAP_CLICK.get(d_args[1], click.STRING)
120
+ if len(d_args) > 1
121
+ else click.STRING
122
+ )
118
123
  return DictParamType(key_t, val_t)
119
124
 
120
125
  return _TYPE_MAP_CLICK.get(annotation, click.STRING)
@@ -144,24 +149,32 @@ def get_metavar(annotation: t.Any) -> str | None:
144
149
  u_args = [a for a in t.get_args(annotation) if a is not type(None)]
145
150
  if len(u_args) == 1:
146
151
  return get_metavar(u_args[0])
147
- parts = [
148
- _TYPE_MAP_CLICK.get(a, click.STRING).name.upper()
149
- for a in u_args
150
- ]
152
+ parts = [_TYPE_MAP_CLICK.get(a, click.STRING).name.upper() for a in u_args]
151
153
  return " | ".join(parts) if parts else None
152
154
 
153
-
154
155
  # dict[K, V] → KEY=VALUE
155
156
  if origin is dict:
156
157
  d_args = t.get_args(annotation)
157
- k = _TYPE_MAP_CLICK.get(d_args[0], click.STRING).name.upper() if d_args else "KEY"
158
- v = _TYPE_MAP_CLICK.get(d_args[1], click.STRING).name.upper() if len(d_args) > 1 else "VALUE"
158
+ k = (
159
+ _TYPE_MAP_CLICK.get(d_args[0], click.STRING).name.upper()
160
+ if d_args
161
+ else "KEY"
162
+ )
163
+ v = (
164
+ _TYPE_MAP_CLICK.get(d_args[1], click.STRING).name.upper()
165
+ if len(d_args) > 1
166
+ else "VALUE"
167
+ )
159
168
  return f"{k}={v}"
160
169
 
161
170
  # list[X] / set[X] → TYPE...
162
171
  if origin in (list, set, frozenset):
163
172
  it_args = t.get_args(annotation)
164
- inner = _TYPE_MAP_CLICK.get(it_args[0], click.STRING).name.upper() if it_args else "TEXT"
173
+ inner = (
174
+ _TYPE_MAP_CLICK.get(it_args[0], click.STRING).name.upper()
175
+ if it_args
176
+ else "TEXT"
177
+ )
165
178
  return f"Iterable {inner}..."
166
179
 
167
180
  return None
@@ -1,11 +1,10 @@
1
1
  import click
2
2
  import typing as t
3
3
 
4
+
4
5
  class DictParamType(click.ParamType):
5
- """KEY=VALUE пары, можно передавать multiple раз."""
6
-
7
6
  name = "KEY=VALUE"
8
-
7
+
9
8
  def __init__(self, key_type: click.ParamType, val_type: click.ParamType) -> None:
10
9
  self.key_type = key_type
11
10
  self.val_type = val_type
@@ -24,4 +23,4 @@ class DictParamType(click.ParamType):
24
23
  return (
25
24
  self.key_type.convert(key.strip(), param, ctx),
26
25
  self.val_type.convert(val.strip(), param, ctx),
27
- )
26
+ )
@@ -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.1"
448
+ version = "0.1.4"
449
449
  source = { editable = "." }
450
450
  dependencies = [
451
451
  { name = "click" },
panda_cli-0.1.2/PKG-INFO DELETED
@@ -1,8 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: panda-cli
3
- Version: 0.1.2
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.2/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