From aa29b7ccb133c3fe958fb0d62614b5f0d5ab70f1 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 12:58:59 +0100 Subject: [PATCH 01/18] Remove check for sys.argv --- magicli.py | 3 --- tests/test_magicli.py | 6 ------ 2 files changed, 9 deletions(-) diff --git a/magicli.py b/magicli.py index 85402e3..f1a171d 100644 --- a/magicli.py +++ b/magicli.py @@ -16,9 +16,6 @@ def magicli(): """ Parses command-line arguments and calls the appropriate function. """ - if not sys.argv: - raise SystemExit(1) - name = Path(sys.argv[0]).name argv = sys.argv[1:] diff --git a/tests/test_magicli.py b/tests/test_magicli.py index 68dcb48..7b47572 100644 --- a/tests/test_magicli.py +++ b/tests/test_magicli.py @@ -66,12 +66,6 @@ def test_wrong_command_not_called(mocked): magicli() -def test_empty_sys_argv(): - sys.argv = [] - with pytest.raises(SystemExit): - magicli() - - @mock.patch("importlib.import_module", side_effect=module_empty) def test_module_without_functions(mocked): sys.argv = ["name"] From cbfe49cd987294f1a3f661564d7d15b4b59982df Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:01:32 +0100 Subject: [PATCH 02/18] Extract logic into get_function_from_argv function --- magicli.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/magicli.py b/magicli.py index f1a171d..15671c6 100644 --- a/magicli.py +++ b/magicli.py @@ -8,6 +8,7 @@ import inspect import subprocess import sys +from functools import partial from importlib import metadata from pathlib import Path @@ -23,16 +24,22 @@ def magicli(): raise SystemExit(call(cli, argv, sys.modules["magicli"])) module = load_module(name) - name = name.replace("-", "_") - if function := is_command(argv, module): - call(function, argv[1:], module, name) - elif inspect.isfunction(function := module.__dict__.get(name)): - call(function, argv, module) + if function := get_function_from_argv(argv, module, name.replace("-", "_")): + function() else: raise SystemExit(help_message(help_from_module, module)) +def get_function_from_argv(argv, module, name): + """Returns the module's function to call based on argv.""" + if function := is_command(argv, module): + return partial(call, function, argv[1:], module, name) + if inspect.isfunction(function := module.__dict__.get(name)): + return partial(call, function, argv, module) + return None + + def is_command(argv, module): """ Checks if the first argument is a valid command in the module and returns From 45d5f5dfa1e221c14c459d4bf9dbddfb14afaf6e Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:04:11 +0100 Subject: [PATCH 03/18] Inline get_docstring function --- magicli.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/magicli.py b/magicli.py index 15671c6..9fc0c5d 100644 --- a/magicli.py +++ b/magicli.py @@ -61,7 +61,7 @@ def call(function, argv, module=None, name=None): Displays a help message if an exception occurs. """ try: - docstring = get_docstring(function) + docstring = inspect.getdoc(function) or "" parameters = inspect.signature(function).parameters check_for_version(argv, parameters, docstring, module) @@ -251,13 +251,6 @@ def get_commands(module): ] -def get_docstring(function): - """ - Returns the cleaned up docstring of a function or an empty string. - """ - return inspect.getdoc(function) or "" - - def get_version(module): """ Returns the version of a module from its metadata or `__version__` attribute. From e1ed546cb2b600bbcf5a5a601d8b597a9664cc41 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:06:15 +0100 Subject: [PATCH 04/18] Move parse_kwarg function --- magicli.py | 44 +++++++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/magicli.py b/magicli.py index 9fc0c5d..2bf0e03 100644 --- a/magicli.py +++ b/magicli.py @@ -73,9 +73,7 @@ def call(function, argv, module=None, name=None): def args_and_kwargs(argv, parameters, docstring): - """ - Parses command-line arguments into positional and keyword arguments. - """ + """Convert argv into args and kwargs.""" parameter_list = list(parameters.values()) args, kwargs = [], {} @@ -91,6 +89,26 @@ def args_and_kwargs(argv, parameters, docstring): return args, kwargs +def parse_kwarg(key, argv, parameters): + """ + Parses a single keyword argument from command-line arguments. + Handles '=' syntax for inline values. Casts `NoneType` values to `True` + and boolean values to `not default`. + """ + key, value = key.split("=", 1) if "=" in key else (key, None) + key = key.replace("-", "_") + cast_to = get_type(parameters.get(key)) + + if value is None: + if cast_to is bool: + return key, not parameters[key].default + if cast_to is type(None): + return key, True + value = next(argv) + + return key, value if cast_to is str else cast_to(value) + + def parse_short_options(short_options, docstring, iter_argv, parameters, kwargs): """ Converts short options into long options and casts into correct types. @@ -133,26 +151,6 @@ def short_to_long_option(short, docstring): raise SystemExit(f"-{short}: invalid short option") -def parse_kwarg(key, argv, parameters): - """ - Parses a single keyword argument from command-line arguments. - Handles '=' syntax for inline values. Casts `NoneType` values to `True` - and boolean values to `not default`. - """ - key, value = key.split("=", 1) if "=" in key else (key, None) - key = key.replace("-", "_") - cast_to = get_type(parameters.get(key)) - - if value is None: - if cast_to is bool: - return key, not parameters[key].default - if cast_to is type(None): - return key, True - value = next(argv) - - return key, value if cast_to is str else cast_to(value) - - def get_type(parameter): """ Determines the type based on function signature annotations or defaults. From 7f742bf431062a4fd6fcdaeaf8bd41d4ba31bfdd Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:12:13 +0100 Subject: [PATCH 05/18] Refactor short_to_long_option --- magicli.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/magicli.py b/magicli.py index 2bf0e03..9d13518 100644 --- a/magicli.py +++ b/magicli.py @@ -138,16 +138,10 @@ def short_to_long_option(short, docstring): template = f"-{short}, --" if (start := docstring.find(template)) != -1: start += len(template) - chars = (" ", "\n", "]") - - try: - end = min(i for ws in chars if (i := docstring.find(ws, start)) != -1) - return docstring[start:end] - - except ValueError: - if len(docstring) - start > 1: - return docstring[start:] - + if len(docstring) - start > 1: + chars = [" ", "\n", "]"] + indices = (i for char in chars if (i := docstring.find(char, start)) != -1) + return docstring[start : min(indices, default=None)] raise SystemExit(f"-{short}: invalid short option") From 3235415331a94c77364625285f3ff59aad524a0b Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:13:57 +0100 Subject: [PATCH 06/18] Refactor check_for_version --- magicli.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/magicli.py b/magicli.py index 9d13518..3d1ab1c 100644 --- a/magicli.py +++ b/magicli.py @@ -161,18 +161,14 @@ def check_for_version(argv, parameters, docstring, module): """ Displays version information if --version is specified in the docstring. """ - if ( - "version" not in parameters - and any( - (argv == [arg] and string in docstring) - for arg, string in [ - ("--version", "--version"), - ("-v", "-v, --version"), - ("-V", "-V, --version"), - ] - ) - and module - ): + if "version" in parameters or not module or len(argv) != 1: + return + args = { + "--version": "--version", + "-v": "-v, --version", + "-V": "-V, --version", + } + if (doc := args.get(argv[0])) and doc in docstring: print(get_version(module)) raise SystemExit From a8274bf338c99eafea03b333afa23964f37da6d3 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:14:59 +0100 Subject: [PATCH 07/18] Rename message to blocks --- magicli.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/magicli.py b/magicli.py index 3d1ab1c..51bf394 100644 --- a/magicli.py +++ b/magicli.py @@ -204,17 +204,17 @@ def help_from_module(module): Generates a help message for a module and lists available commands. Lists all public functions that are not excluded in `__all__`. """ - message = [] + blocks = [] if version := get_version(module): - message.append([f"{module.__name__} {version}"]) + blocks.append([f"{module.__name__} {version}"]) - message.append(["usage:", f"{module.__name__} command"]) + blocks.append(["usage:", f"{module.__name__} command"]) if commands := get_commands(module): - message.append(["commands:", *commands]) + blocks.append(["commands:", *commands]) - return format_blocks(message) + return format_blocks(blocks) def format_blocks(blocks, sep="\n "): From 97cac68bfdc11195a9f1904b19133dd793623349 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:16:35 +0100 Subject: [PATCH 08/18] Fix error in docstring --- magicli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magicli.py b/magicli.py index 51bf394..d5b1001 100644 --- a/magicli.py +++ b/magicli.py @@ -231,7 +231,7 @@ def load_module(name): def get_commands(module): - """Returns list of public commands that are not present in `__all__`.""" + """Returns list of public commands that are not excluded by `__all__`.""" return [ name for name, _ in inspect.getmembers(module, inspect.isfunction) From 60b0c17e1e3e4468c44f9026971ad806bb4a6d27 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:17:51 +0100 Subject: [PATCH 09/18] Refactor get_output --- magicli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/magicli.py b/magicli.py index d5b1001..df810db 100644 --- a/magicli.py +++ b/magicli.py @@ -275,10 +275,10 @@ def get_output(command): try: output = subprocess.run( command.split(), capture_output=True, text=True, check=False - ).stdout + ) except FileNotFoundError: return None - return output.removesuffix("\n") if output else None + return output.stdout.removesuffix("\n") if output else None def get_homepage(url=None): From dcb51e2256a93e9aebaf39395ceea19a137b501b Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:18:24 +0100 Subject: [PATCH 10/18] Simplify get_homepage --- magicli.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/magicli.py b/magicli.py index df810db..0c97bea 100644 --- a/magicli.py +++ b/magicli.py @@ -283,11 +283,10 @@ def get_output(command): def get_homepage(url=None): """Return a homepage url from a git remote url.""" - url = url or get_output("git remote get-url origin") or "" - url = url.removesuffix(".git") + url = url or get_output("git remote get-url origin") if url.startswith("git@"): - url = "https://" + url.replace(":", "/")[4:] - return url + url = "https://" + url.removeprefix("git@").replace(":", "/") + return url.removesuffix(".git") def get_description(name): From 50088bd8fc4be229092e2cb72e480574049dbe14 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:22:38 +0100 Subject: [PATCH 11/18] Refactor get_description --- magicli.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/magicli.py b/magicli.py index 0c97bea..e4dcc54 100644 --- a/magicli.py +++ b/magicli.py @@ -292,13 +292,11 @@ def get_homepage(url=None): def get_description(name): """Return the first paragraph of a module's docstring if available.""" try: - if doc := (importlib.import_module(name).__doc__ or "").split("\n\n"): - return " ".join( - [stripped for line in doc[0].splitlines() if (stripped := line.strip())] - ) + module = importlib.import_module(name) except ModuleNotFoundError: - pass - return None + return None + doc = (module.__doc__ or "").split("\n\n")[0] + return " ".join(stripped for line in doc.splitlines() if (stripped := line.strip())) def cli( From 2fc2e739600ac59f3e8e8a8ad66ac05d14adfc44 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:24:39 +0100 Subject: [PATCH 12/18] Refactor cli function --- magicli.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/magicli.py b/magicli.py index e4dcc54..b585476 100644 --- a/magicli.py +++ b/magicli.py @@ -299,13 +299,7 @@ def get_description(name): return " ".join(stripped for line in doc.splitlines() if (stripped := line.strip())) -def cli( - name="", - author="", - email="", - description="", - homepage="", -): +def cli(name="", author="", email="", description="", homepage=""): """ magiCLI✨ @@ -345,11 +339,11 @@ def cli( if authors: project.append(f"authors = [{{{', '.join(authors)}}}]") - if Path(readme := "README.md").exists(): - project.append(f'readme = "{readme}"') + if Path("README.md").exists(): + project.append(f'readme = "README.md"') - if Path(license_file := "LICENSE").exists(): - project.append(f'license-files = ["{license_file}"]') + if Path("LICENSE").exists(): + project.append(f'license-files = ["LICENSE"]') if description or (description := get_description(name)): project.append(f'description = "{description}"') @@ -369,11 +363,10 @@ def cli( pyproject.write_text(format_blocks(blocks, sep="\n") + "\n", encoding="utf-8") - message = ["pyproject.toml created! ✨"] if Path(".git").exists(): - message.append("You can specify the version with `git tag`") + git_note = "You can specify the version with `git tag`" else: - message.append( + git_note = ( "Error: Not a git repo. Run `git init`. Specify version with `git tag`." ) - print(*message, sep="\n") + print("pyproject.toml created! ✨", git_note, sep="\n") From 9d9bf3bed9908c4c5e1bbea1ef9bd87649a95161 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:25:50 +0100 Subject: [PATCH 13/18] Lint code --- magicli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/magicli.py b/magicli.py index b585476..f44c117 100644 --- a/magicli.py +++ b/magicli.py @@ -340,10 +340,10 @@ def cli(name="", author="", email="", description="", homepage=""): project.append(f"authors = [{{{', '.join(authors)}}}]") if Path("README.md").exists(): - project.append(f'readme = "README.md"') + project.append('readme = "README.md"') if Path("LICENSE").exists(): - project.append(f'license-files = ["LICENSE"]') + project.append('license-files = ["LICENSE"]') if description or (description := get_description(name)): project.append(f'description = "{description}"') From 545fcaf6646ea26392685e991ac0da8c51ef550f Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:32:17 +0100 Subject: [PATCH 14/18] Exclude flake8 rule E203 whitespace before ':' (interferes with code formatting) --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8331182..e5a4225 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: - uses: astral-sh/setup-uv@v5 with: cache-dependency-glob: "" - - run: uv run --with flake8 flake8 magicli.py --extend-ignore=E501 + - run: uv run --with flake8 flake8 magicli.py --extend-ignore=E203,E501 pylint: runs-on: ubuntu-latest From 2b55b4194d141338dacbbe45c3961cfb21a00f02 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 13:56:58 +0100 Subject: [PATCH 15/18] Move pylint config to pyproject.toml --- .github/workflows/lint.yml | 2 +- pyproject.toml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e5a4225..0ddc590 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,7 +22,7 @@ jobs: - uses: astral-sh/setup-uv@v5 with: cache-dependency-glob: "" - - run: uv run --with pylint pylint --disable=unidiomatic-typecheck,raise-missing-from magicli.py + - run: uv run --with pylint pylint magicli.py ruff: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index 1ff6576..e34ad77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,3 +19,9 @@ dev = ["pytest"] [project.urls] Home = "https://github.com/PatrickElmer/magicli" + +[tool.pylint."messages control"] +disable = [ + "unidiomatic-typecheck", + "raise-missing-from", +] From d53d9c3c9f495fe09ff719ca56b4ff09ceee38c4 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 14:03:33 +0100 Subject: [PATCH 16/18] Put single-line docstrings on one line --- magicli.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/magicli.py b/magicli.py index f44c117..ed60337 100644 --- a/magicli.py +++ b/magicli.py @@ -14,9 +14,7 @@ def magicli(): - """ - Parses command-line arguments and calls the appropriate function. - """ + """Parses command-line arguments and calls the appropriate function.""" name = Path(sys.argv[0]).name argv = sys.argv[1:] @@ -110,9 +108,7 @@ def parse_kwarg(key, argv, parameters): def parse_short_options(short_options, docstring, iter_argv, parameters, kwargs): - """ - Converts short options into long options and casts into correct types. - """ + """Converts short options into long options and casts into correct types.""" for i, short in enumerate(short_options): long = short_to_long_option(short, docstring) @@ -132,9 +128,7 @@ def parse_short_options(short_options, docstring, iter_argv, parameters, kwargs) def short_to_long_option(short, docstring): - """ - Converts a one character short option to a long option according to the help message. - """ + """Converts a one character short option to a long option according to the help message.""" template = f"-{short}, --" if (start := docstring.find(template)) != -1: start += len(template) @@ -158,9 +152,7 @@ def get_type(parameter): def check_for_version(argv, parameters, docstring, module): - """ - Displays version information if --version is specified in the docstring. - """ + """Displays version information if --version is specified in the docstring.""" if "version" in parameters or not module or len(argv) != 1: return args = { @@ -240,9 +232,7 @@ def get_commands(module): def get_version(module): - """ - Returns the version of a module from its metadata or `__version__` attribute. - """ + """Returns the version of a module from its metadata or `__version__` attribute.""" try: return metadata.version(module.__name__) except metadata.PackageNotFoundError: @@ -250,9 +240,7 @@ def get_version(module): def get_project_name(): - """ - Detect project name from project structure. - """ + """Detect project name from project structure.""" single_file_layout = [path.stem for path in Path().glob("*.py")] flat_layout = [ path.parent.name From d6fe9bdc2f7a354d4cd5e769899690d80f8e0419 Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Fri, 20 Mar 2026 14:06:07 +0100 Subject: [PATCH 17/18] Rename args_and_kwargs --- magicli.py | 4 ++-- tests/test_parse_kwarg.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/magicli.py b/magicli.py index ed60337..d662134 100644 --- a/magicli.py +++ b/magicli.py @@ -64,13 +64,13 @@ def call(function, argv, module=None, name=None): check_for_version(argv, parameters, docstring, module) - args, kwargs = args_and_kwargs(argv, parameters, docstring) + args, kwargs = parse_argv(argv, parameters, docstring) function(*args, **kwargs) except Exception: raise SystemExit(help_message(help_from_function, function, name)) -def args_and_kwargs(argv, parameters, docstring): +def parse_argv(argv, parameters, docstring): """Convert argv into args and kwargs.""" parameter_list = list(parameters.values()) args, kwargs = [], {} diff --git a/tests/test_parse_kwarg.py b/tests/test_parse_kwarg.py index 6cb0acf..eb13275 100644 --- a/tests/test_parse_kwarg.py +++ b/tests/test_parse_kwarg.py @@ -3,7 +3,7 @@ import pytest -from magicli import args_and_kwargs, get_type, parse_kwarg +from magicli import parse_argv, get_type, parse_kwarg PK = _ParameterKind.POSITIONAL_OR_KEYWORD @@ -34,25 +34,25 @@ def test_get_type(): assert get_type(Parameter("c", PK)) is str -def test_args_and_kwargs(): +def test_parse_argv(): parameters = inspect.signature(lambda arg, kwarg=1: None).parameters - assert args_and_kwargs(["a", "--kwarg=2"], parameters, docstring="") == ( + assert parse_argv(["a", "--kwarg=2"], parameters, docstring="") == ( ["a"], {"kwarg": 2}, ) - assert args_and_kwargs(["a", "--kwarg", "2"], parameters, docstring="") == ( + assert parse_argv(["a", "--kwarg", "2"], parameters, docstring="") == ( ["a"], {"kwarg": 2}, ) -def test_args_and_kwargs_with_underscore(): +def test_parse_argv_with_underscore(): parameters = inspect.signature(lambda arg, kwarg_1=1: None).parameters - assert args_and_kwargs(["a", "--kwarg-1=2"], parameters, docstring="") == ( + assert parse_argv(["a", "--kwarg-1=2"], parameters, docstring="") == ( ["a"], {"kwarg_1": 2}, ) - assert args_and_kwargs(["a", "--kwarg-1", "2"], parameters, docstring="") == ( + assert parse_argv(["a", "--kwarg-1", "2"], parameters, docstring="") == ( ["a"], {"kwarg_1": 2}, ) From 358e4674ff06cf7dc4f7da52b2ca016fd5f5536b Mon Sep 17 00:00:00 2001 From: Patrick Elmer Date: Sat, 21 Mar 2026 13:30:20 +0100 Subject: [PATCH 18/18] Minor tweaks --- magicli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/magicli.py b/magicli.py index d662134..20d2e36 100644 --- a/magicli.py +++ b/magicli.py @@ -263,15 +263,15 @@ def get_output(command): try: output = subprocess.run( command.split(), capture_output=True, text=True, check=False - ) + ).stdout except FileNotFoundError: return None - return output.stdout.removesuffix("\n") if output else None + return output.removesuffix("\n") or None def get_homepage(url=None): """Return a homepage url from a git remote url.""" - url = url or get_output("git remote get-url origin") + url = url or get_output("git remote get-url origin") or "" if url.startswith("git@"): url = "https://" + url.removeprefix("git@").replace(":", "/") return url.removesuffix(".git")