diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4b7fed5..c9ab595 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ruff pytest pytest-cov + pip install ruff pytest pytest-cov click pip install -e . -e test/juliapkg_test_editable_setuptools - name: Lint with ruff run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index fc094c1..0ba99f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +* Add the CLI. * Improve some error messages. ## v0.1.18 (2025-09-01) diff --git a/README.md b/README.md index 3efc8c1..4d281aa 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,18 @@ Julia v1.*.* and the Example package v0.5.*: } ``` +### Command line interface + +You can also use the CLI, some examples: +```sh +python -m juliapkg --help +python -m juliapkg add Example --uuid=7876af07-990d-54b4-ab0e-23690620f79a --version=0.5 +python -m juliapkg resolve +python -m juliapkg status +python -m juliapkg run -E 'using Example; Example.hello("world")' +python -m juliapkg remove Example +``` + ## Using Julia - `juliapkg.executable()` returns a compatible Julia executable. diff --git a/pyproject.toml b/pyproject.toml index 03511ee..f62fde6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,9 @@ classifiers = [ "Programming Language :: Python :: 3", ] +[project.optional-dependencies] +cli = ["click >=8.0,<9.0"] + [project.urls] Homepage = "http://github.com/JuliaPy/pyjuliapkg" Repository = "http://github.com/JuliaPy/pyjuliapkg.git" diff --git a/src/juliapkg/__main__.py b/src/juliapkg/__main__.py new file mode 100644 index 0000000..1490994 --- /dev/null +++ b/src/juliapkg/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for python -m juliapkg.""" + +if __name__ == "__main__": + from .cli import cli + + cli() diff --git a/src/juliapkg/cli.py b/src/juliapkg/cli.py new file mode 100644 index 0000000..6c62bb6 --- /dev/null +++ b/src/juliapkg/cli.py @@ -0,0 +1,142 @@ +"""Command-line interface for juliapkg.""" + +import os +import subprocess +import sys + +from .deps import STATE, add, resolve, rm, status, update + +try: + import click +except ImportError: + click = None + +if click is None: + + def cli(): + raise ImportError( + "`click` is required to use the juliapkg CLI. " + "Please install it with `pip install click` or " + '`pip install "pyjuliapkg[cli]".' + ) + +else: + + class JuliaPkgGroup(click.Group): + """Custom group to avoid long stacktraces when Julia exits with an error.""" + + @property + def always_show_python_error(self) -> bool: + return ( + os.environ.get("PYTHON_JULIAPKG_CLI_ALWAYS_SHOW_PYTHON_ERROR", "0") + == "1" + ) + + @staticmethod + def _is_graceful_exit(e: subprocess.CalledProcessError) -> bool: + """Try to guess if a CalledProcessError was Julia gracefully exiting.""" + return e.returncode == 1 + + def invoke(self, ctx): + try: + return super().invoke(ctx) + except subprocess.CalledProcessError as e: + # Julia already printed an error message + if ( + JuliaPkgGroup._is_graceful_exit(e) + and not self.always_show_python_error + ): + click.get_current_context().exit(1) + else: + raise + + cli = JuliaPkgGroup(help="JuliaPkg - Manage your Julia dependencies from Python.") + + @cli.command(name="add") + @click.argument("package") + @click.option("--uuid", required=True, help="UUID of the package") + @click.option("--version", help="Version constraint") + @click.option("--dev", is_flag=True, help="Add as development dependency") + @click.option("--path", help="Local path to package") + @click.option("--subdir", help="Subdirectory within the package") + @click.option("--url", help="Git URL for the package") + @click.option("--rev", help="Git revision/branch/tag") + @click.option("--target", help="Target environment") + def add_cli(package, uuid, version, dev, path, subdir, url, rev, target): + """Add a Julia package to the project.""" + add( + package, + uuid=uuid, + version=version, + dev=dev, + path=path, + subdir=subdir, + url=url, + rev=rev, + target=target, + ) + click.echo(f"Queued addition of {package}. Run `resolve` to apply changes.") + + @cli.command(name="resolve") + @click.option("--force", is_flag=True, help="Force resolution") + @click.option("--dry-run", is_flag=True, help="Dry run (don't actually install)") + @click.option("--update", is_flag=True, help="Update dependencies") + def resolve_cli(force, dry_run, update): + """Resolve and install Julia dependencies.""" + resolve(force=force, dry_run=dry_run, update=update) + click.echo("Resolved dependencies.") + + @cli.command(name="remove") + @click.argument("package") + @click.option("--target", help="Target environment") + def remove_cli(package, target): + """Remove a Julia package from the project.""" + rm(package, target=target) + click.echo(f"Queued removal of {package}. Run `resolve` to apply changes.") + + @cli.command(name="status") + @click.option("--target", help="Target environment") + def status_cli(target): + """Show the status of Julia packages in the project.""" + status(target=target) + + @cli.command(name="update") + @click.option("--dry-run", is_flag=True, help="Dry run (don't actually install)") + def update_cli(dry_run): + """Update Julia packages in the project.""" + update(dry_run=dry_run) + + @cli.command(name="run", context_settings=dict(ignore_unknown_options=True)) + @click.argument("args", nargs=-1) + def run_cli(args): + """Pass-through to Julia CLI. + + For example, use `run` to launch a REPL or `run script.jl` to run a script. + """ + resolve() + executable = STATE["executable"] + project = STATE["project"] + + env = os.environ.copy() + if sys.executable: + # prefer PythonCall to use the current Python executable + # TODO: this is a hack, it would be better for PythonCall to detect that + # Julia is being called from Python + env.setdefault("JULIA_PYTHONCALL_EXE", sys.executable) + cmd = [ + executable, + "--project=" + project, + ] + for arg in args: + if arg.startswith("--project"): + raise ValueError("Do not specify --project when using pyjuliapkg.") + cmd.append(arg) + subprocess.run( + cmd, + check=True, + env=env, + ) + + +if __name__ == "__main__": + cli() diff --git a/test/test_cli.py b/test/test_cli.py new file mode 100644 index 0000000..cc1189c --- /dev/null +++ b/test/test_cli.py @@ -0,0 +1,68 @@ +import importlib +import sys +from unittest.mock import patch + +import pytest + +from juliapkg.cli import cli + + +@pytest.fixture +def runner(): + try: + from click.testing import CliRunner + + return CliRunner() + except ImportError: + pytest.skip("click is not available") + + +class TestCLI: + def test_cli_help(self, runner): + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "JuliaPkg - Manage your Julia dependencies from Python." in result.output + + def test_cli_no_args(self, runner): + result = runner.invoke(cli, []) + assert "Usage:" in result.output + + def test_run_with_project(self, runner): + result = runner.invoke(cli, ["run", "--project=/tmp/test"]) + assert result.exit_code != 0 + assert "Do not specify --project when using pyjuliapkg" in str(result.exception) + + def test_run_command(self, runner): + result = runner.invoke(cli, ["run", "-e", "using Pkg; Pkg.status()"]) + assert result.exit_code == 0 + + def test_basic_usage(self, runner): + result = runner.invoke( + cli, ["add", "Example", "--uuid", "7876af07-990d-54b4-ab0e-23690620f79a"] + ) + assert result.exit_code == 0 + assert "Queued addition of Example" in result.output + + result = runner.invoke(cli, ["resolve"]) + assert result.exit_code == 0 + + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0 + assert "Example" in result.output + + result = runner.invoke(cli, ["remove", "Example"]) + assert result.exit_code == 0 + assert "Queued removal of Example" in result.output + + result = runner.invoke(cli, ["resolve", "--force"]) + assert result.exit_code == 0 + + def test_click_not_available(self): + with patch.dict(sys.modules, {"click": None, "juliapkg.cli": None}): + del sys.modules["juliapkg.cli"] + cli_module = importlib.import_module("juliapkg.cli") + + with pytest.raises(ImportError) as exc_info: + cli_module.cli() + + assert "`click` is required to use the juliapkg CLI" in str(exc_info.value)