simplifiedcli 1.0.0__py3-none-any.whl

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.
examples/basic.py ADDED
@@ -0,0 +1,49 @@
1
+ """Basic example."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from simpcli import Manager
8
+ from simpcli import get_logger
9
+
10
+ if TYPE_CHECKING: # pragma: no cover
11
+ from logging import Logger
12
+
13
+ from simpcli import Result
14
+
15
+ __version__: str = "0.0.1"
16
+
17
+ logger: Logger = get_logger(__name__)
18
+
19
+
20
+ manager: Manager = Manager(prog=__name__, version=__version__)
21
+
22
+
23
+ @manager.command()
24
+ def no_args() -> Result:
25
+ """Command with no arguments."""
26
+ logger.info("no_args")
27
+ return 0
28
+
29
+
30
+ @manager.command()
31
+ @manager.parameter("one", type=str)
32
+ @manager.parameter("two", type=int)
33
+ def positional_args(one: str, two: int) -> Result:
34
+ """Command with positional arguments."""
35
+ logger.info("positional_args %s %s", one, two)
36
+ return 0
37
+
38
+
39
+ @manager.command()
40
+ @manager.parameter("param", type=str)
41
+ @manager.parameter("-f", "--flag", action="store_true", type=bool)
42
+ def optional_args(param: str, flag: bool = False) -> Result: # noqa: FBT001, FBT002
43
+ """Command with optional arguments."""
44
+ logger.info("flags %s %s", param, flag)
45
+ return 0
46
+
47
+
48
+ if __name__ == "__main__":
49
+ manager.handle_main()
simpcli/__init__.py ADDED
@@ -0,0 +1,295 @@
1
+ """A simplified command line interface using argparse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from argparse import ArgumentError
8
+ from argparse import ArgumentParser
9
+ from collections import deque
10
+ from dataclasses import dataclass
11
+ from logging.handlers import TimedRotatingFileHandler
12
+ from typing import TYPE_CHECKING
13
+ from typing import NoReturn
14
+ from typing import Protocol
15
+ from typing import override
16
+
17
+ if TYPE_CHECKING: # pragma: no cover
18
+ # noinspection PyProtectedMember
19
+ from argparse import _SubParsersAction
20
+ from collections.abc import Callable
21
+ from collections.abc import Mapping
22
+ from collections.abc import Sequence
23
+ from logging import FileHandler
24
+ from logging import Formatter
25
+ from logging import Handler
26
+ from logging import Logger
27
+ from pathlib import Path
28
+ from typing import Any
29
+ from typing import Self
30
+
31
+
32
+ __all__: list[str] = [
33
+ "ARGUMENT_ERROR",
34
+ "NO_COMMAND_ERROR",
35
+ "NO_DEFAULT",
36
+ "Command",
37
+ "Manager",
38
+ "NoCommandError",
39
+ "NoDefault",
40
+ "Parameter",
41
+ "Result",
42
+ "get_logger",
43
+ "logger",
44
+ "logger_formatter",
45
+ "logger_handler",
46
+ "set_logger_file",
47
+ ]
48
+
49
+ type Result = int | str
50
+
51
+
52
+ class CommandFunc(Protocol):
53
+ __name__: str
54
+ parameters: Sequence[Parameter]
55
+
56
+ def __call__(self, *args: Any, **kwargs: Any) -> Result: # pragma: no cover # noqa: ANN401
57
+ pass
58
+
59
+
60
+ logger_formatter: Formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
61
+
62
+ logger_handler: Handler = logging.StreamHandler(sys.stdout)
63
+ logger_handler.setFormatter(logger_formatter)
64
+ logger_handler.setLevel(logging.INFO)
65
+
66
+ logger: Logger = logging.getLogger(__name__)
67
+ logger.propagate = False
68
+ logger.setLevel(logging.DEBUG)
69
+ logger.handlers = [logger_handler]
70
+
71
+
72
+ class NoDefault:
73
+ """Singleton to declare that a parameter has no default values."""
74
+
75
+ __instance__: NoDefault | None = None
76
+
77
+ def __new__(cls) -> Self:
78
+ """Create the singleton instance or return it."""
79
+ if cls.__instance__ is None:
80
+ cls.__instance__ = super().__new__(cls)
81
+ return cls.__instance__
82
+
83
+ @override
84
+ def __repr__(self) -> str:
85
+ return "NoDefault"
86
+
87
+ @classmethod
88
+ def remove_defaults(cls, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
89
+ """Remove NoDefault from kwargs."""
90
+ no_default: NoDefault = NoDefault()
91
+ return {k: v for k, v in kwargs.items() if v is not no_default}
92
+
93
+
94
+ NO_DEFAULT: NoDefault = NoDefault()
95
+ ARGUMENT_ERROR: int = 10
96
+ NO_COMMAND_ERROR: int = 11
97
+
98
+
99
+ def get_logger(name: str) -> Logger:
100
+ """Get a logger whose parent is simpcli.logger.
101
+
102
+ :param name: The name of the logger, usually __name__.
103
+ :return: The logger.
104
+ """
105
+ if name == __name__:
106
+ return logger
107
+
108
+ new_logger: Logger = logging.getLogger(name)
109
+ new_logger.parent = logger
110
+ return new_logger
111
+
112
+
113
+ def set_logger_file(file: Path | None = None, /, *, handler: FileHandler | None = None) -> None:
114
+ """Set the log FileHandler at the file provided or to the FileHandler provided."""
115
+ file_handler: FileHandler
116
+ if file is None:
117
+ if handler is None:
118
+ message: str = "Must supply either 'file' or 'handler'"
119
+ raise ValueError(message)
120
+ file_handler = handler
121
+ elif handler is None:
122
+ file.parent.mkdir(parents=True, exist_ok=True)
123
+ file_handler = TimedRotatingFileHandler(file, when="D", backupCount=7)
124
+ file_handler.setLevel(logging.INFO)
125
+ file_handler.setFormatter(logger_formatter)
126
+ else:
127
+ message: str = "'file' and 'handler' are mutually exclusive"
128
+ raise ValueError(message)
129
+
130
+ logger.handlers = [logger_handler, file_handler]
131
+
132
+
133
+ class NoCommandError(Exception):
134
+ """Exception raised when no command is provided."""
135
+
136
+ def __init__(self, message: str = "No command provided") -> None: # noqa: D107
137
+ super().__init__(message)
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class Command:
142
+ """A runnable command."""
143
+
144
+ kwargs: Mapping[str, Any]
145
+ parameters: Sequence[Parameter]
146
+ func: CommandFunc
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class Parameter:
151
+ """A parameter for a command."""
152
+
153
+ args: Sequence[str]
154
+ kwargs: Mapping[str, Any]
155
+
156
+
157
+ @dataclass(init=False, frozen=True, kw_only=True)
158
+ class Manager:
159
+ """Manages the command line interface."""
160
+
161
+ prog: str
162
+ version: str | None
163
+ kwargs: Mapping[str, Any]
164
+
165
+ global_parameters: list[Parameter]
166
+ commands: dict[str, Command]
167
+
168
+ def __init__(self, prog: str, version: str | None = None, **kwargs: Any) -> None: # noqa: ANN401
169
+ """Initialize the manager."""
170
+ object.__setattr__(self, "prog", prog)
171
+ object.__setattr__(self, "version", version)
172
+ object.__setattr__(self, "kwargs", kwargs)
173
+
174
+ object.__setattr__(self, "global_parameters", [])
175
+ object.__setattr__(self, "commands", {})
176
+
177
+ def global_parameter(self, name_or_flag: str, *name_or_flags: str, **kwargs: Any) -> None: # noqa: ANN401
178
+ """Add a global parameter to the manager."""
179
+ args: list[str] = [name_or_flag, *name_or_flags]
180
+
181
+ self.global_parameters.append(Parameter(args, kwargs))
182
+
183
+ def command[C: CommandFunc](self, **kwargs: Any) -> Callable[[C], C]: # noqa: ANN401
184
+ """Designate the function as a command."""
185
+
186
+ def decorator(func: C) -> C:
187
+ # TODO(Ryan): inspect.signature(func, annotation_format=Format.FORWARD_REF)
188
+ parameters: Sequence[Parameter] = []
189
+ if hasattr(func, "parameters"):
190
+ parameters: Sequence[Parameter] = list(func.parameters)
191
+ delattr(func, "parameters")
192
+ self.commands[func.__name__] = Command(func=func, kwargs=kwargs, parameters=parameters)
193
+
194
+ return func
195
+
196
+ return decorator
197
+
198
+ @staticmethod
199
+ def parameter[C: CommandFunc](
200
+ name_or_flag: str,
201
+ *name_or_flags: str,
202
+ **kwargs: Any, # noqa: ANN401
203
+ ) -> Callable[[C], C]:
204
+ """Add additional configuration to a command parameter."""
205
+ args: list[str] = [name_or_flag, *name_or_flags]
206
+
207
+ def decorator(func: C) -> C:
208
+ parameters: deque[Parameter] = getattr(func, "parameters", deque())
209
+ parameters.appendleft(Parameter(args, kwargs))
210
+ func.parameters = parameters
211
+ return func
212
+
213
+ return decorator
214
+
215
+ def run(self, *args: Any) -> Result: # noqa: ANN401
216
+ """Run the command line interface.
217
+
218
+ :param args: The raw arguments to pass to the command.
219
+ :return: The result of the command.
220
+ """
221
+ result: Result
222
+ try:
223
+ parser: ArgumentParser = self.create_parser()
224
+
225
+ cleaned_args: list[str] = list(map(str, args))
226
+ parsed_args: dict[str, Any] = dict(vars(parser.parse_args(cleaned_args)))
227
+
228
+ result = self.__handle_command(parsed_args)
229
+ except ArgumentError as e:
230
+ # Raised when ArgumentParser fails to parse the arguments.
231
+ logger.exception("Argparse error:", exc_info=e)
232
+ result = ARGUMENT_ERROR
233
+ except NoCommandError as e:
234
+ # Raised when no command is provided.
235
+ logger.exception("No command error:", exc_info=e)
236
+ result = NO_COMMAND_ERROR
237
+ except BaseException as e:
238
+ logger.exception("Unhandled Exception:", exc_info=e)
239
+ result = -1
240
+
241
+ return result
242
+
243
+ def handle_main(self) -> NoReturn:
244
+ """Handle main."""
245
+ args: list[str] = sys.argv[1:]
246
+ result: Result = self.run(*args)
247
+ sys.exit(result)
248
+
249
+ def create_parser(self) -> ArgumentParser:
250
+ """Create the ArgumentParser instance."""
251
+ prog: str = f"{self.prog}.exe" if getattr(sys, "frozen", False) else f"{self.prog}.py"
252
+ kwargs: dict[str, Any] = dict(self.kwargs)
253
+ kwargs.setdefault("exit_on_error", False)
254
+ parser: ArgumentParser = ArgumentParser(prog=prog, **kwargs)
255
+
256
+ parser.add_argument("--verbose", action="store_true")
257
+ if self.version is not None:
258
+ parser.add_argument("-v", "--version", action="version", version=self.version)
259
+
260
+ parameter: Parameter
261
+ for parameter in self.global_parameters:
262
+ parser.add_argument(*parameter.args, **parameter.kwargs)
263
+
264
+ # User Defined Commands
265
+ command_parser: _SubParsersAction = parser.add_subparsers(
266
+ dest="command",
267
+ metavar="command",
268
+ )
269
+
270
+ command_name: str | None
271
+ command: Command
272
+ for command_name, command in self.commands.items():
273
+ command_kwargs: dict[str, Any] = dict(command.kwargs)
274
+ command_kwargs.setdefault("description", command.func.__doc__)
275
+
276
+ user_command_parser: ArgumentParser = command_parser.add_parser(
277
+ command_name,
278
+ **command_kwargs,
279
+ )
280
+ parameter: Parameter
281
+ for parameter in command.parameters:
282
+ user_command_parser.add_argument(*parameter.args, **parameter.kwargs)
283
+
284
+ return parser
285
+
286
+ def __handle_command(self, parsed_args: dict[str, Any]) -> Result:
287
+ if parsed_args.pop("verbose", False):
288
+ logger_handler.setLevel(logging.DEBUG)
289
+
290
+ command_name: str = parsed_args.pop("command")
291
+ command: Command | None = self.commands.get(command_name, None)
292
+ if command is None:
293
+ raise NoCommandError
294
+
295
+ return command.func(**parsed_args)
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: simplifiedcli
3
+ Version: 1.0.0
4
+ Summary: A simplified command line interface.
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: dev
8
+ Requires-Dist: tox>=4.33.0; extra == "dev"
9
+ Requires-Dist: pytest>=8.4.2; extra == "dev"
10
+
11
+ # SimplifiedCLI
12
+
13
+ **A simple CLI for python applications.**
14
+
15
+ ## Basic Example:
16
+
17
+ ```python
18
+ """Basic example."""
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ from simpcli import Manager
25
+ from simpcli import get_logger
26
+
27
+ if TYPE_CHECKING: # pragma: no cover
28
+ from logging import Logger
29
+
30
+ from simpcli import Result
31
+
32
+ __version__: str = "0.0.1"
33
+
34
+ logger: Logger = get_logger(__name__)
35
+
36
+
37
+ manager: Manager = Manager(prog=__name__, version=__version__)
38
+
39
+
40
+ @manager.command()
41
+ def no_args() -> Result:
42
+ """Command with no arguments."""
43
+ logger.info("no_args")
44
+ return 0
45
+
46
+
47
+ @manager.command()
48
+ @manager.parameter("one", type=str)
49
+ @manager.parameter("two", type=int)
50
+ def positional_args(one: str, two: int) -> Result:
51
+ """Command with positional arguments."""
52
+ logger.info("positional_args %s %s", one, two)
53
+ return 0
54
+
55
+
56
+ @manager.command()
57
+ @manager.parameter("param", type=str)
58
+ @manager.parameter("-f", "--flag", action="store_true", type=bool)
59
+ def optional_args(param: str, flag: bool = False) -> Result: # noqa: FBT001, FBT002
60
+ """Command with optional arguments."""
61
+ logger.info("flags %s %s", param, flag)
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ manager.handle_main()
67
+
68
+ ```
@@ -0,0 +1,6 @@
1
+ examples/basic.py,sha256=UU2OZ6g1uwev8wNICgC6RYhBtacxcrGAUPPfsFgfqrA,1176
2
+ simpcli/__init__.py,sha256=oqX9lYMJaWL6pIVXJXi-ceGzIRCEZXfuCi2Jn6ZfE9c,9739
3
+ simplifiedcli-1.0.0.dist-info/METADATA,sha256=s6vyjjJrfFUKpHcIOr0OviIPhhS4YPjxCEVdT0bIiDg,1565
4
+ simplifiedcli-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ simplifiedcli-1.0.0.dist-info/top_level.txt,sha256=MbY8yAJZ9oLOlmyowH0iT6Vjhgtf7WyOVl4PpjCmTQ0,17
6
+ simplifiedcli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ examples
2
+ simpcli