Skip to content

Commit 784f7fa

Browse files
authored
CLI (#64)
* init cli * fmt * fmt2 * cleanup and add repl * custom group * fmt * shorter name * cleanup * rm dev deps * tests * cleanup tests * test with click on CI * cleaner env var * force needed? * fix test error * rm error handling test * don't check no arg exit * cleanup * remove pyjuliapkg script * remove aliases for now * nitpick * rename env var * document the CLI * removed repl alias for run --------- Co-authored-by: Christopher Doris <github.com/cjdoris>
1 parent aafd5b6 commit 784f7fa

File tree

7 files changed

+233
-1
lines changed

7 files changed

+233
-1
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
- name: Install dependencies
2828
run: |
2929
python -m pip install --upgrade pip
30-
pip install ruff pytest pytest-cov
30+
pip install ruff pytest pytest-cov click
3131
pip install -e . -e test/juliapkg_test_editable_setuptools
3232
- name: Lint with ruff
3333
run: |

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## Unreleased
4+
* Add the CLI.
45
* Improve some error messages.
56

67
## v0.1.18 (2025-09-01)

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ Julia v1.*.* and the Example package v0.5.*:
5555
}
5656
```
5757

58+
### Command line interface
59+
60+
You can also use the CLI, some examples:
61+
```sh
62+
python -m juliapkg --help
63+
python -m juliapkg add Example --uuid=7876af07-990d-54b4-ab0e-23690620f79a --version=0.5
64+
python -m juliapkg resolve
65+
python -m juliapkg status
66+
python -m juliapkg run -E 'using Example; Example.hello("world")'
67+
python -m juliapkg remove Example
68+
```
69+
5870
## Using Julia
5971

6072
- `juliapkg.executable()` returns a compatible Julia executable.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ classifiers = [
1111
"Programming Language :: Python :: 3",
1212
]
1313

14+
[project.optional-dependencies]
15+
cli = ["click >=8.0,<9.0"]
16+
1417
[project.urls]
1518
Homepage = "http://github.com/JuliaPy/pyjuliapkg"
1619
Repository = "http://github.com/JuliaPy/pyjuliapkg.git"

src/juliapkg/__main__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Entry point for python -m juliapkg."""
2+
3+
if __name__ == "__main__":
4+
from .cli import cli
5+
6+
cli()

src/juliapkg/cli.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Command-line interface for juliapkg."""
2+
3+
import os
4+
import subprocess
5+
import sys
6+
7+
from .deps import STATE, add, resolve, rm, status, update
8+
9+
try:
10+
import click
11+
except ImportError:
12+
click = None
13+
14+
if click is None:
15+
16+
def cli():
17+
raise ImportError(
18+
"`click` is required to use the juliapkg CLI. "
19+
"Please install it with `pip install click` or "
20+
'`pip install "pyjuliapkg[cli]".'
21+
)
22+
23+
else:
24+
25+
class JuliaPkgGroup(click.Group):
26+
"""Custom group to avoid long stacktraces when Julia exits with an error."""
27+
28+
@property
29+
def always_show_python_error(self) -> bool:
30+
return (
31+
os.environ.get("PYTHON_JULIAPKG_CLI_ALWAYS_SHOW_PYTHON_ERROR", "0")
32+
== "1"
33+
)
34+
35+
@staticmethod
36+
def _is_graceful_exit(e: subprocess.CalledProcessError) -> bool:
37+
"""Try to guess if a CalledProcessError was Julia gracefully exiting."""
38+
return e.returncode == 1
39+
40+
def invoke(self, ctx):
41+
try:
42+
return super().invoke(ctx)
43+
except subprocess.CalledProcessError as e:
44+
# Julia already printed an error message
45+
if (
46+
JuliaPkgGroup._is_graceful_exit(e)
47+
and not self.always_show_python_error
48+
):
49+
click.get_current_context().exit(1)
50+
else:
51+
raise
52+
53+
cli = JuliaPkgGroup(help="JuliaPkg - Manage your Julia dependencies from Python.")
54+
55+
@cli.command(name="add")
56+
@click.argument("package")
57+
@click.option("--uuid", required=True, help="UUID of the package")
58+
@click.option("--version", help="Version constraint")
59+
@click.option("--dev", is_flag=True, help="Add as development dependency")
60+
@click.option("--path", help="Local path to package")
61+
@click.option("--subdir", help="Subdirectory within the package")
62+
@click.option("--url", help="Git URL for the package")
63+
@click.option("--rev", help="Git revision/branch/tag")
64+
@click.option("--target", help="Target environment")
65+
def add_cli(package, uuid, version, dev, path, subdir, url, rev, target):
66+
"""Add a Julia package to the project."""
67+
add(
68+
package,
69+
uuid=uuid,
70+
version=version,
71+
dev=dev,
72+
path=path,
73+
subdir=subdir,
74+
url=url,
75+
rev=rev,
76+
target=target,
77+
)
78+
click.echo(f"Queued addition of {package}. Run `resolve` to apply changes.")
79+
80+
@cli.command(name="resolve")
81+
@click.option("--force", is_flag=True, help="Force resolution")
82+
@click.option("--dry-run", is_flag=True, help="Dry run (don't actually install)")
83+
@click.option("--update", is_flag=True, help="Update dependencies")
84+
def resolve_cli(force, dry_run, update):
85+
"""Resolve and install Julia dependencies."""
86+
resolve(force=force, dry_run=dry_run, update=update)
87+
click.echo("Resolved dependencies.")
88+
89+
@cli.command(name="remove")
90+
@click.argument("package")
91+
@click.option("--target", help="Target environment")
92+
def remove_cli(package, target):
93+
"""Remove a Julia package from the project."""
94+
rm(package, target=target)
95+
click.echo(f"Queued removal of {package}. Run `resolve` to apply changes.")
96+
97+
@cli.command(name="status")
98+
@click.option("--target", help="Target environment")
99+
def status_cli(target):
100+
"""Show the status of Julia packages in the project."""
101+
status(target=target)
102+
103+
@cli.command(name="update")
104+
@click.option("--dry-run", is_flag=True, help="Dry run (don't actually install)")
105+
def update_cli(dry_run):
106+
"""Update Julia packages in the project."""
107+
update(dry_run=dry_run)
108+
109+
@cli.command(name="run", context_settings=dict(ignore_unknown_options=True))
110+
@click.argument("args", nargs=-1)
111+
def run_cli(args):
112+
"""Pass-through to Julia CLI.
113+
114+
For example, use `run` to launch a REPL or `run script.jl` to run a script.
115+
"""
116+
resolve()
117+
executable = STATE["executable"]
118+
project = STATE["project"]
119+
120+
env = os.environ.copy()
121+
if sys.executable:
122+
# prefer PythonCall to use the current Python executable
123+
# TODO: this is a hack, it would be better for PythonCall to detect that
124+
# Julia is being called from Python
125+
env.setdefault("JULIA_PYTHONCALL_EXE", sys.executable)
126+
cmd = [
127+
executable,
128+
"--project=" + project,
129+
]
130+
for arg in args:
131+
if arg.startswith("--project"):
132+
raise ValueError("Do not specify --project when using pyjuliapkg.")
133+
cmd.append(arg)
134+
subprocess.run(
135+
cmd,
136+
check=True,
137+
env=env,
138+
)
139+
140+
141+
if __name__ == "__main__":
142+
cli()

