Skip to content
Merged

CLI #64

Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ classifiers = [
"Programming Language :: Python :: 3",
]

[project.scripts]
pyjuliapkg = "juliapkg.cli:cli"

[project.optional-dependencies]
cli = ["click >=8.0,<9.0"]

[project.urls]
Homepage = "http://github.com/JuliaPy/pyjuliapkg"
Repository = "http://github.com/JuliaPy/pyjuliapkg.git"
Expand Down
6 changes: 6 additions & 0 deletions src/juliapkg/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Entry point for python -m juliapkg."""

if __name__ == "__main__":
from .cli import cli

cli()
143 changes: 143 additions & 0 deletions src/juliapkg/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Command-line interface for juliapkg."""

import os
import subprocess
import sys

from .deps import STATE, add, resolve, rm, status, update

try:
import click

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("JULIAPKG_ALWAYS_SHOW_PYTHON_ERROR_CLI", "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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thinking here is that the user does not care to see the subprocess.CalledProcessError stacktrace most of the time. So, for the CLI only, why not hide it?

It may be nice to put something like Hint: Detected a Julia error; the Python stacktrace is hidden. Set JULIAPKG_ALWAYS_SHOW_PYTHON_ERROR_CLI=1 to see the full stacktrace.


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.add_command(remove_cli, name="rm")

@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.add_command(status_cli, name="st")

@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.add_command(update_cli, name="up")

@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,
)

cli.add_command(run_cli, name="repl")

except ImportError:

def cli():
raise ImportError(
"`click` is required to use the juliapkg CLI. "
"Please install it with `pip install click` or "
'`pip install "pyjuliapkg[cli]".'
)


if __name__ == "__main__":
cli()
68 changes: 68 additions & 0 deletions test/test_cli.py
Original file line number Diff line number Diff line change
@@ -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_repl_with_project(self, runner):
result = runner.invoke(cli, ["repl", "--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)
Loading