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.
- turbolaunch-0.1.0/LICENSE.txt +20 -0
- turbolaunch-0.1.0/PKG-INFO +80 -0
- turbolaunch-0.1.0/README.md +53 -0
- turbolaunch-0.1.0/pyproject.toml +49 -0
- turbolaunch-0.1.0/setup.cfg +4 -0
- turbolaunch-0.1.0/src/turbolaunch/__init__.py +33 -0
- turbolaunch-0.1.0/src/turbolaunch/constants.py +6 -0
- turbolaunch-0.1.0/src/turbolaunch/convert.py +237 -0
- turbolaunch-0.1.0/src/turbolaunch/parse.py +225 -0
- turbolaunch-0.1.0/src/turbolaunch/program.py +158 -0
- turbolaunch-0.1.0/src/turbolaunch/test_all.py +25 -0
- turbolaunch-0.1.0/src/turbolaunch/types.py +62 -0
- turbolaunch-0.1.0/src/turbolaunch/util.py +83 -0
- turbolaunch-0.1.0/src/turbolaunch.egg-info/PKG-INFO +80 -0
- turbolaunch-0.1.0/src/turbolaunch.egg-info/SOURCES.txt +16 -0
- turbolaunch-0.1.0/src/turbolaunch.egg-info/dependency_links.txt +1 -0
- turbolaunch-0.1.0/src/turbolaunch.egg-info/requires.txt +6 -0
- turbolaunch-0.1.0/src/turbolaunch.egg-info/top_level.txt +1 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
turbolaunch
|