test/test_cli.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import importlib
2+
import sys
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from juliapkg.cli import cli
8+
9+
10+
@pytest.fixture
11+
def runner():
12+
try:
13+
from click.testing import CliRunner
14+
15+
return CliRunner()
16+
except ImportError:
17+
pytest.skip("click is not available")
18+
19+
20+
class TestCLI:
21+
def test_cli_help(self, runner):
22+
result = runner.invoke(cli, ["--help"])
23+
assert result.exit_code == 0
24+
assert "JuliaPkg - Manage your Julia dependencies from Python." in result.output
25+
26+
def test_cli_no_args(self, runner):
27+
result = runner.invoke(cli, [])
28+
assert "Usage:" in result.output
29+
30+
def test_run_with_project(self, runner):
31+
result = runner.invoke(cli, ["run", "--project=/tmp/test"])
32+
assert result.exit_code != 0
33+
assert "Do not specify --project when using pyjuliapkg" in str(result.exception)
34+
35+
def test_run_command(self, runner):
36+
result = runner.invoke(cli, ["run", "-e", "using Pkg; Pkg.status()"])
37+
assert result.exit_code == 0
38+
39+
def test_basic_usage(self, runner):
40+
result = runner.invoke(
41+
cli, ["add", "Example", "--uuid", "7876af07-990d-54b4-ab0e-23690620f79a"]
42+
)
43+
assert result.exit_code == 0
44+
assert "Queued addition of Example" in result.output
45+
46+
result = runner.invoke(cli, ["resolve"])
47+
assert result.exit_code == 0
48+
49+
result = runner.invoke(cli, ["status"])
50+
assert result.exit_code == 0
51+
assert "Example" in result.output
52+
53+
result = runner.invoke(cli, ["remove", "Example"])
54+
assert result.exit_code == 0
55+
assert "Queued removal of Example" in result.output
56+
57+
result = runner.invoke(cli, ["resolve", "--force"])
58+
assert result.exit_code == 0
59+
60+
def test_click_not_available(self):
61+
with patch.dict(sys.modules, {"click": None, "juliapkg.cli": None}):
62+
del sys.modules["juliapkg.cli"]
63+
cli_module = importlib.import_module("juliapkg.cli")
64+
65+
with pytest.raises(ImportError) as exc_info:
66+
cli_module.cli()
67+
68+
assert "`click` is required to use the juliapkg CLI" in str(exc_info.value)

0 commit comments

Comments
 (0)