diff --git a/.gitignore b/.gitignore index fa3165f..86072c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ _* -.pytest_cache +.*_cache .vscode .venv build diff --git a/magicli.py b/magicli.py index c335879..85402e3 100644 --- a/magicli.py +++ b/magicli.py @@ -6,6 +6,7 @@ import importlib import inspect +import subprocess import sys from importlib import metadata from pathlib import Path @@ -22,7 +23,7 @@ def magicli(): argv = sys.argv[1:] if name == "magicli": - raise SystemExit(call(cli, argv)) + raise SystemExit(call(cli, argv, sys.modules["magicli"])) module = load_module(name) name = name.replace("-", "_") @@ -198,7 +199,7 @@ def help_from_function(function, name=None): message = [name] if name else [] message.append(function.__name__) message.extend(map(format_kwarg, inspect.signature(function).parameters.values())) - return format_message([["usage:", " ".join(message)]]) + return format_blocks([["usage:", " ".join(message)]]) def format_kwarg(kwarg): @@ -221,12 +222,12 @@ def help_from_module(module): if commands := get_commands(module): message.append(["commands:", *commands]) - return format_message(message) + return format_blocks(message) -def format_message(blocks): +def format_blocks(blocks, sep="\n "): """Formats blocks of text with proper indentation.""" - return "\n\n".join("\n ".join(block) for block in blocks) + return "\n\n".join(sep.join(block) for block in blocks) def load_module(name): @@ -267,10 +268,15 @@ def get_project_name(): """ Detect project name from project structure. """ - flat_layout = [path.stem for path in Path().glob("*.py")] - src_layout = [path.parent.name for path in Path().glob("*/__init__.py")] + single_file_layout = [path.stem for path in Path().glob("*.py")] + flat_layout = [ + path.parent.name + for path in Path().glob("*/__init__.py") + if path.parent.name != "tests" + ] + src_layout = [path.parent.name for path in Path().glob("src/*/__init__.py")] - if len(names := flat_layout + src_layout) == 1: + if len(names := single_file_layout + flat_layout + src_layout) == 1: return names[0] if name := input("CLI name: "): @@ -279,10 +285,61 @@ def get_project_name(): raise SystemExit(1) -def cli(): +def get_output(command): + """Return the stdout of a shell command or None on failure.""" + 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 + + +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") + if url.startswith("git@"): + url = "https://" + url.replace(":", "/")[4:] + return url + + +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())] + ) + except ModuleNotFoundError: + pass + return None + + +def cli( + name="", + author="", + email="", + description="", + homepage="", +): """ + magiCLI✨ + Generates a "pyproject.toml" configuration file for a module and sets up the project script. The CLI name must be the same as the module name. + + usage: + magicli [option] + + options: + --name + --author + --email + --description + --homepage + -v, --version """ pyproject = Path("pyproject.toml") if ( @@ -291,23 +348,45 @@ def cli(): ): raise SystemExit(1) - name = get_project_name() - pyproject.write_text( - f"""\ -[build-system] -requires = ["setuptools>=80", "setuptools-scm[simple]>=8"] -build-backend = "setuptools.build_meta" + name = name or get_project_name() + author = author or get_output("git config --get user.name") + email = email or get_output("git config --get user.email") + authors = [f'{k}="{v}"' for k, v in {"name": author, "email": email}.items() if v] + + project = [ + "[project]", + f'name = "{name}"', + 'dynamic = ["version"]', + 'dependencies = ["magicli<3"]', + ] -[project] -name = "{name}" -dynamic = ["version"] -dependencies = ["magicli<3"] + if authors: + project.append(f"authors = [{{{', '.join(authors)}}}]") -[project.scripts] -{name} = "magicli:magicli" -""" + if Path(readme := "README.md").exists(): + project.append(f'readme = "{readme}"') + + if Path(license_file := "LICENSE").exists(): + project.append(f'license-files = ["{license_file}"]') + + if description or (description := get_description(name)): + project.append(f'description = "{description}"') + + blocks = [project, ["[project.scripts]", f'{name} = "magicli:magicli"']] + + if homepage or (homepage := get_homepage()): + blocks.append(["[project.urls]", f'Home = "{homepage}"']) + + blocks.append( + [ + "[build-system]", + 'requires = ["setuptools>=80", "setuptools-scm[simple]>=8"]', + 'build-backend = "setuptools.build_meta"', + ] ) + 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`") diff --git a/tests/fixtures.py b/tests/fixtures.py index a12ff87..b526fe3 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,54 +1,65 @@ import os -import shutil from pathlib import Path +from tempfile import TemporaryDirectory import pytest -@pytest.fixture() -def setup(): +def _setup(filenames, dirname=None): cwd = Path.cwd() - path = Path("tests", "tmp") - - # Make sure the directory does not exist - if path.exists(): - shutil.rmtree(path) - - path.mkdir(exist_ok=True) - Path(path, "module.py").touch() - os.chdir(path) - - yield path - + directory = TemporaryDirectory() + if dirname: + Path(directory.name, dirname).mkdir() + os.chdir(directory.name) + else: + os.chdir(directory.name) + for filename in filenames: + Path(directory.name, filename).touch() + return directory, cwd + + +def _teardown(directory, cwd): + directory.cleanup() os.chdir(cwd) - shutil.rmtree(path) -@pytest.fixture() -def two_py(): - file = Path("two.py") - file.touch() +@pytest.fixture +def with_tempdir(): + directory, cwd = _setup(["module.py"]) + yield directory.name + _teardown(directory, cwd) - yield file - file.unlink() +@pytest.fixture +def with_readme_and_license(): + directory, cwd = _setup(["README.md", "LICENSE"]) + yield directory.name + _teardown(directory, cwd) -@pytest.fixture() -def pyproject_toml(): - file = Path("pyproject.toml") - file.touch() +@pytest.fixture +def with_two_files(): + directory, cwd = _setup(["module.py", "two.py"]) + yield + _teardown(directory, cwd) - yield file - file.unlink() +@pytest.fixture +def pyproject(): + directory, cwd = _setup(["pyproject.toml", "module.py"]) + yield Path(directory.name, "pyproject.toml") + _teardown(directory, cwd) -@pytest.fixture() -def dotgit(): - dir = Path(".git") - dir.mkdir(exist_ok=True) +@pytest.fixture +def with_git(): + directory, cwd = _setup([], dirname=".git") + yield + _teardown(directory, cwd) - yield dir - shutil.rmtree(dir) +@pytest.fixture +def empty_directory(): + directory, cwd = _setup([]) + yield + _teardown(directory, cwd) diff --git a/tests/test_cli.py b/tests/test_cli.py index 467e5a4..6e82c3d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,13 +2,26 @@ from unittest import mock import pytest -from fixtures import pyproject_toml, setup, two_py, dotgit +from fixtures import ( + empty_directory, + pyproject, + with_git, + with_readme_and_license, + with_tempdir, + with_two_files, +) -from magicli import cli, get_project_name +from magicli import cli, get_description, get_homepage, get_output, get_project_name + + +def module(name): + module = type(pytest)(name) + module.__doc__ = "docstring" + return module @mock.patch("builtins.input", lambda _: "two") -def test_correct_name_input(setup, two_py): +def test_correct_name_input(with_two_files): cli() path = Path("pyproject.toml") @@ -17,46 +30,96 @@ def test_correct_name_input(setup, two_py): assert 'name = "two"' in f.read() -@mock.patch("builtins.input", lambda *args: "n") -def test_automatic_name(setup): +@mock.patch("builtins.input", lambda *_: "y") +def test_automatic_name(pyproject): cli() - with Path("pyproject.toml").open() as f: - assert 'name = "module"' in f.read() + assert 'name = "module"' in pyproject.read_text() -@mock.patch("builtins.input", lambda *args: "y") -def test_overwrite_pyproject_toml(setup, pyproject_toml): + +@mock.patch("builtins.input", lambda *_: "y") +def test_overwrite_pyproject_toml(pyproject): cli() - with pyproject_toml.open() as f: - assert 'name = "module"' in f.read() + + assert 'name = "module"' in pyproject.read_text() -@mock.patch("builtins.input", lambda *args: "") -def test_empty_cli_name_failure(setup, two_py): +@mock.patch("builtins.input", lambda *_: "") +def test_empty_cli_name_failure(with_two_files): with pytest.raises(SystemExit) as error: get_project_name() assert error.value.code == 1 -def test_on_git_repo(capsys, setup): - cli() +def test_with_git_repo(capsys, with_git): + cli(name="_") out, _ = capsys.readouterr() - with_git = ( + without_git = ( "Error: Not a git repo. Run `git init`. Specify version with `git tag`.\n" ) - without_git = "You can specify the version with `git tag`\n" + with_git = "You can specify the version with `git tag`\n" assert out.endswith(with_git) assert without_git not in out -def test_git_repo(capsys, setup, dotgit): - cli() +def test_without_git_repo(capsys, empty_directory): + cli(name="_") out, _ = capsys.readouterr() - with_git = ( + without_git = ( "Error: Not a git repo. Run `git init`. Specify version with `git tag`.\n" ) - without_git = "You can specify the version with `git tag`\n" + with_git = "You can specify the version with `git tag`\n" assert out.endswith(without_git) assert with_git not in out + + +def test_get_output(): + assert get_output("ls") is not None + assert get_output("-") is None + + +def test_get_homepage(): + for url in [ + "https://github.com/PatrickElmer/magicli.git", + "git@github.com:PatrickElmer/magicli.git", + ]: + assert get_homepage(url) == "https://github.com/PatrickElmer/magicli" + + +def test_get_description(): + assert get_description("magicli") is not None + + +def test_cli_with_kwargs(with_readme_and_license): + cli( + name="name", + author="Patrick Elmer", + email="patrick@elmer.ws", + description="docstring", + homepage="https://github.com/PatrickElmer/magicli", + ) + assert ( + Path("pyproject.toml").read_text() + == """\ +[project] +name = "name" +dynamic = ["version"] +dependencies = ["magicli<3"] +authors = [{name="Patrick Elmer", email="patrick@elmer.ws"}] +readme = "README.md" +license-files = ["LICENSE"] +description = "docstring" + +[project.scripts] +name = "magicli:magicli" + +[project.urls] +Home = "https://github.com/PatrickElmer/magicli" + +[build-system] +requires = ["setuptools>=80", "setuptools-scm[simple]>=8"] +build-backend = "setuptools.build_meta" +""" + ) diff --git a/tests/test_magicli.py b/tests/test_magicli.py index 6281e9f..68dcb48 100644 --- a/tests/test_magicli.py +++ b/tests/test_magicli.py @@ -1,8 +1,9 @@ import sys -from unittest import mock from functools import partial +from unittest import mock import pytest +from fixtures import pyproject from magicli import magicli @@ -84,8 +85,8 @@ def test_module_not_found(): magicli() -@mock.patch("builtins.input", lambda *args: "n") -def test_module_is_magicli(): +@mock.patch("builtins.input", return_value="n") +def test_module_is_magicli(pyproject): sys.argv = ["magicli"] with pytest.raises(SystemExit) as error: magicli() diff --git a/tests/test_parse_kwarg.py b/tests/test_parse_kwarg.py index f879d26..6cb0acf 100644 --- a/tests/test_parse_kwarg.py +++ b/tests/test_parse_kwarg.py @@ -29,9 +29,9 @@ def test_parse_kwarg_bool_and_none(default, result): def test_get_type(): - assert get_type(Parameter("a", PK, annotation=int)) == int - assert get_type(Parameter("b", PK, default=1)) == int - assert get_type(Parameter("c", PK)) == str + assert get_type(Parameter("a", PK, annotation=int)) is int + assert get_type(Parameter("b", PK, default=1)) is int + assert get_type(Parameter("c", PK)) is str def test_args_and_kwargs(): diff --git a/tests/test_parse_short_options.py b/tests/test_parse_short_options.py index d9b7004..a90705e 100644 --- a/tests/test_parse_short_options.py +++ b/tests/test_parse_short_options.py @@ -1,4 +1,3 @@ -from functools import partial from inspect import Parameter, _ParameterKind import pytest @@ -7,7 +6,7 @@ @pytest.mark.parametrize( - ["default", "result"], + ("default", "result"), [ (None, True), (True, False),