turbolaunch 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,20 @@
1
+ Copyright 2025 Sam Vervaeck
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the “Software”), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
20
+
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: turbolaunch
3
+ Version: 0.1.0
4
+ Summary: The definitive command-line arguments parser
5
+ Author-email: Sam Vervaeck <samvv@pm.me>
6
+ Maintainer-email: Sam Vervaeck <samvv@pm.me>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/samvv/turbolaunch
9
+ Project-URL: Bug Reports, https://github.com/samvv/turbolaunch/issues
10
+ Project-URL: Source, https://github.com/samvv/turbolaunch/
11
+ Keywords: cli,argparse,library
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE.txt
22
+ Provides-Extra: dev
23
+ Requires-Dist: check-manifest; extra == "dev"
24
+ Provides-Extra: test
25
+ Requires-Dist: coverage; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ TurboLaunch
29
+ ===========
30
+
31
+ TurboLaunch is a CLI parser for Python programs that requires almost no setup.
32
+
33
+ - ✅ Run Python functions directly as CLI commands
34
+ - ✅ Support for enums and other built-in types
35
+ - 🚧 Help messages derived from docstrings
36
+ - 🚧 Plugins to extend the functionality of this library
37
+ - 🚧 Fuzzing of as much code paths as possible to ensure quality
38
+
39
+ TurboLaunch was initially written as part of the [Mage project](https://github.com/samvv/mage).
40
+
41
+ ## Quick Start
42
+
43
+ Simply create or edit a module with the following code:
44
+
45
+ ```py
46
+ def main() -> int:
47
+ import turbolaunch
48
+ turbolaunch.launch(__name__)
49
+ ```
50
+
51
+ In your `pyproject.toml`-file, you'd have something like this:
52
+
53
+ ```toml
54
+ [project.scripts]
55
+ mycommand = "mylibrary:main"
56
+ ```
57
+
58
+ That's it!
59
+
60
+ Now if you would like to have command `test` which e.g. takes a filename and an optional `foo` flag:
61
+
62
+ ```py
63
+ def test(filename: str, foo: bool = False) -> int:
64
+ if foo:
65
+ print("'foo' is enabled")
66
+ print(f"Reading {filename}")
67
+ return 0
68
+ ```
69
+
70
+ The above code would be run like this:
71
+
72
+ ```
73
+ mycommand test loremipsum.txt --foo
74
+ ```
75
+
76
+ More options, such as programmatic usage and plugins will come soon.
77
+
78
+ ## License
79
+
80
+ This software is licensed under the MIT license.
@@ -0,0 +1,53 @@
1
+ TurboLaunch
2
+ ===========
3
+
4
+ TurboLaunch is a CLI parser for Python programs that requires almost no setup.
5
+
6
+ - ✅ Run Python functions directly as CLI commands
7
+ - ✅ Support for enums and other built-in types
8
+ - 🚧 Help messages derived from docstrings
9
+ - 🚧 Plugins to extend the functionality of this library
10
+ - 🚧 Fuzzing of as much code paths as possible to ensure quality
11
+
12
+ TurboLaunch was initially written as part of the [Mage project](https://github.com/samvv/mage).
13
+
14
+ ## Quick Start
15
+
16
+ Simply create or edit a module with the following code:
17
+
18
+ ```py
19
+ def main() -> int:
20
+ import turbolaunch
21
+ turbolaunch.launch(__name__)
22
+ ```
23
+
24
+ In your `pyproject.toml`-file, you'd have something like this:
25
+
26
+ ```toml
27
+ [project.scripts]
28
+ mycommand = "mylibrary:main"
29
+ ```
30
+
31
+ That's it!
32
+
33
+ Now if you would like to have command `test` which e.g. takes a filename and an optional `foo` flag:
34
+
35
+ ```py
36
+ def test(filename: str, foo: bool = False) -> int:
37
+ if foo:
38
+ print("'foo' is enabled")
39
+ print(f"Reading {filename}")
40
+ return 0
41
+ ```
42
+
43
+ The above code would be run like this:
44
+
45
+ ```
46
+ mycommand test loremipsum.txt --foo
47
+ ```
48
+
49
+ More options, such as programmatic usage and plugins will come soon.
50
+
51
+ ## License
52
+
53
+ This software is licensed under the MIT license.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "turbolaunch"
7
+ version = "0.1.0"
8
+ description = "The definitive command-line arguments parser"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ keywords = ["cli", "argparse", "library"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+
16
+ # Indicate who your project is intended for
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Software Development :: Libraries",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+
21
+ # Specify the Python versions you support here. In particular, ensure
22
+ # that you indicate you support Python 3. These classifiers are *not*
23
+ # checked by "pip install". See instead "requires-python" key in this file.
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3 :: Only",
27
+ ]
28
+
29
+ [[project.authors]]
30
+ name = "Sam Vervaeck"
31
+ email = "samvv@pm.me"
32
+
33
+ [[project.maintainers]]
34
+ name = "Sam Vervaeck"
35
+ email = "samvv@pm.me"
36
+
37
+ [project.optional-dependencies]
38
+ dev = ["check-manifest"]
39
+ test = ["coverage"]
40
+
41
+ [project.urls]
42
+ "Homepage" = "https://github.com/samvv/turbolaunch"
43
+ "Bug Reports" = "https://github.com/samvv/turbolaunch/issues"
44
+ "Source" = "https://github.com/samvv/turbolaunch/"
45
+
46
+ [tool.setuptools]
47
+ # If there are data files included in your packages that need to be
48
+ # installed, specify them here.
49
+ #package-data = { "sample" = ["*.dat"] }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+
2
+ from types import ModuleType
3
+
4
+ from .program import *
5
+ from .parse import parse
6
+ from .convert import convert, convert_command
7
+
8
+ class App:
9
+
10
+ def __init__(self, name) -> None:
11
+ self.program = Program(name)
12
+
13
+ def command[F: Callable[..., int | None]](self, name: str | None = None) -> Callable[[F], F]:
14
+ def decorator(f: F) -> F:
15
+ self.program.add_subcommand(convert_command(f, name))
16
+ return f
17
+ return decorator
18
+
19
+ def __call__(self, argv: list[str]) -> int | None:
20
+ cmd, posargs, kwargs = parse(self.program, argv[1:])
21
+ assert(cmd.callback is not None)
22
+ return cmd.callback(*posargs, **kwargs)
23
+
24
+ def launch(mod: ModuleType | str, name: str | None = None) -> int:
25
+
26
+ cmd, posargs, kwargs = parse(convert(mod, name=name))
27
+
28
+ if cmd.callback is None:
29
+ print('Command could not be executed. Perhaps you specified the wrong arguments?')
30
+ return 1
31
+
32
+ # Call the function in user-space
33
+ return cmd.callback(*posargs, **kwargs)
@@ -0,0 +1,6 @@
1
+
2
+ TRUE_MEMBER_NAME = '_true'
3
+
4
+ FALSE_MEMBER_NAME = '_false'
5
+
6
+ DEFAULT_MEMBER_NAME = '_default'
@@ -0,0 +1,237 @@
1
+
2
+ import inspect
3
+ from pathlib import Path
4
+ import sys
5
+ from types import ModuleType
6
+ import typing
7
+
8
+ from turbolaunch.constants import FALSE_MEMBER_NAME, TRUE_MEMBER_NAME
9
+ from turbolaunch.types import describe_type
10
+
11
+ from .program import *
12
+ from .util import IndentWriter, ident, is_boolish, to_kebab_case
13
+
14
+ def _inserter(name: str) -> ArgFn:
15
+ def func(key: str, value: Any, out: ArgValues) -> None:
16
+ if name not in out:
17
+ m = {}
18
+ out[name] = m
19
+ else:
20
+ m = out[name]
21
+ m[key] = value
22
+ return func
23
+
24
+ def _append(name: str, value: Any, out: ArgValues) -> None:
25
+ if not name in out:
26
+ out[name] = []
27
+ out[name].append(value)
28
+
29
+ def _print_help_loop(cmd: Command, out: IndentWriter, depth: int) -> None:
30
+ out.write(f'{cmd.name}')
31
+ if cmd.description is not None:
32
+ out.write(' ')
33
+ out.write(cmd.description)
34
+ out.writeln()
35
+ out.indent()
36
+ if cmd.count_arguments() > 0 and depth == 0:
37
+ out.writeln('Arguments and flags:')
38
+ for arg in cmd.arguments():
39
+ if arg.is_flag:
40
+ if len(arg.name) == 1:
41
+ out.write(f'-{arg.name}')
42
+ else:
43
+ out.write(f'--{arg.name}')
44
+ elif arg.min_count == 0:
45
+ if arg.max_count == 1:
46
+ out.write(f'[{arg.name}]')
47
+ else:
48
+ out.write(f'[{arg.name}..]')
49
+ else:
50
+ if arg.max_count == 1:
51
+ out.write(f'<{arg.name}>')
52
+ else:
53
+ out.write(f'<{arg.name}..>')
54
+ out.write(' ')
55
+ out.write(describe_type(arg.ty))
56
+ out.writeln()
57
+ if cmd.count_subcommands() > 0:
58
+ out.writeln('\nSubcommands:')
59
+ out.indent()
60
+ for sub in cmd.subcommands():
61
+ _print_help_loop(sub, out, depth+1)
62
+ out.dedent()
63
+ out.dedent()
64
+
65
+ def _print_help(cmd: Command) -> None:
66
+ out = IndentWriter(sys.stderr)
67
+ _print_help_loop(cmd, out, 0)
68
+ exit(1)
69
+
70
+ def convert_command(proc, name: str | None = None):
71
+
72
+ sig = inspect.signature(proc)
73
+
74
+ cmd = Command(to_kebab_case(name or proc.__name__))
75
+
76
+ types = typing.get_type_hints(proc)
77
+
78
+ for name, param in sig.parameters.items():
79
+
80
+ ty = types[param.name]
81
+
82
+ arg = Argument(name)
83
+
84
+ arg.set_type(ty)
85
+
86
+ if param.default is not param.empty:
87
+ arg.set_default(param.default)
88
+ arg.set_optional()
89
+
90
+ if param.kind == param.POSITIONAL_ONLY or param.kind == param.POSITIONAL_OR_KEYWORD or param.kind == param.VAR_POSITIONAL:
91
+ arg.set_positional()
92
+ if param.kind == param.KEYWORD_ONLY or param.kind == param.POSITIONAL_OR_KEYWORD or param.kind == param.VAR_KEYWORD:
93
+ arg.set_flag()
94
+
95
+ if param.kind == param.VAR_KEYWORD:
96
+ if typing.get_origin(ty) is typing.Unpack:
97
+ args = typing.get_args(ty)
98
+ total = args[0].__total__
99
+ for k, v in typing.get_type_hints(args[0]).items():
100
+ arg = Argument(k)
101
+ arg.set_flag()
102
+ required = True
103
+ if typing.get_origin(v) is typing.NotRequired:
104
+ required = False
105
+ v = typing.get_args(v)[0]
106
+ arg.set_type(v)
107
+ if not total or not required:
108
+ arg.set_optional()
109
+ cmd.add_argument(arg)
110
+ continue
111
+ else:
112
+ arg.set_rest()
113
+ arg.set_callback(_inserter(name))
114
+ if param.kind == param.VAR_POSITIONAL:
115
+ arg.set_rest()
116
+ arg.set_no_max_count()
117
+ arg.set_callback(_append)
118
+
119
+ cmd.add_argument(arg)
120
+
121
+ cmd.set_callback(proc)
122
+
123
+ help_arg = Argument('help')
124
+ help_arg.set_flag()
125
+ help_arg.set_type(bool)
126
+ help_arg.set_callback(lambda key, value, out, cmd=cmd: _print_help(cmd))
127
+ cmd.add_argument(help_arg)
128
+
129
+ return cmd
130
+
131
+ def convert(mod: ModuleType | str, name: str | None = None) -> Program:
132
+
133
+ if name is None:
134
+ name = Path(sys.argv[0]).stem
135
+
136
+ if isinstance(mod, str):
137
+ import importlib
138
+ mod = importlib.import_module(mod)
139
+
140
+ prog = Program(name)
141
+
142
+ for name, proc in mod.__dict__.items():
143
+ if not name.startswith('_') and callable(proc) and proc.__module__ == mod.__name__:
144
+ prog.add_subcommand(convert_command(proc))
145
+
146
+ help_arg = Argument('help')
147
+ help_arg.set_flag()
148
+ help_arg.set_type(bool)
149
+ help_arg.set_callback(lambda key, value, out: _print_help(prog))
150
+ prog.add_argument(help_arg)
151
+
152
+ add_complements(prog)
153
+
154
+ return prog
155
+
156
+ def _boolish_setter(map: Callable[[Any], bool], name: str, inverted: bool = False) -> ArgFn:
157
+ def func(_: str, value: Any, out: ArgValues) -> None:
158
+ out[name] = map(value != inverted)
159
+ return func
160
+
161
+ def _bool_to_boolish_fn(ty: Any) -> Callable[[Any], bool]:
162
+ if ty is bool:
163
+ return ident
164
+ if hasattr(ty, TRUE_MEMBER_NAME) and hasattr(ty, FALSE_MEMBER_NAME):
165
+ return lambda x: getattr(ty, TRUE_MEMBER_NAME) if x else getattr(ty, FALSE_MEMBER_NAME)
166
+ raise ValueError(f'{ty} is not a valid boolean-like type')
167
+
168
+ def add_complements(prog: Program) -> None:
169
+ """
170
+ Generates additional arguments that are the inverse of existing arguments.
171
+
172
+ Example: `--enable-foo` will ackquire `--disable-foo` and both will work.
173
+
174
+ Two additional flags can also be enabled that enable/disable all flags at once.
175
+
176
+ Example: `--enable-all` and `--disable-all`
177
+ """
178
+
179
+ def visit(cmd: Command) -> None:
180
+
181
+ enable_flags = list[tuple[str, Callable[[Any], bool]]]()
182
+ disable_flags = list[tuple[str, Callable[[Any], bool]]]()
183
+
184
+ for arg in list(cmd.arguments()):
185
+ if not arg.is_rest and is_boolish(arg.ty):
186
+ if arg.name.startswith('enable_'):
187
+ suffix = arg.name[7:]
188
+ map = _bool_to_boolish_fn(arg.ty)
189
+ enable_flags.append((arg.name, map))
190
+ arg.set_callback(_boolish_setter(map, arg.name))
191
+ inv_arg = Argument('disable_' + suffix)
192
+ inv_arg.set_callback(_boolish_setter(map, arg.name, inverted=True))
193
+ inv_arg.set_optional()
194
+ inv_arg.set_type(arg.ty)
195
+ inv_arg.set_flag()
196
+ cmd.add_argument(inv_arg)
197
+ elif arg.name.startswith('disable_'):
198
+ suffix = arg.name[8:]
199
+ map = _bool_to_boolish_fn(arg.ty)
200
+ disable_flags.append((arg.name, map))
201
+ arg.set_callback(_boolish_setter(map, arg.name))
202
+ inv_arg = Argument('enable_' + suffix)
203
+ inv_arg.set_optional()
204
+ inv_arg.set_flag()
205
+ inv_arg.set_type(arg.ty)
206
+ inv_arg.set_callback(_boolish_setter(map, arg.name, inverted=True))
207
+ cmd.add_argument(inv_arg)
208
+
209
+ if enable_flags or disable_flags:
210
+ enable_all = Argument('enable_all')
211
+ enable_all.set_flag()
212
+ enable_all.set_optional()
213
+ enable_all.set_type(bool)
214
+ def enable_all_cb(_: str, value: bool, out: ArgValues) -> None:
215
+ for name, map in enable_flags:
216
+ out[name] = map(value)
217
+ for name, map in disable_flags:
218
+ out[name] = map(not value)
219
+ enable_all.set_callback(enable_all_cb)
220
+ cmd.add_argument(enable_all)
221
+ disable_all = Argument('disable_all')
222
+ disable_all.set_flag()
223
+ disable_all.set_optional()
224
+ disable_all.set_type(bool)
225
+ def disble_all_cb(_: str, value: bool, out: ArgValues) -> None:
226
+ for name, map in enable_flags:
227
+ out[name] = map(not value)
228
+ for name, map in disable_flags:
229
+ out[name] = map(value)
230
+ disable_all.set_callback(disble_all_cb)
231
+ cmd.add_argument(disable_all)
232
+
233
+ for subcmd in cmd.subcommands():
234
+ visit(subcmd)
235
+
236
+ visit(prog)
237
+
@@ -0,0 +1,225 @@
1
+ """
2
+ Convert a list of arguments to a function that is ready to be invoked
3
+ """
4
+ from enum import Enum, IntEnum, StrEnum
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ import sys
8
+ from types import UnionType
9
+ from typing import Any, Literal, TypeAliasType
10
+ import typing
11
+
12
+ from turbolaunch.constants import DEFAULT_MEMBER_NAME
13
+
14
+ from .program import ArgValues, Command, Program
15
+ from .types import get_cls, has_type
16
+ from .util import Peek, find, is_boolish, to_snake_case
17
+
18
+ class CLIError(RuntimeError):
19
+ pass
20
+
21
+ class ValueParseError(CLIError):
22
+
23
+ def __init__(self, text: str) -> None:
24
+ super().__init__(f"failed to parse {repr(text)} as a value")
25
+
26
+ class ValueMissingError(CLIError):
27
+
28
+ def __init__(self, name: str) -> None:
29
+ super().__init__(f"value missing for argument '{name}'")
30
+
31
+ class UnknownArgError(CLIError):
32
+
33
+ def __init__(self, arg: str) -> None:
34
+ super().__init__(f"unknown argument received: '{arg}'")
35
+
36
+ def _try_parse_value(text: str, types: Iterable[Any]) -> Any:
37
+ for ty_2 in types:
38
+ try:
39
+ return _parse_value(text, ty_2)
40
+ except ValueParseError:
41
+ pass
42
+ raise ValueParseError(f'unable to parse as any of {types}')
43
+
44
+ def _parse_value(text: str, ty: Any) -> Any:
45
+ if isinstance(ty, TypeAliasType):
46
+ assert(not ty.__type_params__)
47
+ ty = ty.__value__
48
+ origin = typing.get_origin(ty)
49
+ if origin is UnionType or origin is typing.Union:
50
+ args = typing.get_args(ty)
51
+ return _try_parse_value(text, args)
52
+ if origin is Literal:
53
+ args = typing.get_args(ty)
54
+ for arg in args:
55
+ try:
56
+ value = _parse_value(text, get_cls(arg))
57
+ except ValueParseError:
58
+ continue
59
+ if value != arg:
60
+ return value
61
+ raise ValueParseError(f"no literal types matched")
62
+ if ty is Path:
63
+ return Path(text)
64
+ if ty is float:
65
+ try:
66
+ return float(text)
67
+ except ValueError:
68
+ raise ValueParseError(text)
69
+ if ty is str:
70
+ return text
71
+ if ty is Any:
72
+ return _try_parse_value(text, [ bool, int, float, str ])
73
+ if ty is bool:
74
+ if text in [ 'on', 'true', '1' ]:
75
+ return True
76
+ if text in [ 'off', 'false', '0' ]:
77
+ return False
78
+ raise ValueParseError(text)
79
+ if ty is int:
80
+ try:
81
+ return int(text)
82
+ except ValueError:
83
+ raise ValueParseError(text)
84
+ if issubclass(ty, StrEnum):
85
+ try:
86
+ return ty(text)
87
+ except ValueError:
88
+ raise ValueParseError(text)
89
+ if issubclass(ty, IntEnum):
90
+ try:
91
+ return ty(int(text)) # type: ignore
92
+ except ValueError:
93
+ raise ValueParseError(text)
94
+ raise RuntimeError(f'parsing the given value according to {ty} is not supported')
95
+
96
+ def _get_type_default(ty: Any) -> Any:
97
+ if isinstance(ty, Enum):
98
+ if hasattr(ty, DEFAULT_MEMBER_NAME):
99
+ return getattr(ty, DEFAULT_MEMBER_NAME)
100
+
101
+ def parse(prog: Program, argv: list[str] | None = None) -> tuple[Command, list[Any], dict[str, Any]]:
102
+
103
+ if argv is None:
104
+ argv = sys.argv[1:]
105
+
106
+ # Variables used during processing of the arguments
107
+ cmd = prog
108
+ args = Peek(argv)
109
+ mapping: ArgValues = {}
110
+ pos_index = 0
111
+ pos_arg_count = 0
112
+
113
+ # Process arguments one by one
114
+ while True:
115
+
116
+ arg = args.get()
117
+
118
+ if arg is None:
119
+ break # We're at the end of the arguments list
120
+
121
+ if arg.startswith('-'): # We're dealing with a flag
122
+
123
+ i = find(arg, lambda ch: ch != '-')
124
+
125
+ if i is None:
126
+ raise UnknownArgError(arg)
127
+
128
+ try:
129
+ j = arg.index('=', i)
130
+ name = to_snake_case(arg[i:j])
131
+ value_str = arg[j+1:]
132
+ except ValueError:
133
+ name = to_snake_case(arg[i:])
134
+ value_str = None
135
+
136
+ arg_desc = cmd.get_flag(name)
137
+
138
+ if arg_desc is None:
139
+ arg_desc = cmd.rest_flags_argument
140
+ if arg_desc is None:
141
+ raise UnknownArgError(arg)
142
+
143
+ ty = arg_desc.ty
144
+
145
+ value = None
146
+
147
+ if value_str is not None:
148
+ value = _parse_value(value_str, ty)
149
+ else:
150
+ # `value` is still `None` here
151
+ next_arg = args.peek()
152
+ if next_arg is not None and not next_arg.startswith('-'):
153
+ try:
154
+ value = _parse_value(next_arg, ty)
155
+ args.get()
156
+ except ValueParseError:
157
+ pass # `value` remains `None` and lookahead is discarded
158
+
159
+ if value is None:
160
+ if is_boolish(arg_desc.ty) or arg_desc.is_rest_flags:
161
+ # Assume `True` in the cases where a boolean-like value is
162
+ # expected or when it could potentially be a boolean but we
163
+ # don't know for sure
164
+ value = True
165
+ elif arg_desc.default is not None:
166
+ # For all types except bool, attempt to assign the default
167
+ # value of the flag.
168
+ value = arg_desc.default
169
+ else:
170
+ # If the user didn't explicitly specify a default, maybe we
171
+ # can derive a default from the type.
172
+ default = _get_type_default(arg_desc.ty)
173
+ if default is not None:
174
+ value = default
175
+ elif arg_desc.min_count > 0: # If the flag was required
176
+ raise ValueMissingError(name)
177
+
178
+ arg_desc.parse_callback(name, value, mapping)
179
+
180
+ else: # We're dealing with a positional argument
181
+
182
+ # Try to parse the argument as a subcommand first
183
+ subcmd = cmd.get_subcommand(arg)
184
+ if subcmd is not None:
185
+ cmd = subcmd
186
+ pos_index = 0
187
+ pos_arg_count = 0
188
+ continue
189
+
190
+ # If that fails, process it as a plain positional argument
191
+
192
+ while True:
193
+ if pos_index >= len(cmd._pos_args):
194
+ raise UnknownArgError(arg)
195
+ arg_desc = cmd._pos_args[pos_index]
196
+ if pos_arg_count >= arg_desc.max_count:
197
+ pos_index += 1
198
+ pos_arg_count = 0
199
+ continue
200
+ value = _parse_value(arg, arg_desc.ty)
201
+ arg_desc.parse_callback(arg_desc.name, value, mapping)
202
+ pos_arg_count += 1
203
+ break
204
+
205
+ # TODO check that required arguments have been set
206
+
207
+ # Build positional arguments and keyword arguments from the mapping
208
+ posargs = []
209
+ kwargs = {}
210
+ for name, value in mapping.items():
211
+ arg_desc = cmd.get_argument(name)
212
+ assert(arg_desc is not None)
213
+ if arg_desc.is_positional:
214
+ if arg_desc.is_rest_pos:
215
+ posargs.extend(value)
216
+ else:
217
+ posargs.append(value)
218
+ else:
219
+ if arg_desc.is_rest_flags:
220
+ kwargs.update(value)
221
+ else:
222
+ kwargs[name] = value
223
+
224
+ return cmd, posargs, kwargs
225
+
@@ -0,0 +1,158 @@
1
+
2
+ from collections.abc import Callable, Iterable
3
+ from typing import Any
4
+ import math
5
+
6
+ type ArgValue = Any
7
+
8
+ type ArgValues = dict[str, ArgValue]
9
+
10
+ type ArgFn = Callable[[str, ArgValue, ArgValues], None]
11
+
12
+ ARGFLAGS_FLAG = 1
13
+ ARGFLAGS_POSITIONAL = 2
14
+ ARGFLAGS_REST = 4
15
+
16
+ def _set_arg_value(name: str, value: Any, out: ArgValues) -> None:
17
+ out[name] = value
18
+
19
+ def _are_bits_set(mask: int, bit: int) -> bool:
20
+ return mask & bit == bit
21
+
22
+ def _set_bit(mask: int, bit: int, enable: bool) -> int:
23
+ if enable:
24
+ return mask | bit
25
+ else:
26
+ return mask & ~bit
27
+
28
+ class Argument:
29
+
30
+ def __init__(self, name: str) -> None:
31
+ self.name = name
32
+ self.flags = 0
33
+ self.ty: Any = None
34
+ self.min_count = 1
35
+ self.max_count = 1
36
+ self.default: ArgValue | None = None
37
+ self.parse_callback: ArgFn = _set_arg_value
38
+
39
+ @property
40
+ def is_positional(self) -> bool:
41
+ return (self.flags & ARGFLAGS_POSITIONAL) > 0
42
+
43
+ @property
44
+ def is_flag(self) -> bool:
45
+ return (self.flags & ARGFLAGS_FLAG) > 0
46
+
47
+ @property
48
+ def is_rest(self) -> bool:
49
+ return _are_bits_set(self.flags, ARGFLAGS_REST)
50
+
51
+ @property
52
+ def is_rest_flags(self) -> bool:
53
+ return _are_bits_set(self.flags, ARGFLAGS_FLAG | ARGFLAGS_REST)
54
+
55
+ @property
56
+ def is_rest_pos(self) -> bool:
57
+ return _are_bits_set(self.flags, ARGFLAGS_POSITIONAL | ARGFLAGS_REST)
58
+
59
+ @property
60
+ def is_optional(self) -> bool:
61
+ return self.min_count == 0
62
+
63
+ def set_flag(self, enable = True) -> None:
64
+ self.flags = _set_bit(self.flags, ARGFLAGS_FLAG, enable)
65
+
66
+ def set_rest(self, enable = True) -> None:
67
+ self.flags = _set_bit(self.flags, ARGFLAGS_REST, enable)
68
+
69
+ def set_positional(self, enable = True) -> None:
70
+ self.flags = _set_bit(self.flags, ARGFLAGS_POSITIONAL, enable)
71
+
72
+ def set_default(self, value: ArgValue) -> None:
73
+ self.default = value
74
+
75
+ def set_no_max_count(self) -> None:
76
+ self.max_count = math.inf
77
+
78
+ def set_type(self, ty: Any) -> None:
79
+ self.ty = ty
80
+
81
+ def set_optional(self) -> None:
82
+ self.min_count = 0
83
+
84
+ def set_required(self) -> None:
85
+ if self.min_count == 0:
86
+ self.min_count = 1
87
+
88
+ def set_callback(self, cb: ArgFn) -> None:
89
+ self.parse_callback = cb
90
+
91
+ class Command:
92
+
93
+ def __init__(self, name: str) -> None:
94
+ self.name = name
95
+ self.description: str | None = None
96
+ self.callback: Callable[..., int] | None = None
97
+ self._subcommands = dict[str, Command]()
98
+ self._arguments = dict[str, Argument]()
99
+ self._pos_args: list[Argument] = []
100
+ self._rest_flags_argument = None
101
+ # self._arguments_by_flag = dict[str, Argument]()
102
+
103
+ def arguments(self) -> Iterable[Argument]:
104
+ return self._arguments.values()
105
+
106
+ def subcommands(self) -> 'Iterable[Command]':
107
+ return self._subcommands.values()
108
+
109
+ def add_subcommand(self, cmd: 'Command') -> None:
110
+ """
111
+ Add a subcommand to this command.
112
+
113
+ This class expects the command to not be mutated anymore after it has been added.
114
+ """
115
+ assert(cmd.name not in self._subcommands)
116
+ self._subcommands[cmd.name] = cmd
117
+
118
+ def count_arguments(self) -> int:
119
+ return len(self._arguments)
120
+
121
+ def add_argument(self, arg: Argument) -> None:
122
+ """
123
+ Add an argument to this command.
124
+
125
+ This class expects the argument to not be mutated anymore after it has been added.
126
+ """
127
+ assert(arg.name not in self._arguments)
128
+ self._arguments[arg.name] = arg
129
+ if arg.is_positional:
130
+ self._pos_args.append(arg)
131
+ if arg.is_rest_flags:
132
+ assert(self._rest_flags_argument is None)
133
+ self._rest_flags_argument = arg
134
+
135
+ @property
136
+ def rest_flags_argument(self) -> Argument | None:
137
+ return self._rest_flags_argument
138
+
139
+ def set_callback(self, callback: Callable[..., Any]) -> None:
140
+ self.callback = callback
141
+
142
+ def get_argument(self, name: str) -> Argument | None:
143
+ return self._arguments.get(name)
144
+
145
+ def count_subcommands(self) -> int:
146
+ return len(self._subcommands)
147
+
148
+ def get_flag(self, name: str) -> Argument | None:
149
+ arg = self.get_argument(name)
150
+ if arg is not None and arg.is_flag:
151
+ return arg
152
+
153
+ def get_subcommand(self, name: str) -> 'Command | None':
154
+ return self._subcommands.get(name)
155
+
156
+ class Program(Command):
157
+ pass
158
+
@@ -0,0 +1,25 @@
1
+
2
+ import pytest
3
+ from turbolaunch import App
4
+
5
+ def test_simple_arg():
6
+ app = App('myprog')
7
+ _bla = None
8
+ @app.command()
9
+ def foo(bla: str) -> None:
10
+ nonlocal _bla
11
+ _bla = bla
12
+ app([ 'myprog', 'foo', 'hello' ])
13
+ assert(_bla == 'hello')
14
+
15
+ def test_simple_arg_2():
16
+ app = App('myprog')
17
+ _bla = None
18
+ @app.command(name='bar')
19
+ def foo(bla: str) -> None:
20
+ nonlocal _bla
21
+ _bla = bla
22
+ with pytest.raises(RuntimeError):
23
+ app([ 'myprog', 'foo', 'hello' ])
24
+ app([ 'myprog', 'bar', 'hello' ])
25
+ assert(_bla == 'hello')
@@ -0,0 +1,62 @@
1
+
2
+ from collections.abc import Generator
3
+ import types
4
+ import typing
5
+ from typing import Any, Union
6
+
7
+
8
+ def describe_type(ty: Any) -> str:
9
+ if type(ty) is typing.TypeAliasType:
10
+ return describe_type(ty.__value__)
11
+ origin = typing.get_origin(ty)
12
+ if origin is None:
13
+ return ty.__name__
14
+ elif origin is typing.Union or origin is types.UnionType:
15
+ return ' | '.join(describe_type(arg) for arg in typing.get_args(ty))
16
+ elif origin is typing.Literal:
17
+ return ' | '.join(repr(lit) for lit in typing.get_args(ty))
18
+ else:
19
+ raise NotImplementedError(f"{ty} cannot be printed yet")
20
+
21
+
22
+ def is_optional(ty: Any) -> bool:
23
+ origin = typing.get_origin(ty)
24
+ if origin is types.UnionType:
25
+ args = typing.get_args(ty)
26
+ for arg in args:
27
+ if arg is None:
28
+ return True
29
+ return False
30
+
31
+
32
+ def unwrap_optional(ty: Any) -> Any:
33
+ origin = typing.get_origin(ty)
34
+ if origin is types.UnionType:
35
+ args = typing.get_args(ty)
36
+ return Union[*(arg for arg in args if arg is not None)]
37
+ return ty
38
+
39
+
40
+ def get_cls(value: Any) -> Any:
41
+ return value.__class__
42
+
43
+
44
+ def flatten_union_type(ty: Any) -> Generator[Any]:
45
+ origin = typing.get_origin(ty)
46
+ if origin is typing.Union or origin is types.UnionType:
47
+ for arg in typing.get_args(ty):
48
+ yield from flatten_union_type(arg)
49
+ else:
50
+ yield ty
51
+
52
+
53
+ def has_type(left: Any, right: Any) -> bool:
54
+ """
55
+ Check whether `right` occurs somewhere in `left`.
56
+
57
+ For instance:
58
+ has_type(int | bool | str, bool) == True
59
+ has_type(int | bool | str, float) == False
60
+ """
61
+ return right in flatten_union_type(left)
62
+
@@ -0,0 +1,83 @@
1
+
2
+ from collections.abc import Callable, Iterable, Sequence
3
+ import re
4
+ from typing import Any, Generic, TextIO, TypeVar
5
+ from io import StringIO
6
+
7
+ from turbolaunch.constants import FALSE_MEMBER_NAME, TRUE_MEMBER_NAME
8
+
9
+ def to_kebab_case(name: str) -> str:
10
+ return name.replace('_', '-')
11
+
12
+
13
+ def to_snake_case(name: str) -> str:
14
+ return name.replace('-', '_')
15
+
16
+
17
+ _T = TypeVar('_T')
18
+
19
+
20
+ class Peek(Generic[_T]):
21
+
22
+ def __init__(self, iter: Iterable[_T]) -> None:
23
+ self._elements = list(iter)
24
+ self._offset = 0
25
+
26
+ def get(self) -> _T | None:
27
+ if self._offset >= len(self._elements):
28
+ return None
29
+ element = self._elements[self._offset]
30
+ self._offset += 1
31
+ return element
32
+
33
+ def peek(self) -> _T | None:
34
+ return self._elements[self._offset] if self._offset < len(self._elements) else None
35
+
36
+
37
+ def find(l: Sequence[_T], pred: Callable[[_T], bool]) -> int | None:
38
+ for i, element in enumerate(l):
39
+ if pred(element):
40
+ return i
41
+
42
+
43
+ class IndentWriter:
44
+
45
+ def __init__(self, out: TextIO | None = None, indentation=' '):
46
+ if out is None:
47
+ out = StringIO()
48
+ self.output = out
49
+ self.at_blank_line = True
50
+ self.newline_count = 0
51
+ self.indent_level = 0
52
+ self.indentation = indentation
53
+ self._re_whitespace = re.compile('[\n\r\t ]')
54
+
55
+ def indent(self):
56
+ self.indent_level += 1
57
+
58
+ def dedent(self):
59
+ self.indent_level -= 1
60
+
61
+ def ensure_trailing_lines(self, count):
62
+ self.write('\n' * max(0, count - self.newline_count))
63
+
64
+ def write(self, text: str) -> None:
65
+ for ch in text:
66
+ if ch == '\n':
67
+ self.newline_count = self.newline_count + 1 if self.at_blank_line else 1
68
+ self.at_blank_line = True
69
+ elif self.at_blank_line and not self._re_whitespace.match(ch):
70
+ self.newline_count = 0
71
+ self.output.write(self.indentation * self.indent_level)
72
+ self.at_blank_line = False
73
+ self.output.write(ch)
74
+
75
+ def writeln(self, text: str = '') -> None:
76
+ self.write(text)
77
+ self.write('\n')
78
+
79
+ def ident(value: _T) -> _T:
80
+ return value
81
+
82
+ def is_boolish(ty: Any) -> bool:
83
+ return ty is bool or (hasattr(ty, TRUE_MEMBER_NAME) and hasattr(ty, FALSE_MEMBER_NAME))
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: turbolaunch
3
+ Version: 0.1.0
4
+ Summary: The definitive command-line arguments parser
5
+ Author-email: Sam Vervaeck <samvv@pm.me>
6
+ Maintainer-email: Sam Vervaeck <samvv@pm.me>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/samvv/turbolaunch
9
+ Project-URL: Bug Reports, https://github.com/samvv/turbolaunch/issues
10
+ Project-URL: Source, https://github.com/samvv/turbolaunch/
11
+ Keywords: cli,argparse,library
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE.txt
22
+ Provides-Extra: dev
23
+ Requires-Dist: check-manifest; extra == "dev"
24
+ Provides-Extra: test
25
+ Requires-Dist: coverage; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ TurboLaunch
29
+ ===========
30
+
31
+ TurboLaunch is a CLI parser for Python programs that requires almost no setup.
32
+
33
+ - ✅ Run Python functions directly as CLI commands
34
+ - ✅ Support for enums and other built-in types
35
+ - 🚧 Help messages derived from docstrings
36
+ - 🚧 Plugins to extend the functionality of this library
37
+ - 🚧 Fuzzing of as much code paths as possible to ensure quality
38
+
39
+ TurboLaunch was initially written as part of the [Mage project](https://github.com/samvv/mage).
40
+
41
+ ## Quick Start
42
+
43
+ Simply create or edit a module with the following code:
44
+
45
+ ```py
46
+ def main() -> int:
47
+ import turbolaunch
48
+ turbolaunch.launch(__name__)
49
+ ```
50
+
51
+ In your `pyproject.toml`-file, you'd have something like this:
52
+
53
+ ```toml
54
+ [project.scripts]
55
+ mycommand = "mylibrary:main"
56
+ ```
57
+
58
+ That's it!
59
+
60
+ Now if you would like to have command `test` which e.g. takes a filename and an optional `foo` flag:
61
+
62
+ ```py
63
+ def test(filename: str, foo: bool = False) -> int:
64
+ if foo:
65
+ print("'foo' is enabled")
66
+ print(f"Reading {filename}")
67
+ return 0
68
+ ```
69
+
70
+ The above code would be run like this:
71
+
72
+ ```
73
+ mycommand test loremipsum.txt --foo
74
+ ```
75
+
76
+ More options, such as programmatic usage and plugins will come soon.
77
+
78
+ ## License
79
+
80
+ This software is licensed under the MIT license.
@@ -0,0 +1,16 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ src/turbolaunch/__init__.py
5
+ src/turbolaunch/constants.py
6
+ src/turbolaunch/convert.py
7
+ src/turbolaunch/parse.py
8
+ src/turbolaunch/program.py
9
+ src/turbolaunch/test_all.py
10
+ src/turbolaunch/types.py
11
+ src/turbolaunch/util.py
12
+ src/turbolaunch.egg-info/PKG-INFO
13
+ src/turbolaunch.egg-info/SOURCES.txt
14
+ src/turbolaunch.egg-info/dependency_links.txt
15
+ src/turbolaunch.egg-info/requires.txt
16
+ src/turbolaunch.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ check-manifest
4
+
5
+ [test]
6
+ coverage
@@ -0,0 +1 @@
1
+ turbolaunch