Skip to content
Merged

CLI #64

Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
* Add the CLI.
* Improve some error messages.

## v0.1.18 (2025-09-01)
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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()
142 changes: 142 additions & 0 deletions src/juliapkg/cli.py
Original file line number Diff line number Diff line change
@@ -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()
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_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)
Loading