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.
@@ -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 --disable=unidiomatic-typecheck,raise-missing-from magicli.py
25
+ - run: uv run --with pylint pylint magicli.py
26
26
 
27
27
  ruff:
28
28
  runs-on: ubuntu-latest
@@ -1,5 +1,5 @@
1
1
  _*
2
- .pytest_cache
2
+ .*_cache
3
3
  .vscode
4
4
  .venv
5
5
  build
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magicli
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Automatically generates a CLI from functions.
5
5
  Author-email: Patrick Elmer <patrick@elmer.ws>
6
6
  License-Expression: GPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: magicli
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Automatically generates a CLI from functions.
5
5
  Author-email: Patrick Elmer <patrick@elmer.ws>
6
6
  License-Expression: GPL-3.0-or-later
@@ -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 ✨")
@@ -19,3 +19,12 @@ dev = ["pytest"]
19
19
 
20
20
  [project.urls]
21
21
  Home = "https://github.com/PatrickElmer/magicli"
22
+
23
+ [tool.pylint."messages control"]
24
+ disable = [
25
+ "unidiomatic-typecheck",
26
+ "raise-missing-from",
27
+ ]
28
+
29
+ [tool.pytest]
30
+ log_level = "DEBUG"
@@ -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
- global ANSWER
15
- ANSWER = 1
13
+ "-V, --version"
14
+ logging.info("name")
16
15
 
17
16
 
18
17
  def command():
19
18
  "-v, --version"
20
- global ANSWER
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 ANSWER == 1
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 ANSWER == 2
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", lambda *args: "n")
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, capsys):
104
- sys.argv = ["name", "--version"]
105
- with pytest.raises(SystemExit) as error:
106
- magicli()
107
- assert error.value.code is None
108
- assert capsys.readouterr()[0] == "1.2.3\n"
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, capsys):
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 capsys.readouterr()[0] == "1.2.3\n"
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 args_and_kwargs, get_type, parse_kwarg
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)) == int
33
- assert get_type(Parameter("b", PK, default=1)) == int
34
- assert get_type(Parameter("c", PK)) == str
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 test_args_and_kwargs():
37
+ def test_parse_argv():
38
38
  parameters = inspect.signature(lambda arg, kwarg=1: None).parameters
39
- assert args_and_kwargs(["a", "--kwarg=2"], parameters, docstring="") == (
39
+ assert parse_argv(["a", "--kwarg=2"], parameters, docstring="") == (
40
40
  ["a"],
41
41
  {"kwarg": 2},
42
42
  )
43
- assert args_and_kwargs(["a", "--kwarg", "2"], parameters, docstring="") == (
43
+ assert parse_argv(["a", "--kwarg", "2"], parameters, docstring="") == (
44
44
  ["a"],
45
45
  {"kwarg": 2},
46
46
  )
47
47
 
48
48
 
49
- def test_args_and_kwargs_with_underscore():
49
+ def test_parse_argv_with_underscore():
50
50
  parameters = inspect.signature(lambda arg, kwarg_1=1: None).parameters
51
- assert args_and_kwargs(["a", "--kwarg-1=2"], parameters, docstring="") == (
51
+ assert parse_argv(["a", "--kwarg-1=2"], parameters, docstring="") == (
52
52
  ["a"],
53
53
  {"kwarg_1": 2},
54
54
  )
55
- assert args_and_kwargs(["a", "--kwarg-1", "2"], parameters, docstring="") == (
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
- ["default", "result"],
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")
@@ -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)
@@ -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