panda-cli 0.2.0__tar.gz → 0.3.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,199 @@
1
+ Metadata-Version: 2.5
2
+ Name: panda-cli
3
+ Version: 0.3.0
4
+ Summary: Declarative CLI framework: commands are Pydantic models, built on Click
5
+ Author-email: meleshuk0804@gmail.com
6
+ Keywords: cli,click,command-line,declarative,pydantic
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: click>=8.3.1
17
+ Requires-Dist: pydantic>=2.12.5
18
+ Description-Content-Type: text/markdown
19
+
20
+ # panda-cli
21
+
22
+ A lightweight declarative CLI framework built on top of Click and Pydantic.
23
+
24
+ Define commands as Python classes, describe arguments with type hints,
25
+ and build CLI trees using simple class composition.
26
+
27
+ ---
28
+
29
+ ## Features
30
+
31
+ - Declarative CLI commands
32
+ - Type-safe arguments
33
+ - Automatic CLI tree generation
34
+ - Built-in validation via Pydantic
35
+ - Validation errors reported in command-line terms (`--port`, not `port`)
36
+ - Minimal boilerplate
37
+ - Nested command groups
38
+
39
+ ---
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install panda-cli
45
+ ```
46
+
47
+
48
+ ## Quick Example
49
+ ```python
50
+ from panda_cli import BaseCommand, Option, BaseGroup
51
+ from panda_cli import Config
52
+
53
+
54
+ class WebCli(BaseCommand):
55
+ """ Web server """ # automaticly add to web --help
56
+ __cli_name__ = "web"
57
+
58
+ config=Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
59
+
60
+ host: str = Option(
61
+ "--host",
62
+ default="127.0.0.1",
63
+ description="Host to listen on",
64
+ )
65
+
66
+ port: int = Option(
67
+ "--port",
68
+ default=8000,
69
+ )
70
+
71
+ methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
72
+
73
+ map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
74
+
75
+ def __exec__(self) -> None:
76
+ """ Automaticly runs when command is called """
77
+
78
+ print(f"Host: {self.host}")
79
+ print(f"Port: {self.port}")
80
+ print(f"Methods: {self.methods}")
81
+ print(f"Map: {self.map}")
82
+
83
+
84
+ class MainCli(BaseGroup):
85
+ __cli_name__ = "main"
86
+
87
+
88
+ web: WebCli
89
+
90
+
91
+ def main():
92
+ MainCli.run()
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
97
+ ```
98
+
99
+
100
+ ## CLI
101
+ ```bash
102
+ uv run examples/web.py --help
103
+
104
+ Usage: main [OPTIONS] COMMAND [ARGS]...
105
+
106
+ Options:
107
+ --help Show this message and exit.
108
+
109
+ Commands:
110
+ web Web server
111
+ ```
112
+
113
+ ```bash
114
+ uv run examples/web.py web --help
115
+
116
+ Usage: main web [OPTIONS]
117
+
118
+ Web server
119
+
120
+ Options:
121
+ --host TEXT Host to listen on [default: 127.0.0.1]
122
+ --port INTEGER [default: 8000]
123
+ --methods Iterable TEXT... [required]
124
+ --map TEXT=TEXT [required]
125
+ --help Show this message and exit.
126
+ ```
127
+
128
+
129
+ ## Validation
130
+
131
+ Any Pydantic validator works, and failures are shown the way a CLI user
132
+ expects them — not as a traceback:
133
+
134
+ ```python
135
+ from pydantic import Field
136
+
137
+ class WebCli(BaseCommand):
138
+ __cli_name__ = "web"
139
+ port: int = Field(default=8000, ge=1, le=65535)
140
+
141
+ def __exec__(self) -> None: ...
142
+ ```
143
+
144
+ ```bash
145
+ $ web --port 99999
146
+
147
+ Usage: web [OPTIONS]
148
+ Try 'web --help' for help.
149
+
150
+ Error: Invalid value for '--port': Input should be less than or equal to 65535
151
+ ```
152
+
153
+
154
+ ## Environment variables
155
+
156
+ ```python
157
+ host: str = Option("--host", default="127.0.0.1", envvar="APP_HOST")
158
+ ```
159
+
160
+
161
+ ## Embedding into an existing Click app
162
+
163
+ `run()` is the entry point for `console_scripts`, but the built command is
164
+ also available on its own — useful for mounting a panda-cli tree inside a
165
+ Click application, and for testing:
166
+
167
+ ```python
168
+ import click
169
+
170
+ cli = click.Group("myapp")
171
+ cli.add_command(MainCli.as_click())
172
+ ```
173
+
174
+ `MainCli.spec()` returns the same tree as backend-independent dataclasses,
175
+ without touching Click at all.
176
+
177
+
178
+ ## Architecture
179
+
180
+ ```
181
+ declaration BaseCommand / BaseGroup — what the user writes
182
+ introspect model -> Spec — the only module that imports pydantic
183
+ backend Spec -> click.Command — the only module that imports click
184
+ runtime argv -> validate -> __exec__
185
+ ```
186
+
187
+ Subcommands are declared as fields (`web: WebCli`) but are not model fields:
188
+ the metaclass moves them into the command tree before Pydantic sees them, so
189
+ a group stays an ordinary model with its own options.
190
+
191
+
192
+ ## Development
193
+
194
+ ```bash
195
+ make test # run the test suite
196
+ make cov # coverage
197
+ make regression # tests guarding previously fixed bugs
198
+ make check # lint + tests
199
+ ```
@@ -0,0 +1,180 @@
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
+ - Validation errors reported in command-line terms (`--port`, not `port`)
17
+ - Minimal boilerplate
18
+ - Nested command groups
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install panda-cli
26
+ ```
27
+
28
+
29
+ ## Quick Example
30
+ ```python
31
+ from panda_cli import BaseCommand, Option, BaseGroup
32
+ from panda_cli import Config
33
+
34
+
35
+ class WebCli(BaseCommand):
36
+ """ Web server """ # automaticly add to web --help
37
+ __cli_name__ = "web"
38
+
39
+ config=Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
40
+
41
+ host: str = Option(
42
+ "--host",
43
+ default="127.0.0.1",
44
+ description="Host to listen on",
45
+ )
46
+
47
+ port: int = Option(
48
+ "--port",
49
+ default=8000,
50
+ )
51
+
52
+ methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
53
+
54
+ map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
55
+
56
+ def __exec__(self) -> None:
57
+ """ Automaticly runs when command is called """
58
+
59
+ print(f"Host: {self.host}")
60
+ print(f"Port: {self.port}")
61
+ print(f"Methods: {self.methods}")
62
+ print(f"Map: {self.map}")
63
+
64
+
65
+ class MainCli(BaseGroup):
66
+ __cli_name__ = "main"
67
+
68
+
69
+ web: WebCli
70
+
71
+
72
+ def main():
73
+ MainCli.run()
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
78
+ ```
79
+
80
+
81
+ ## CLI
82
+ ```bash
83
+ uv run examples/web.py --help
84
+
85
+ Usage: main [OPTIONS] COMMAND [ARGS]...
86
+
87
+ Options:
88
+ --help Show this message and exit.
89
+
90
+ Commands:
91
+ web Web server
92
+ ```
93
+
94
+ ```bash
95
+ uv run examples/web.py web --help
96
+
97
+ Usage: main web [OPTIONS]
98
+
99
+ Web server
100
+
101
+ Options:
102
+ --host TEXT Host to listen on [default: 127.0.0.1]
103
+ --port INTEGER [default: 8000]
104
+ --methods Iterable TEXT... [required]
105
+ --map TEXT=TEXT [required]
106
+ --help Show this message and exit.
107
+ ```
108
+
109
+
110
+ ## Validation
111
+
112
+ Any Pydantic validator works, and failures are shown the way a CLI user
113
+ expects them — not as a traceback:
114
+
115
+ ```python
116
+ from pydantic import Field
117
+
118
+ class WebCli(BaseCommand):
119
+ __cli_name__ = "web"
120
+ port: int = Field(default=8000, ge=1, le=65535)
121
+
122
+ def __exec__(self) -> None: ...
123
+ ```
124
+
125
+ ```bash
126
+ $ web --port 99999
127
+
128
+ Usage: web [OPTIONS]
129
+ Try 'web --help' for help.
130
+
131
+ Error: Invalid value for '--port': Input should be less than or equal to 65535
132
+ ```
133
+
134
+
135
+ ## Environment variables
136
+
137
+ ```python
138
+ host: str = Option("--host", default="127.0.0.1", envvar="APP_HOST")
139
+ ```
140
+
141
+
142
+ ## Embedding into an existing Click app
143
+
144
+ `run()` is the entry point for `console_scripts`, but the built command is
145
+ also available on its own — useful for mounting a panda-cli tree inside a
146
+ Click application, and for testing:
147
+
148
+ ```python
149
+ import click
150
+
151
+ cli = click.Group("myapp")
152
+ cli.add_command(MainCli.as_click())
153
+ ```
154
+
155
+ `MainCli.spec()` returns the same tree as backend-independent dataclasses,
156
+ without touching Click at all.
157
+
158
+
159
+ ## Architecture
160
+
161
+ ```
162
+ declaration BaseCommand / BaseGroup — what the user writes
163
+ introspect model -> Spec — the only module that imports pydantic
164
+ backend Spec -> click.Command — the only module that imports click
165
+ runtime argv -> validate -> __exec__
166
+ ```
167
+
168
+ Subcommands are declared as fields (`web: WebCli`) but are not model fields:
169
+ the metaclass moves them into the command tree before Pydantic sees them, so
170
+ a group stays an ordinary model with its own options.
171
+
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ make test # run the test suite
177
+ make cov # coverage
178
+ make regression # tests guarding previously fixed bugs
179
+ make check # lint + tests
180
+ ```
@@ -0,0 +1,47 @@
1
+ from panda_cli import BaseCommand, Option, BaseGroup
2
+ from panda_cli import Config
3
+
4
+
5
+ class WebCli(BaseCommand):
6
+ """Web server""" # automaticly add to web --help
7
+
8
+ __cli_name__ = "web"
9
+
10
+ config = Config(fields_name_to_flag=True) # if True auto gen flag by field name host -> --host
11
+
12
+ host: str = Option(
13
+ "--host",
14
+ default="127.0.0.1",
15
+ description="Host to listen on",
16
+ )
17
+
18
+ port: int = Option(
19
+ "--port",
20
+ default=8000,
21
+ )
22
+
23
+ methods: list[str] # --methods GET --methods POST -> ['GET', 'POST']
24
+
25
+ map: dict[str, str] # --map key=value --map key2=value2 -> {'key': 'value', 'key2': 'value2'}
26
+
27
+ def __exec__(self) -> None:
28
+ """Automaticly runs when command is called"""
29
+
30
+ print(f"Host: {self.host}")
31
+ print(f"Port: {self.port}")
32
+ print(f"Methods: {self.methods}")
33
+ print(f"Map: {self.map}")
34
+
35
+
36
+ class MainCli(BaseGroup):
37
+ __cli_name__ = "main"
38
+
39
+ web: WebCli
40
+
41
+
42
+ def main():
43
+ MainCli.run()
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -1,4 +1,4 @@
1
- .PHONY: major minor patch
1
+ .PHONY: major minor patch test cov regression lint check
2
2
 
3
3
  PYPROJECT := pyproject.toml
4
4
  INIT_FILE := src/panda_cli/__init__.py
@@ -22,4 +22,19 @@ minor:
22
22
  $(call bump,$(MAJOR).$(shell echo $$(($(MINOR)+1))).0)
23
23
 
24
24
  patch:
25
- $(call bump,$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))))
25
+ $(call bump,$(MAJOR).$(MINOR).$(shell echo $$(($(PATCH)+1))))
26
+
27
+ test:
28
+ uv run pytest
29
+
30
+ cov:
31
+ uv run pytest --cov
32
+
33
+ regression: # тесты на закрытые баги
34
+ uv run pytest -m regression -rX
35
+
36
+ lint:
37
+ uv run ruff check .
38
+ uv run ruff format --check .
39
+
40
+ check: lint test
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "panda-cli"
3
+ version = "0.3.0"
4
+ description = "Declarative CLI framework: commands are Pydantic models, built on Click"
5
+ readme = "README.md"
6
+ authors = [
7
+ { email = "meleshuk0804@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ keywords = ["cli", "click", "pydantic", "command-line", "declarative"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "click>=8.3.1",
23
+ "pydantic>=2.12.5",
24
+ ]
25
+
26
+ [build-system]
27
+ requires = ["hatchling"]
28
+ build-backend = "hatchling.build"
29
+
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/panda_cli"]
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "mypy>=1.19.1",
37
+ "pytest>=9.1.1",
38
+ "pytest-cov>=7.1.0",
39
+ "ruff>=0.15.4",
40
+ "twine>=6.2.0",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ addopts = "-q --strict-markers --strict-config"
46
+ markers = [
47
+ "regression: тест на конкретный закрытый баг — не удалять при рефакторинге",
48
+ ]
49
+ xfail_strict = true
50
+
51
+ [tool.coverage.run]
52
+ source = ["panda_cli"]
53
+ branch = true
54
+
55
+ [tool.coverage.report]
56
+ show_missing = true
57
+ exclude_lines = ["pragma: no cover", "raise NotImplementedError", "if t.TYPE_CHECKING:"]
58
+
59
+ [tool.ruff]
60
+ line-length = 100
61
+ src = [".", "src"]
62
+ exclude = ["_temp", "dist", ".venv"]
63
+
64
+ [tool.ruff.lint]
65
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
66
+ ignore = [
67
+ "RUF003", # кириллица в комментариях
68
+ "RUF001", # кириллица в строках — здесь это нормально
69
+ "RUF002", # кириллица в docstring'ах
70
+ "RUF012", # ClassVar для mutable-атрибутов: ложное срабатывание на полях pydantic
71
+ ]
72
+
73
+ [tool.ruff.lint.isort]
74
+ known-first-party = ["panda_cli", "tests"]
75
+
76
+ [tool.ruff.lint.per-file-ignores]
77
+ # пример повторяет README дословно — форматирование импортов оставлено как в доке
78
+ "examples/*.py" = ["I001"]
79
+
80
+ [tool.mypy]
81
+ python_version = "3.12"
82
+ strict = true
83
+ files = ["src/panda_cli", "tests"]
@@ -0,0 +1,15 @@
1
+ from .base import BaseCommand, BaseGroup, Config
2
+ from .errors import BindingError, DeclarationError, PandaCLIError
3
+ from .option import Option
4
+
5
+ __all__ = [
6
+ "BaseCommand",
7
+ "BaseGroup",
8
+ "BindingError",
9
+ "Config",
10
+ "DeclarationError",
11
+ "Option",
12
+ "PandaCLIError",
13
+ ]
14
+
15
+ __version__ = "0.3.0"
@@ -0,0 +1,79 @@
1
+ """Аннотация Python → описание значения параметра.
2
+
3
+ Чистый слой: только `typing`, никакого click и никакого pydantic.
4
+ Развёртка `Optional` живёт здесь в единственном экземпляре — раньше она была
5
+ скопирована в четырёх функциях, и в пятом месте про неё забыли.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import types
11
+ import typing as t
12
+ from dataclasses import dataclass
13
+
14
+ from panda_cli.spec import (
15
+ Arity,
16
+ ChoiceType,
17
+ PairType,
18
+ ScalarType,
19
+ TypeSpec,
20
+ UnionType,
21
+ )
22
+
23
+ _SEQUENCE_ORIGINS: t.Final = (list, tuple, set, frozenset)
24
+
25
+
26
+ def unwrap_optional(annotation: t.Any) -> t.Any:
27
+ """`X | None` → `X`. Всё остальное возвращается как есть."""
28
+ if t.get_origin(annotation) not in (t.Union, types.UnionType):
29
+ return annotation
30
+ args = [a for a in t.get_args(annotation) if a is not type(None)]
31
+ return args[0] if len(args) == 1 else annotation
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Shape:
36
+ """Что аннотация говорит о параметре."""
37
+
38
+ type: TypeSpec
39
+ arity: Arity
40
+ collect_pairs: bool
41
+
42
+
43
+ def describe(annotation: t.Any) -> Shape:
44
+ """Разобрать аннотацию поля в тип значения и арность."""
45
+ ann = unwrap_optional(annotation)
46
+ origin = t.get_origin(ann)
47
+
48
+ if origin is t.Literal:
49
+ choices = tuple(str(c) for c in t.get_args(ann))
50
+ return Shape(ChoiceType(choices), Arity.SINGLE, False)
51
+
52
+ if origin in (t.Union, types.UnionType):
53
+ members = tuple(_scalar(a) for a in t.get_args(ann) if a is not type(None))
54
+ return Shape(UnionType(members), Arity.SINGLE, False)
55
+
56
+ # Голые `dict` / `list` не имеют __origin__, но означают то же самое.
57
+ if origin is dict or ann is dict:
58
+ args = t.get_args(ann)
59
+ key = _scalar(args[0]) if args else ScalarType(str)
60
+ value = _scalar(args[1]) if len(args) > 1 else ScalarType(str)
61
+ return Shape(PairType(key, value), Arity.MULTIPLE, True)
62
+
63
+ if origin in _SEQUENCE_ORIGINS or ann in _SEQUENCE_ORIGINS:
64
+ args = t.get_args(ann)
65
+ inner = describe(args[0]).type if args else ScalarType(str)
66
+ return Shape(inner, Arity.MULTIPLE, False)
67
+
68
+ if ann is bool:
69
+ return Shape(ScalarType(bool), Arity.FLAG, False)
70
+
71
+ return Shape(_scalar(ann), Arity.SINGLE, False)
72
+
73
+
74
+ def _scalar(annotation: t.Any) -> TypeSpec:
75
+ """Тип одного значения — без учёта контейнера."""
76
+ ann = unwrap_optional(annotation)
77
+ if t.get_origin(ann) is t.Literal:
78
+ return ChoiceType(tuple(str(c) for c in t.get_args(ann)))
79
+ return ScalarType(ann)
@@ -0,0 +1,5 @@
1
+ """Бэкенды: спецификация → конкретный парсер командной строки."""
2
+
3
+ from panda_cli.backends.click_backend import build
4
+
5
+ __all__ = ["build"]