magicli 2.0.2__tar.gz → 2.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.
- {magicli-2.0.2 → magicli-2.1.0}/.github/workflows/lint.yml +2 -2
- {magicli-2.0.2 → magicli-2.1.0}/.gitignore +1 -1
- {magicli-2.0.2 → magicli-2.1.0}/PKG-INFO +1 -1
- {magicli-2.0.2 → magicli-2.1.0}/magicli.egg-info/PKG-INFO +1 -1
- magicli-2.1.0/magicli.py +397 -0
- {magicli-2.0.2 → magicli-2.1.0}/pyproject.toml +9 -0
- magicli-2.1.0/tests/fixtures.py +72 -0
- magicli-2.1.0/tests/test_cli.py +135 -0
- {magicli-2.0.2 → magicli-2.1.0}/tests/test_magicli.py +21 -28
- {magicli-2.0.2 → magicli-2.1.0}/tests/test_parse_kwarg.py +10 -10
- {magicli-2.0.2 → magicli-2.1.0}/tests/test_parse_short_options.py +1 -2
- magicli-2.0.2/magicli.py +0 -314
- magicli-2.0.2/tests/fixtures.py +0 -54
- magicli-2.0.2/tests/test_cli.py +0 -62
- {magicli-2.0.2 → magicli-2.1.0}/.github/workflows/pytest.yml +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/.github/workflows/release.yml +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/LICENSE +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/README.md +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/magicli.egg-info/SOURCES.txt +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/magicli.egg-info/dependency_links.txt +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/magicli.egg-info/entry_points.txt +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/magicli.egg-info/requires.txt +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/magicli.egg-info/top_level.txt +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/setup.cfg +0 -0
- {magicli-2.0.2 → magicli-2.1.0}/tests/test_help.py +0 -0
|
@@ -13,7 +13,7 @@ jobs:
|
|
|
13
13
|
- uses: astral-sh/setup-uv@v5
|
|
14
14
|
with:
|
|
15
15
|
cache-dependency-glob: ""
|
|
16
|
-
- run: uv run --with flake8 flake8 magicli.py --extend-ignore=E501
|
|
16
|
+
- run: uv run --with flake8 flake8 magicli.py --extend-ignore=E203,E501
|
|
17
17
|
|
|
18
18
|
pylint:
|
|
19
19
|
runs-on: ubuntu-latest
|
|
@@ -22,7 +22,7 @@ jobs:
|
|
|
22
22
|
- uses: astral-sh/setup-uv@v5
|
|
23
23
|
with:
|
|
24
24
|
cache-dependency-glob: ""
|
|
25
|
-
- run: uv run --with pylint pylint
|
|
25
|
+
- run: uv run --with pylint pylint magicli.py
|
|
26
26
|
|
|
27
27
|
ruff:
|
|
28
28
|
runs-on: ubuntu-latest
|
magicli-2.1.0/magicli.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Magicli generates command-line interfaces from Python modules
|
|
3
|
+
by introspecting its functions and automatically parsing command-
|
|
4
|
+
line arguments based on function signatures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from functools import partial
|
|
14
|
+
from importlib import metadata
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(level=os.getenv("MAGICLI_LOG_LEVEL", "DEBUG"), format="%(message)s")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ParseArgvError(Exception):
|
|
21
|
+
"""Failure to parse argv into args and kwargs based on function parameters and docstring."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def magicli():
|
|
25
|
+
"""Parses command-line arguments and calls the appropriate function."""
|
|
26
|
+
name = Path(sys.argv[0]).name
|
|
27
|
+
argv = sys.argv[1:]
|
|
28
|
+
|
|
29
|
+
if name == "magicli":
|
|
30
|
+
raise SystemExit(call(cli, argv, sys.modules["magicli"]))
|
|
31
|
+
|
|
32
|
+
module = load_module(name)
|
|
33
|
+
|
|
34
|
+
if function := get_function_from_argv(argv, module, name.replace("-", "_")):
|
|
35
|
+
function()
|
|
36
|
+
else:
|
|
37
|
+
raise SystemExit(help_message(help_from_module, module))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_function_from_argv(argv, module, name):
|
|
41
|
+
"""Returns the module's function to call based on argv."""
|
|
42
|
+
if function := is_command(argv, module):
|
|
43
|
+
return partial(call, function, argv[1:], module, name)
|
|
44
|
+
if inspect.isfunction(function := module.__dict__.get(name)):
|
|
45
|
+
return partial(call, function, argv, module)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_command(argv, module):
|
|
50
|
+
"""
|
|
51
|
+
Checks if the first argument is a valid command in the module and returns
|
|
52
|
+
the function to call if `argv[0]` is public and not excluded in `__all__`.
|
|
53
|
+
"""
|
|
54
|
+
if (
|
|
55
|
+
argv
|
|
56
|
+
and not (command := argv[0].replace("-", "_")).startswith("_")
|
|
57
|
+
and command in module.__dict__.get("__all__", [command])
|
|
58
|
+
and inspect.isfunction(function := module.__dict__.get(command))
|
|
59
|
+
):
|
|
60
|
+
return function
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def call(function, argv, module=None, name=None):
|
|
65
|
+
"""
|
|
66
|
+
Converts arguments to function parameters and calls the function.
|
|
67
|
+
Displays a help message if an exception occurs.
|
|
68
|
+
"""
|
|
69
|
+
docstring = inspect.getdoc(function) or ""
|
|
70
|
+
parameters = inspect.signature(function).parameters
|
|
71
|
+
|
|
72
|
+
check_for_version(argv, parameters, docstring, module)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
args, kwargs = parse_argv(argv, parameters, docstring)
|
|
76
|
+
except ParseArgvError as exc:
|
|
77
|
+
raise SystemExit(help_message(help_from_function, function, name)) from exc
|
|
78
|
+
|
|
79
|
+
function(*args, **kwargs)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_argv(argv, parameters, docstring):
|
|
83
|
+
"""Convert argv into args and kwargs."""
|
|
84
|
+
parameter_list = list(parameters.values())
|
|
85
|
+
args, kwargs = [], {}
|
|
86
|
+
|
|
87
|
+
for key in (iter_argv := iter(argv)):
|
|
88
|
+
if key.startswith("--"):
|
|
89
|
+
left, right = parse_kwarg(key[2:], iter_argv, parameters)
|
|
90
|
+
kwargs[left] = right
|
|
91
|
+
elif key.startswith("-"):
|
|
92
|
+
parse_short_options(key[1:], docstring, iter_argv, parameters, kwargs)
|
|
93
|
+
else:
|
|
94
|
+
if (index := len(args)) >= len(parameter_list):
|
|
95
|
+
raise ParseArgvError
|
|
96
|
+
args.append(get_type(parameter_list[index])(key))
|
|
97
|
+
|
|
98
|
+
return args, kwargs
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_kwarg(key, argv, parameters):
|
|
102
|
+
"""
|
|
103
|
+
Parses a single keyword argument from command-line arguments.
|
|
104
|
+
Handles '=' syntax for inline values. Casts `NoneType` values to `True`
|
|
105
|
+
and boolean values to `not default`.
|
|
106
|
+
"""
|
|
107
|
+
key, value = key.split("=", 1) if "=" in key else (key, None)
|
|
108
|
+
key = key.replace("-", "_")
|
|
109
|
+
cast_to = get_type(parameters.get(key))
|
|
110
|
+
|
|
111
|
+
if value is None:
|
|
112
|
+
if cast_to is bool:
|
|
113
|
+
return key, not parameters[key].default
|
|
114
|
+
if cast_to is type(None):
|
|
115
|
+
return key, True
|
|
116
|
+
value = next(argv)
|
|
117
|
+
|
|
118
|
+
return key, value if cast_to is str else cast_to(value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_short_options(short_options, docstring, iter_argv, parameters, kwargs):
|
|
122
|
+
"""Converts short options into long options and casts into correct types."""
|
|
123
|
+
for i, short in enumerate(short_options):
|
|
124
|
+
long = short_to_long_option(short, docstring)
|
|
125
|
+
|
|
126
|
+
if long not in parameters:
|
|
127
|
+
raise SystemExit(f"--{long}: invalid long option")
|
|
128
|
+
|
|
129
|
+
cast_to = get_type(parameters[long])
|
|
130
|
+
|
|
131
|
+
if cast_to is bool:
|
|
132
|
+
kwargs[long] = not parameters[long].default
|
|
133
|
+
elif cast_to is type(None):
|
|
134
|
+
kwargs[long] = True
|
|
135
|
+
elif i == len(short_options) - 1:
|
|
136
|
+
kwargs[long] = cast_to(next(iter_argv))
|
|
137
|
+
else:
|
|
138
|
+
raise SystemExit(f"-{short}: invalid type")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def short_to_long_option(short, docstring):
|
|
142
|
+
"""Converts a one character short option to a long option according to the help message."""
|
|
143
|
+
template = f"-{short}, --"
|
|
144
|
+
if (start := docstring.find(template)) != -1:
|
|
145
|
+
start += len(template)
|
|
146
|
+
if len(docstring) - start > 1:
|
|
147
|
+
chars = [" ", "\n", "]"]
|
|
148
|
+
indices = (i for char in chars if (i := docstring.find(char, start)) != -1)
|
|
149
|
+
return docstring[start : min(indices, default=None)]
|
|
150
|
+
raise SystemExit(f"-{short}: invalid short option")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_type(parameter):
|
|
154
|
+
"""
|
|
155
|
+
Determines the type based on function signature annotations or defaults.
|
|
156
|
+
Falls back to `str` if neither is available.
|
|
157
|
+
"""
|
|
158
|
+
if parameter.annotation is not parameter.empty:
|
|
159
|
+
return parameter.annotation
|
|
160
|
+
if parameter.default is not parameter.empty:
|
|
161
|
+
return type(parameter.default)
|
|
162
|
+
return str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_for_version(argv, parameters, docstring, module):
|
|
166
|
+
"""Displays version information if --version is specified in the docstring."""
|
|
167
|
+
if "version" in parameters or not module or len(argv) != 1:
|
|
168
|
+
return
|
|
169
|
+
args = {
|
|
170
|
+
"--version": "--version",
|
|
171
|
+
"-v": "-v, --version",
|
|
172
|
+
"-V": "-V, --version",
|
|
173
|
+
}
|
|
174
|
+
if (doc := args.get(argv[0])) and doc in docstring:
|
|
175
|
+
logging.info(get_version(module))
|
|
176
|
+
raise SystemExit
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def help_message(help_function, obj, *args):
|
|
180
|
+
"""
|
|
181
|
+
Generates a help message for a function or module.
|
|
182
|
+
Returns the object's docstring if available, otherwise generates the help message
|
|
183
|
+
using the provided `help_function`.
|
|
184
|
+
"""
|
|
185
|
+
return inspect.getdoc(obj) or help_function(obj, *args) or 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def help_from_function(function, name=None):
|
|
189
|
+
"""
|
|
190
|
+
Generates a help message for a function based on its signature.
|
|
191
|
+
Displays the function name, required positional arguments, and
|
|
192
|
+
optional keyword arguments with their default values.
|
|
193
|
+
"""
|
|
194
|
+
message = [name] if name else []
|
|
195
|
+
message.append(function.__name__)
|
|
196
|
+
message.extend(map(format_kwarg, inspect.signature(function).parameters.values()))
|
|
197
|
+
return format_blocks([["usage:", " ".join(message)]])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def format_kwarg(kwarg):
|
|
201
|
+
"""Formats a parameter as positional or optional argument."""
|
|
202
|
+
return kwarg.name if kwarg.default is kwarg.empty else f"[--{kwarg.name}]"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def help_from_module(module):
|
|
206
|
+
"""
|
|
207
|
+
Generates a help message for a module and lists available commands.
|
|
208
|
+
Lists all public functions that are not excluded in `__all__`.
|
|
209
|
+
"""
|
|
210
|
+
blocks = []
|
|
211
|
+
|
|
212
|
+
if version := get_version(module):
|
|
213
|
+
blocks.append([f"{module.__name__} {version}"])
|
|
214
|
+
|
|
215
|
+
blocks.append(["usage:", f"{module.__name__} command"])
|
|
216
|
+
|
|
217
|
+
if commands := get_commands(module):
|
|
218
|
+
blocks.append(["commands:", *commands])
|
|
219
|
+
|
|
220
|
+
return format_blocks(blocks)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def format_blocks(blocks, sep="\n "):
|
|
224
|
+
"""Formats blocks of text with proper indentation."""
|
|
225
|
+
return "\n\n".join(sep.join(block) for block in blocks)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_module(name):
|
|
229
|
+
"""Load module from name"""
|
|
230
|
+
try:
|
|
231
|
+
return importlib.import_module(name)
|
|
232
|
+
except ModuleNotFoundError as exc:
|
|
233
|
+
raise SystemExit(f"{name}: command not found") from exc
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_commands(module):
|
|
237
|
+
"""Returns list of public commands that are not excluded by `__all__`."""
|
|
238
|
+
return [
|
|
239
|
+
name
|
|
240
|
+
for name, _ in inspect.getmembers(module, inspect.isfunction)
|
|
241
|
+
if not name.startswith("_") and name in module.__dict__.get("__all__", [name])
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_version(module):
|
|
246
|
+
"""Returns the version of a module from its metadata or `__version__` attribute."""
|
|
247
|
+
try:
|
|
248
|
+
return metadata.version(module.__name__)
|
|
249
|
+
except metadata.PackageNotFoundError:
|
|
250
|
+
return module.__dict__.get("__version__")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_project_name():
|
|
254
|
+
"""Detect project name from project structure."""
|
|
255
|
+
single_file_layout = [path.stem for path in Path().glob("*.py")]
|
|
256
|
+
flat_layout = [
|
|
257
|
+
path.parent.name
|
|
258
|
+
for path in Path().glob("*/__init__.py")
|
|
259
|
+
if path.parent.name != "tests"
|
|
260
|
+
]
|
|
261
|
+
src_layout = [path.parent.name for path in Path().glob("src/*/__init__.py")]
|
|
262
|
+
|
|
263
|
+
if len(names := single_file_layout + flat_layout + src_layout) == 1:
|
|
264
|
+
return names[0]
|
|
265
|
+
|
|
266
|
+
if name := input("CLI name: "):
|
|
267
|
+
return name
|
|
268
|
+
|
|
269
|
+
raise SystemExit(1)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_output(command):
|
|
273
|
+
"""Return the stdout of a shell command or None on failure."""
|
|
274
|
+
try:
|
|
275
|
+
output = subprocess.run(
|
|
276
|
+
command.split(), capture_output=True, text=True, check=False
|
|
277
|
+
).stdout
|
|
278
|
+
except FileNotFoundError:
|
|
279
|
+
return None
|
|
280
|
+
return output.removesuffix("\n") or None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_homepage(url=None):
|
|
284
|
+
"""Return a homepage url from a git remote url."""
|
|
285
|
+
url = url or get_output("git remote get-url origin") or ""
|
|
286
|
+
if url.startswith("git@"):
|
|
287
|
+
url = "https://" + url.removeprefix("git@").replace(":", "/")
|
|
288
|
+
return url.removesuffix(".git")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_description(name):
|
|
292
|
+
"""Return the first paragraph of a module's docstring if available."""
|
|
293
|
+
try:
|
|
294
|
+
module = importlib.import_module(name)
|
|
295
|
+
except ModuleNotFoundError:
|
|
296
|
+
return None
|
|
297
|
+
doc = (module.__doc__ or "").split("\n\n")[0]
|
|
298
|
+
return " ".join(stripped for line in doc.splitlines() if (stripped := line.strip()))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def get_license_expression(content):
|
|
302
|
+
"""Returns the license expression used in pyproject.toml."""
|
|
303
|
+
return {
|
|
304
|
+
"Apache License": "Apache-2.0",
|
|
305
|
+
"BSD 2-Clause License": "BSD-2-Clause",
|
|
306
|
+
"BSD 3-Clause License": "BSD-3-Clause",
|
|
307
|
+
"GNU AFFERO GENERAL PUBLIC LICENSE": "AGPL-3.0-or-later",
|
|
308
|
+
"GNU GENERAL PUBLIC LICENSE": "GPL-3.0-or-later",
|
|
309
|
+
"GNU LESSER GENERAL PUBLIC LICENSE": "LGPL-3.0-or-later",
|
|
310
|
+
"MIT License": "MIT",
|
|
311
|
+
"Mozilla Public License Version 2.0": "MPL-2.0",
|
|
312
|
+
"Public domain statement": "Unlicense",
|
|
313
|
+
}.get(content.split("\n")[0].strip())
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def detect_path(glob, extensions):
|
|
317
|
+
"""Returns only a single path of a glob if it ends with one of the provided extensions."""
|
|
318
|
+
paths = [path for path in Path().glob(glob) if path.suffix in extensions]
|
|
319
|
+
return paths[0] if len(paths) == 1 else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def cli(name="", author="", email="", description="", homepage=""):
|
|
323
|
+
"""
|
|
324
|
+
magiCLI✨
|
|
325
|
+
|
|
326
|
+
Generates a "pyproject.toml" configuration file for a module and sets up the project script.
|
|
327
|
+
The CLI name must be the same as the module name.
|
|
328
|
+
|
|
329
|
+
usage:
|
|
330
|
+
magicli [option]
|
|
331
|
+
|
|
332
|
+
options:
|
|
333
|
+
--name
|
|
334
|
+
--author
|
|
335
|
+
--email
|
|
336
|
+
--description
|
|
337
|
+
--homepage
|
|
338
|
+
-v, --version
|
|
339
|
+
"""
|
|
340
|
+
pyproject = Path("pyproject.toml")
|
|
341
|
+
if (
|
|
342
|
+
pyproject.exists()
|
|
343
|
+
and input("Overwrite existing pyproject.toml? (yN) ").strip().lower() != "y"
|
|
344
|
+
):
|
|
345
|
+
raise SystemExit(1)
|
|
346
|
+
|
|
347
|
+
name = name or get_project_name()
|
|
348
|
+
|
|
349
|
+
if Path(".git").exists():
|
|
350
|
+
author = author or get_output("git config --get user.name")
|
|
351
|
+
email = email or get_output("git config --get user.email")
|
|
352
|
+
if not get_output("git tag"):
|
|
353
|
+
logging.debug("Specify the version with `git tag`")
|
|
354
|
+
else:
|
|
355
|
+
logging.debug("Not a git repo. Run `git init`")
|
|
356
|
+
|
|
357
|
+
authors = [f'{k}="{v}"' for k, v in {"name": author, "email": email}.items() if v]
|
|
358
|
+
|
|
359
|
+
project = [
|
|
360
|
+
"[project]",
|
|
361
|
+
f'name = "{name}"',
|
|
362
|
+
'dynamic = ["version"]',
|
|
363
|
+
'dependencies = ["magicli<3"]',
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
if authors:
|
|
367
|
+
project.append(f"authors = [{{{', '.join(authors)}}}]")
|
|
368
|
+
|
|
369
|
+
if readme := detect_path("README*", {".md", ".rst", ".txt"}):
|
|
370
|
+
project.append(f'readme = "{readme.name}"')
|
|
371
|
+
|
|
372
|
+
if license_file := detect_path("LICENSE*", {"", ".txt"}):
|
|
373
|
+
license_content = license_file.read_text(encoding="utf-8")
|
|
374
|
+
if license_expression := get_license_expression(license_content):
|
|
375
|
+
project.append(f'license = "{license_expression}"')
|
|
376
|
+
else:
|
|
377
|
+
logging.debug("Unknown license: %s", license_file.name)
|
|
378
|
+
project.append(f'license-files = ["{license_file.name}"]')
|
|
379
|
+
|
|
380
|
+
if description or (description := get_description(name)):
|
|
381
|
+
project.append(f'description = "{description}"')
|
|
382
|
+
|
|
383
|
+
blocks = [project, ["[project.scripts]", f'{name} = "magicli:magicli"']]
|
|
384
|
+
|
|
385
|
+
if homepage or (homepage := get_homepage()):
|
|
386
|
+
blocks.append(["[project.urls]", f'Home = "{homepage}"'])
|
|
387
|
+
|
|
388
|
+
blocks.append(
|
|
389
|
+
[
|
|
390
|
+
"[build-system]",
|
|
391
|
+
'requires = ["setuptools>=80", "setuptools-scm[simple]>=8"]',
|
|
392
|
+
'build-backend = "setuptools.build_meta"',
|
|
393
|
+
]
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
pyproject.write_text(format_blocks(blocks, sep="\n") + "\n", encoding="utf-8")
|
|
397
|
+
logging.debug("Created pyproject.toml ✨")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from tempfile import TemporaryDirectory
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _setup(filenames, dirname=None):
|
|
9
|
+
cwd = Path.cwd()
|
|
10
|
+
directory = TemporaryDirectory()
|
|
11
|
+
if dirname:
|
|
12
|
+
Path(directory.name, dirname).mkdir()
|
|
13
|
+
os.chdir(directory.name)
|
|
14
|
+
else:
|
|
15
|
+
os.chdir(directory.name)
|
|
16
|
+
for filename in filenames:
|
|
17
|
+
Path(directory.name, filename).touch()
|
|
18
|
+
return directory, cwd
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _teardown(directory, cwd):
|
|
22
|
+
directory.cleanup()
|
|
23
|
+
os.chdir(cwd)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def with_tempdir():
|
|
28
|
+
directory, cwd = _setup(["module.py"])
|
|
29
|
+
yield directory.name
|
|
30
|
+
_teardown(directory, cwd)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def with_license():
|
|
35
|
+
directory, cwd = _setup(["LICENSE"])
|
|
36
|
+
yield directory.name
|
|
37
|
+
_teardown(directory, cwd)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def with_readme_and_license():
|
|
42
|
+
directory, cwd = _setup(["README.md", "LICENSE"])
|
|
43
|
+
yield directory.name
|
|
44
|
+
_teardown(directory, cwd)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def with_two_files():
|
|
49
|
+
directory, cwd = _setup(["module.py", "two.py"])
|
|
50
|
+
yield
|
|
51
|
+
_teardown(directory, cwd)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def pyproject():
|
|
56
|
+
directory, cwd = _setup(["pyproject.toml", "module.py"])
|
|
57
|
+
yield Path(directory.name, "pyproject.toml")
|
|
58
|
+
_teardown(directory, cwd)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
def with_git():
|
|
63
|
+
directory, cwd = _setup([], dirname=".git")
|
|
64
|
+
yield
|
|
65
|
+
_teardown(directory, cwd)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def empty_directory():
|
|
70
|
+
directory, cwd = _setup([])
|
|
71
|
+
yield
|
|
72
|
+
_teardown(directory, cwd)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest import mock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from fixtures import (
|
|
7
|
+
empty_directory,
|
|
8
|
+
pyproject,
|
|
9
|
+
with_git,
|
|
10
|
+
with_license,
|
|
11
|
+
with_readme_and_license,
|
|
12
|
+
with_tempdir,
|
|
13
|
+
with_two_files,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from magicli import (
|
|
17
|
+
cli,
|
|
18
|
+
get_description,
|
|
19
|
+
get_homepage,
|
|
20
|
+
get_license_expression,
|
|
21
|
+
get_output,
|
|
22
|
+
get_project_name,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def module(name):
|
|
27
|
+
module = type(pytest)(name)
|
|
28
|
+
module.__doc__ = "docstring"
|
|
29
|
+
return module
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mock.patch("builtins.input", lambda _: "two")
|
|
33
|
+
def test_correct_name_input(with_two_files):
|
|
34
|
+
cli()
|
|
35
|
+
|
|
36
|
+
path = Path("pyproject.toml")
|
|
37
|
+
assert path.exists()
|
|
38
|
+
with path.open(encoding="utf-8") as f:
|
|
39
|
+
assert 'name = "two"' in f.read()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mock.patch("builtins.input", lambda *_: "y")
|
|
43
|
+
def test_automatic_name(pyproject):
|
|
44
|
+
cli()
|
|
45
|
+
|
|
46
|
+
assert 'name = "module"' in pyproject.read_text()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@mock.patch("builtins.input", lambda *_: "y")
|
|
50
|
+
def test_overwrite_pyproject_toml(pyproject):
|
|
51
|
+
cli()
|
|
52
|
+
|
|
53
|
+
assert 'name = "module"' in pyproject.read_text()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mock.patch("builtins.input", lambda *_: "")
|
|
57
|
+
def test_empty_cli_name_failure(with_two_files):
|
|
58
|
+
with pytest.raises(SystemExit) as error:
|
|
59
|
+
get_project_name()
|
|
60
|
+
assert error.value.code == 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_with_git_repo(caplog, with_git):
|
|
64
|
+
cli(name="_")
|
|
65
|
+
assert "Specify the version with `git tag`" in caplog.messages
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_without_git_repo(caplog, empty_directory):
|
|
69
|
+
cli(name="_")
|
|
70
|
+
assert "Not a git repo. Run `git init`" in caplog.messages
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_get_output():
|
|
74
|
+
assert get_output("ls") is not None
|
|
75
|
+
assert get_output("-") is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_get_homepage():
|
|
79
|
+
for url in [
|
|
80
|
+
"https://github.com/PatrickElmer/magicli.git",
|
|
81
|
+
"git@github.com:PatrickElmer/magicli.git",
|
|
82
|
+
]:
|
|
83
|
+
assert get_homepage(url) == "https://github.com/PatrickElmer/magicli"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_get_description():
|
|
87
|
+
assert get_description("magicli") is not None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_get_license_expression():
|
|
91
|
+
assert get_license_expression("Apache License") == "Apache-2.0"
|
|
92
|
+
assert get_license_expression(" GNU GENERAL PUBLIC LICENSE ") == "GPL-3.0-or-later"
|
|
93
|
+
assert get_license_expression("") is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_cli_with_license(with_license):
|
|
97
|
+
Path(with_license, "LICENSE").write_text("MIT License", encoding="utf-8")
|
|
98
|
+
cli(name="name", author="Patrick Elmer", email="patrick@elmer.ws")
|
|
99
|
+
pyproject = Path("pyproject.toml").read_text(encoding="utf-8")
|
|
100
|
+
assert 'license = "MIT"' in pyproject
|
|
101
|
+
assert 'license-files = ["LICENSE"]' in pyproject
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_cli_with_kwargs(caplog, with_readme_and_license):
|
|
105
|
+
cli(
|
|
106
|
+
name="name",
|
|
107
|
+
author="Patrick Elmer",
|
|
108
|
+
email="patrick@elmer.ws",
|
|
109
|
+
description="docstring",
|
|
110
|
+
homepage="https://github.com/PatrickElmer/magicli",
|
|
111
|
+
)
|
|
112
|
+
assert any(msg == "Unknown license: LICENSE" for msg in caplog.messages)
|
|
113
|
+
assert (
|
|
114
|
+
Path("pyproject.toml").read_text(encoding="utf-8")
|
|
115
|
+
== """\
|
|
116
|
+
[project]
|
|
117
|
+
name = "name"
|
|
118
|
+
dynamic = ["version"]
|
|
119
|
+
dependencies = ["magicli<3"]
|
|
120
|
+
authors = [{name="Patrick Elmer", email="patrick@elmer.ws"}]
|
|
121
|
+
readme = "README.md"
|
|
122
|
+
license-files = ["LICENSE"]
|
|
123
|
+
description = "docstring"
|
|
124
|
+
|
|
125
|
+
[project.scripts]
|
|
126
|
+
name = "magicli:magicli"
|
|
127
|
+
|
|
128
|
+
[project.urls]
|
|
129
|
+
Home = "https://github.com/PatrickElmer/magicli"
|
|
130
|
+
|
|
131
|
+
[build-system]
|
|
132
|
+
requires = ["setuptools>=80", "setuptools-scm[simple]>=8"]
|
|
133
|
+
build-backend = "setuptools.build_meta"
|
|
134
|
+
"""
|
|
135
|
+
)
|
|
@@ -1,24 +1,22 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import sys
|
|
2
|
-
from unittest import mock
|
|
3
3
|
from functools import partial
|
|
4
|
+
from unittest import mock
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
7
|
+
from fixtures import pyproject
|
|
6
8
|
|
|
7
9
|
from magicli import magicli
|
|
8
10
|
|
|
9
|
-
ANSWER = None
|
|
10
|
-
|
|
11
11
|
|
|
12
12
|
def name():
|
|
13
|
-
"--version"
|
|
14
|
-
|
|
15
|
-
ANSWER = 1
|
|
13
|
+
"-V, --version"
|
|
14
|
+
logging.info("name")
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
def command():
|
|
19
18
|
"-v, --version"
|
|
20
|
-
|
|
21
|
-
ANSWER = 2
|
|
19
|
+
logging.info("command")
|
|
22
20
|
|
|
23
21
|
|
|
24
22
|
def create_module(name, version=None, functions=None):
|
|
@@ -43,19 +41,19 @@ def test_module_imported(mocked):
|
|
|
43
41
|
|
|
44
42
|
|
|
45
43
|
@mock.patch("importlib.import_module", side_effect=module)
|
|
46
|
-
def test_first_function_called(mocked):
|
|
44
|
+
def test_first_function_called(mocked, caplog):
|
|
47
45
|
sys.argv = ["name"]
|
|
48
46
|
magicli()
|
|
49
47
|
mocked.assert_called_once_with("name")
|
|
50
|
-
assert
|
|
48
|
+
assert caplog.messages[0] == "name"
|
|
51
49
|
|
|
52
50
|
|
|
53
51
|
@mock.patch("importlib.import_module", side_effect=module)
|
|
54
|
-
def test_command_called(mocked):
|
|
52
|
+
def test_command_called(mocked, caplog):
|
|
55
53
|
sys.argv = ["name", "command"]
|
|
56
54
|
magicli()
|
|
57
55
|
mocked.assert_called_once_with("name")
|
|
58
|
-
assert
|
|
56
|
+
assert caplog.messages[0] == "command"
|
|
59
57
|
|
|
60
58
|
|
|
61
59
|
@mock.patch("importlib.import_module", side_effect=module)
|
|
@@ -65,12 +63,6 @@ def test_wrong_command_not_called(mocked):
|
|
|
65
63
|
magicli()
|
|
66
64
|
|
|
67
65
|
|
|
68
|
-
def test_empty_sys_argv():
|
|
69
|
-
sys.argv = []
|
|
70
|
-
with pytest.raises(SystemExit):
|
|
71
|
-
magicli()
|
|
72
|
-
|
|
73
|
-
|
|
74
66
|
@mock.patch("importlib.import_module", side_effect=module_empty)
|
|
75
67
|
def test_module_without_functions(mocked):
|
|
76
68
|
sys.argv = ["name"]
|
|
@@ -84,8 +76,8 @@ def test_module_not_found():
|
|
|
84
76
|
magicli()
|
|
85
77
|
|
|
86
78
|
|
|
87
|
-
@mock.patch("builtins.input",
|
|
88
|
-
def test_module_is_magicli():
|
|
79
|
+
@mock.patch("builtins.input", return_value="n")
|
|
80
|
+
def test_module_is_magicli(pyproject):
|
|
89
81
|
sys.argv = ["magicli"]
|
|
90
82
|
with pytest.raises(SystemExit) as error:
|
|
91
83
|
magicli()
|
|
@@ -100,12 +92,13 @@ def test_short_option_with_wrong_type(mocked):
|
|
|
100
92
|
|
|
101
93
|
|
|
102
94
|
@mock.patch("importlib.import_module", side_effect=module_version)
|
|
103
|
-
def test_version(mocked,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
95
|
+
def test_version(mocked, caplog):
|
|
96
|
+
for version in ["--version", "-V"]:
|
|
97
|
+
sys.argv = ["name", version]
|
|
98
|
+
with pytest.raises(SystemExit) as error:
|
|
99
|
+
magicli()
|
|
100
|
+
assert error.value.code is None
|
|
101
|
+
assert caplog.messages[0] == "1.2.3"
|
|
109
102
|
|
|
110
103
|
sys.argv = ["name", "-v"]
|
|
111
104
|
with pytest.raises(SystemExit) as error:
|
|
@@ -114,9 +107,9 @@ def test_version(mocked, capsys):
|
|
|
114
107
|
|
|
115
108
|
|
|
116
109
|
@mock.patch("importlib.import_module", side_effect=module_version)
|
|
117
|
-
def test_version_with_command(mocked,
|
|
110
|
+
def test_version_with_command(mocked, caplog):
|
|
118
111
|
sys.argv = ["name", "command", "-v"]
|
|
119
112
|
with pytest.raises(SystemExit) as error:
|
|
120
113
|
magicli()
|
|
121
114
|
assert error.value.code is None
|
|
122
|
-
assert
|
|
115
|
+
assert caplog.messages[0] == "1.2.3"
|
|
@@ -3,7 +3,7 @@ from inspect import Parameter, _ParameterKind
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
|
|
6
|
-
from magicli import
|
|
6
|
+
from magicli import get_type, parse_argv, parse_kwarg
|
|
7
7
|
|
|
8
8
|
PK = _ParameterKind.POSITIONAL_OR_KEYWORD
|
|
9
9
|
|
|
@@ -29,30 +29,30 @@ def test_parse_kwarg_bool_and_none(default, result):
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def test_get_type():
|
|
32
|
-
assert get_type(Parameter("a", PK, annotation=int))
|
|
33
|
-
assert get_type(Parameter("b", PK, default=1))
|
|
34
|
-
assert get_type(Parameter("c", PK))
|
|
32
|
+
assert get_type(Parameter("a", PK, annotation=int)) is int
|
|
33
|
+
assert get_type(Parameter("b", PK, default=1)) is int
|
|
34
|
+
assert get_type(Parameter("c", PK)) is str
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def
|
|
37
|
+
def test_parse_argv():
|
|
38
38
|
parameters = inspect.signature(lambda arg, kwarg=1: None).parameters
|
|
39
|
-
assert
|
|
39
|
+
assert parse_argv(["a", "--kwarg=2"], parameters, docstring="") == (
|
|
40
40
|
["a"],
|
|
41
41
|
{"kwarg": 2},
|
|
42
42
|
)
|
|
43
|
-
assert
|
|
43
|
+
assert parse_argv(["a", "--kwarg", "2"], parameters, docstring="") == (
|
|
44
44
|
["a"],
|
|
45
45
|
{"kwarg": 2},
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def
|
|
49
|
+
def test_parse_argv_with_underscore():
|
|
50
50
|
parameters = inspect.signature(lambda arg, kwarg_1=1: None).parameters
|
|
51
|
-
assert
|
|
51
|
+
assert parse_argv(["a", "--kwarg-1=2"], parameters, docstring="") == (
|
|
52
52
|
["a"],
|
|
53
53
|
{"kwarg_1": 2},
|
|
54
54
|
)
|
|
55
|
-
assert
|
|
55
|
+
assert parse_argv(["a", "--kwarg-1", "2"], parameters, docstring="") == (
|
|
56
56
|
["a"],
|
|
57
57
|
{"kwarg_1": 2},
|
|
58
58
|
)
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
from functools import partial
|
|
2
1
|
from inspect import Parameter, _ParameterKind
|
|
3
2
|
|
|
4
3
|
import pytest
|
|
@@ -7,7 +6,7 @@ from magicli import parse_short_options, short_to_long_option
|
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
@pytest.mark.parametrize(
|
|
10
|
-
|
|
9
|
+
("default", "result"),
|
|
11
10
|
[
|
|
12
11
|
(None, True),
|
|
13
12
|
(True, False),
|
magicli-2.0.2/magicli.py
DELETED
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Magicli generates command-line interfaces from Python modules
|
|
3
|
-
by introspecting its functions and automatically parsing command-
|
|
4
|
-
line arguments based on function signatures.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import importlib
|
|
8
|
-
import inspect
|
|
9
|
-
import sys
|
|
10
|
-
from importlib import metadata
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def magicli():
|
|
15
|
-
"""
|
|
16
|
-
Parses command-line arguments and calls the appropriate function.
|
|
17
|
-
"""
|
|
18
|
-
if not sys.argv:
|
|
19
|
-
raise SystemExit(1)
|
|
20
|
-
|
|
21
|
-
name = Path(sys.argv[0]).name
|
|
22
|
-
argv = sys.argv[1:]
|
|
23
|
-
|
|
24
|
-
if name == "magicli":
|
|
25
|
-
raise SystemExit(call(cli, argv))
|
|
26
|
-
|
|
27
|
-
module = load_module(name)
|
|
28
|
-
name = name.replace("-", "_")
|
|
29
|
-
|
|
30
|
-
if function := is_command(argv, module):
|
|
31
|
-
call(function, argv[1:], module, name)
|
|
32
|
-
elif inspect.isfunction(function := module.__dict__.get(name)):
|
|
33
|
-
call(function, argv, module)
|
|
34
|
-
else:
|
|
35
|
-
raise SystemExit(help_message(help_from_module, module))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def is_command(argv, module):
|
|
39
|
-
"""
|
|
40
|
-
Checks if the first argument is a valid command in the module and returns
|
|
41
|
-
the function to call if `argv[0]` is public and not excluded in `__all__`,
|
|
42
|
-
"""
|
|
43
|
-
if (
|
|
44
|
-
argv
|
|
45
|
-
and not (command := argv[0].replace("-", "_")).startswith("_")
|
|
46
|
-
and command in module.__dict__.get("__all__", [command])
|
|
47
|
-
and inspect.isfunction(function := module.__dict__.get(command))
|
|
48
|
-
):
|
|
49
|
-
return function
|
|
50
|
-
return None
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def call(function, argv, module=None, name=None):
|
|
54
|
-
"""
|
|
55
|
-
Converts arguments to function parameters and calls the function.
|
|
56
|
-
Displays a help message if an exception occurs.
|
|
57
|
-
"""
|
|
58
|
-
try:
|
|
59
|
-
docstring = get_docstring(function)
|
|
60
|
-
parameters = inspect.signature(function).parameters
|
|
61
|
-
|
|
62
|
-
check_for_version(argv, parameters, docstring, module)
|
|
63
|
-
|
|
64
|
-
args, kwargs = args_and_kwargs(argv, parameters, docstring)
|
|
65
|
-
function(*args, **kwargs)
|
|
66
|
-
except Exception:
|
|
67
|
-
raise SystemExit(help_message(help_from_function, function, name))
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def args_and_kwargs(argv, parameters, docstring):
|
|
71
|
-
"""
|
|
72
|
-
Parses command-line arguments into positional and keyword arguments.
|
|
73
|
-
"""
|
|
74
|
-
parameter_list = list(parameters.values())
|
|
75
|
-
args, kwargs = [], {}
|
|
76
|
-
|
|
77
|
-
for key in (iter_argv := iter(argv)):
|
|
78
|
-
if key.startswith("--"):
|
|
79
|
-
left, right = parse_kwarg(key[2:], iter_argv, parameters)
|
|
80
|
-
kwargs[left] = right
|
|
81
|
-
elif key.startswith("-"):
|
|
82
|
-
parse_short_options(key[1:], docstring, iter_argv, parameters, kwargs)
|
|
83
|
-
else:
|
|
84
|
-
args.append(get_type(parameter_list[len(args)])(key))
|
|
85
|
-
|
|
86
|
-
return args, kwargs
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def parse_short_options(short_options, docstring, iter_argv, parameters, kwargs):
|
|
90
|
-
"""
|
|
91
|
-
Converts short options into long options and casts into correct types.
|
|
92
|
-
"""
|
|
93
|
-
for i, short in enumerate(short_options):
|
|
94
|
-
long = short_to_long_option(short, docstring)
|
|
95
|
-
|
|
96
|
-
if long not in parameters:
|
|
97
|
-
raise SystemExit(f"--{long}: invalid long option")
|
|
98
|
-
|
|
99
|
-
cast_to = get_type(parameters[long])
|
|
100
|
-
|
|
101
|
-
if cast_to is bool:
|
|
102
|
-
kwargs[long] = not parameters[long].default
|
|
103
|
-
elif cast_to is type(None):
|
|
104
|
-
kwargs[long] = True
|
|
105
|
-
elif i == len(short_options) - 1:
|
|
106
|
-
kwargs[long] = cast_to(next(iter_argv))
|
|
107
|
-
else:
|
|
108
|
-
raise SystemExit(f"-{short}: invalid type")
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def short_to_long_option(short, docstring):
|
|
112
|
-
"""
|
|
113
|
-
Converts a one character short option to a long option accoring to the help message.
|
|
114
|
-
"""
|
|
115
|
-
template = f"-{short}, --"
|
|
116
|
-
if (start := docstring.find(template)) != -1:
|
|
117
|
-
start += len(template)
|
|
118
|
-
chars = (" ", "\n", "]")
|
|
119
|
-
|
|
120
|
-
try:
|
|
121
|
-
end = min(i for ws in chars if (i := docstring.find(ws, start)) != -1)
|
|
122
|
-
return docstring[start:end]
|
|
123
|
-
|
|
124
|
-
except ValueError:
|
|
125
|
-
if len(docstring) - start > 1:
|
|
126
|
-
return docstring[start:]
|
|
127
|
-
|
|
128
|
-
raise SystemExit(f"-{short}: invalid short option")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def parse_kwarg(key, argv, parameters):
|
|
132
|
-
"""
|
|
133
|
-
Parses a single keyword argument from command-line arguments.
|
|
134
|
-
Handles '=' syntax for inline values. Casts `NoneType` values to `True`
|
|
135
|
-
and boolean values to `not default`.
|
|
136
|
-
"""
|
|
137
|
-
key, value = key.split("=", 1) if "=" in key else (key, None)
|
|
138
|
-
key = key.replace("-", "_")
|
|
139
|
-
cast_to = get_type(parameters.get(key))
|
|
140
|
-
|
|
141
|
-
if value is None:
|
|
142
|
-
if cast_to is bool:
|
|
143
|
-
return key, not parameters[key].default
|
|
144
|
-
if cast_to is type(None):
|
|
145
|
-
return key, True
|
|
146
|
-
value = next(argv)
|
|
147
|
-
|
|
148
|
-
return key, value if cast_to is str else cast_to(value)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def get_type(parameter):
|
|
152
|
-
"""
|
|
153
|
-
Determines the type based on function signature annotations or defaults.
|
|
154
|
-
Falls back to `str` if neither is available.
|
|
155
|
-
"""
|
|
156
|
-
if parameter.annotation is not parameter.empty:
|
|
157
|
-
return parameter.annotation
|
|
158
|
-
if parameter.default is not parameter.empty:
|
|
159
|
-
return type(parameter.default)
|
|
160
|
-
return str
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def check_for_version(argv, parameters, docstring, module):
|
|
164
|
-
"""
|
|
165
|
-
Displays version information if --version is specified in the docstring.
|
|
166
|
-
"""
|
|
167
|
-
if (
|
|
168
|
-
"version" not in parameters
|
|
169
|
-
and any(
|
|
170
|
-
(argv == [arg] and string in docstring)
|
|
171
|
-
for arg, string in [("--version", "--version"), ("-v", "-v, --version")]
|
|
172
|
-
)
|
|
173
|
-
and module
|
|
174
|
-
):
|
|
175
|
-
print(get_version(module))
|
|
176
|
-
raise SystemExit
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def help_message(help_function, obj, *args):
|
|
180
|
-
"""
|
|
181
|
-
Generates a help message for a function or module.
|
|
182
|
-
Returns the object's docstring if available, otherwise generates the help message
|
|
183
|
-
using the provided `help_function`.
|
|
184
|
-
"""
|
|
185
|
-
return inspect.getdoc(obj) or help_function(obj, *args) or 1
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def help_from_function(function, name=None):
|
|
189
|
-
"""
|
|
190
|
-
Generates a help message for a function based on its signature.
|
|
191
|
-
Displays the function name, required positional arguments, and
|
|
192
|
-
optional keyword arguments with their default values.
|
|
193
|
-
"""
|
|
194
|
-
message = [name] if name else []
|
|
195
|
-
message.append(function.__name__)
|
|
196
|
-
message.extend(map(format_kwarg, inspect.signature(function).parameters.values()))
|
|
197
|
-
return format_message([["usage:", " ".join(message)]])
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def format_kwarg(kwarg):
|
|
201
|
-
"""Formats a parameter as positional or optional argument."""
|
|
202
|
-
return kwarg.name if kwarg.default is kwarg.empty else f"[--{kwarg.name}]"
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def help_from_module(module):
|
|
206
|
-
"""
|
|
207
|
-
Generates a help message for a module and lists available commands.
|
|
208
|
-
Lists all public functions that are not excluded in `__all__`.
|
|
209
|
-
"""
|
|
210
|
-
message = []
|
|
211
|
-
|
|
212
|
-
if version := get_version(module):
|
|
213
|
-
message.append([f"{module.__name__} {version}"])
|
|
214
|
-
|
|
215
|
-
message.append(["usage:", f"{module.__name__} command"])
|
|
216
|
-
|
|
217
|
-
if commands := get_commands(module):
|
|
218
|
-
message.append(["commands:", *commands])
|
|
219
|
-
|
|
220
|
-
return format_message(message)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def format_message(blocks):
|
|
224
|
-
"""Formats blocks of text with proper indentation."""
|
|
225
|
-
return "\n\n".join("\n ".join(block) for block in blocks)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def load_module(name):
|
|
229
|
-
"""Load module from name"""
|
|
230
|
-
try:
|
|
231
|
-
return importlib.import_module(name)
|
|
232
|
-
except ModuleNotFoundError:
|
|
233
|
-
raise SystemExit(f"{name}: command not found")
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def get_commands(module):
|
|
237
|
-
"""Returns list of public commands, unless not present in `__all__`."""
|
|
238
|
-
return [
|
|
239
|
-
name
|
|
240
|
-
for name, _ in inspect.getmembers(module, inspect.isfunction)
|
|
241
|
-
if not name.startswith("_") and name in module.__dict__.get("__all__", [name])
|
|
242
|
-
]
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def get_docstring(function):
|
|
246
|
-
"""
|
|
247
|
-
Returns the cleaned up docstring of a function or an empty string.
|
|
248
|
-
"""
|
|
249
|
-
return inspect.getdoc(function) or ""
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def get_version(module):
|
|
253
|
-
"""
|
|
254
|
-
Returns the version of a module from its metadata or `__version__` attribute.
|
|
255
|
-
"""
|
|
256
|
-
try:
|
|
257
|
-
return metadata.version(module.__name__)
|
|
258
|
-
except metadata.PackageNotFoundError:
|
|
259
|
-
return module.__dict__.get("__version__")
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def get_project_name():
|
|
263
|
-
"""
|
|
264
|
-
Detect project name from project structure.
|
|
265
|
-
"""
|
|
266
|
-
flat_layout = [path.stem for path in Path().glob("*.py")]
|
|
267
|
-
src_layout = [path.parent.name for path in Path().glob("*/__init__.py")]
|
|
268
|
-
|
|
269
|
-
if len(names := flat_layout + src_layout) == 1:
|
|
270
|
-
return names[0]
|
|
271
|
-
|
|
272
|
-
if name := input("CLI name: "):
|
|
273
|
-
return name
|
|
274
|
-
|
|
275
|
-
raise SystemExit(1)
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def cli():
|
|
279
|
-
"""
|
|
280
|
-
Generates a "pyproject.toml" configuration file for a module and sets up the project script.
|
|
281
|
-
The CLI name must be the same as the module name.
|
|
282
|
-
"""
|
|
283
|
-
pyproject = Path("pyproject.toml")
|
|
284
|
-
if (
|
|
285
|
-
pyproject.exists()
|
|
286
|
-
and input("Overwrite existing pyproject.toml? (yN) ").strip().lower() != "y"
|
|
287
|
-
):
|
|
288
|
-
raise SystemExit(1)
|
|
289
|
-
|
|
290
|
-
name = get_project_name()
|
|
291
|
-
pyproject.write_text(
|
|
292
|
-
f"""\
|
|
293
|
-
[build-system]
|
|
294
|
-
requires = ["setuptools>=80", "setuptools-scm[simple]>=8"]
|
|
295
|
-
build-backend = "setuptools.build_meta"
|
|
296
|
-
|
|
297
|
-
[project]
|
|
298
|
-
name = "{name}"
|
|
299
|
-
dynamic = ["version"]
|
|
300
|
-
dependencies = ["magicli<3"]
|
|
301
|
-
|
|
302
|
-
[project.scripts]
|
|
303
|
-
{name} = "magicli:magicli"
|
|
304
|
-
"""
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
message = ["pyproject.toml created! ✨"]
|
|
308
|
-
if Path(".git").exists():
|
|
309
|
-
message.append("You can specify the version with `git tag`")
|
|
310
|
-
else:
|
|
311
|
-
message.append(
|
|
312
|
-
"Error: Not a git repo. Run `git init`. Specify version with `git tag`."
|
|
313
|
-
)
|
|
314
|
-
print(*message, sep="\n")
|
magicli-2.0.2/tests/fixtures.py
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import shutil
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@pytest.fixture()
|
|
9
|
-
def setup():
|
|
10
|
-
cwd = Path.cwd()
|
|
11
|
-
path = Path("tests", "tmp")
|
|
12
|
-
|
|
13
|
-
# Make sure the directory does not exist
|
|
14
|
-
if path.exists():
|
|
15
|
-
shutil.rmtree(path)
|
|
16
|
-
|
|
17
|
-
path.mkdir(exist_ok=True)
|
|
18
|
-
Path(path, "module.py").touch()
|
|
19
|
-
os.chdir(path)
|
|
20
|
-
|
|
21
|
-
yield path
|
|
22
|
-
|
|
23
|
-
os.chdir(cwd)
|
|
24
|
-
shutil.rmtree(path)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@pytest.fixture()
|
|
28
|
-
def two_py():
|
|
29
|
-
file = Path("two.py")
|
|
30
|
-
file.touch()
|
|
31
|
-
|
|
32
|
-
yield file
|
|
33
|
-
|
|
34
|
-
file.unlink()
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@pytest.fixture()
|
|
38
|
-
def pyproject_toml():
|
|
39
|
-
file = Path("pyproject.toml")
|
|
40
|
-
file.touch()
|
|
41
|
-
|
|
42
|
-
yield file
|
|
43
|
-
|
|
44
|
-
file.unlink()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@pytest.fixture()
|
|
48
|
-
def dotgit():
|
|
49
|
-
dir = Path(".git")
|
|
50
|
-
dir.mkdir(exist_ok=True)
|
|
51
|
-
|
|
52
|
-
yield dir
|
|
53
|
-
|
|
54
|
-
shutil.rmtree(dir)
|
magicli-2.0.2/tests/test_cli.py
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
from unittest import mock
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
from fixtures import pyproject_toml, setup, two_py, dotgit
|
|
6
|
-
|
|
7
|
-
from magicli import cli, get_project_name
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@mock.patch("builtins.input", lambda _: "two")
|
|
11
|
-
def test_correct_name_input(setup, two_py):
|
|
12
|
-
cli()
|
|
13
|
-
|
|
14
|
-
path = Path("pyproject.toml")
|
|
15
|
-
assert path.exists()
|
|
16
|
-
with path.open() as f:
|
|
17
|
-
assert 'name = "two"' in f.read()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
@mock.patch("builtins.input", lambda *args: "n")
|
|
21
|
-
def test_automatic_name(setup):
|
|
22
|
-
cli()
|
|
23
|
-
with Path("pyproject.toml").open() as f:
|
|
24
|
-
assert 'name = "module"' in f.read()
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@mock.patch("builtins.input", lambda *args: "y")
|
|
28
|
-
def test_overwrite_pyproject_toml(setup, pyproject_toml):
|
|
29
|
-
cli()
|
|
30
|
-
with pyproject_toml.open() as f:
|
|
31
|
-
assert 'name = "module"' in f.read()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@mock.patch("builtins.input", lambda *args: "")
|
|
35
|
-
def test_empty_cli_name_failure(setup, two_py):
|
|
36
|
-
with pytest.raises(SystemExit) as error:
|
|
37
|
-
get_project_name()
|
|
38
|
-
assert error.value.code == 1
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_on_git_repo(capsys, setup):
|
|
42
|
-
cli()
|
|
43
|
-
out, _ = capsys.readouterr()
|
|
44
|
-
|
|
45
|
-
with_git = (
|
|
46
|
-
"Error: Not a git repo. Run `git init`. Specify version with `git tag`.\n"
|
|
47
|
-
)
|
|
48
|
-
without_git = "You can specify the version with `git tag`\n"
|
|
49
|
-
assert out.endswith(with_git)
|
|
50
|
-
assert without_git not in out
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def test_git_repo(capsys, setup, dotgit):
|
|
54
|
-
cli()
|
|
55
|
-
out, _ = capsys.readouterr()
|
|
56
|
-
|
|
57
|
-
with_git = (
|
|
58
|
-
"Error: Not a git repo. Run `git init`. Specify version with `git tag`.\n"
|
|
59
|
-
)
|
|
60
|
-
without_git = "You can specify the version with `git tag`\n"
|
|
61
|
-
assert out.endswith(without_git)
|
|
62
|
-
assert with_git not in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|