Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## Unreleased
* Add preferences support.

## v0.1.24 (2026-05-31)
* Add `libjulia()` function.

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ pip install juliapkg
- `require_julia(version, target=None)` declares that you require the given version of
Julia. The `version` is a Julia compat specifier, so `1.5` matches any `1.*.*` version at
least `1.5`.
- `add(pkg, uuid=None, dev=False, version=None, path=None, subdir=None, url=None, rev=None, target=None)`
- `add(pkg, uuid=None, dev=False, version=None, path=None, subdir=None, url=None, rev=None, preferences=None, target=None)`
adds a required package.
- `rm(pkg, target=None)` remove a package.
- `version` is a version compat specifier.
- `dev=True` installs the package in dev mode.
- `path`, `subdir`, `url` and `rev` specify the location of the package.
- `preferences` is a dict of [package preferences](https://github.com/JuliaPackaging/Preferences.jl).
- `rm(pkg, target=None)` removes a package.

Note that these functions edit `juliapkg.json` but do not actually install anything until
`resolve()` is called, which happens automatically in `executable()` and `project()`.
Expand All @@ -55,6 +59,8 @@ Julia v1.*.* and the Example package v0.5.*:
}
```

Each package may also specify an optional `"preferences"` object; see `add()` above.

### Command line interface

You can also use the CLI, some examples:
Expand Down
50 changes: 49 additions & 1 deletion src/juliapkg/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
# 5 - added hash_sha256 to deps_files for content verification
# 6 - added libjulia path to meta
# increment whenever the format changes
META_VERSION = 6
META_VERSION = 7


def load_meta():
Expand Down Expand Up @@ -80,6 +80,7 @@ def __init__(
subdir: Union[str, None] = None,
url: Union[str, None] = None,
rev: Union[str, None] = None,
preferences: Union[dict, None] = None,
):
# Validate name: type then value
if not isinstance(name, str):
Expand Down Expand Up @@ -161,6 +162,14 @@ def __init__(
)
self.rev = rev

# Validate preferences (dict or None)
if preferences is not None and not isinstance(preferences, dict):
raise TypeError(
f"package preferences must be a 'dict' or 'None', got "
f"'{type(preferences).__name__}'"
)
self.preferences = preferences

def jlstr(self):
args = ['name="{}"'.format(self.name), 'uuid="{}"'.format(self.uuid)]
if self.path is not None:
Expand All @@ -183,6 +192,7 @@ def dict(self):
"subdir": self.subdir,
"url": self.url,
"rev": self.rev,
"preferences": self.preferences,
}
return {k: v for (k, v) in ans.items() if v is not None}

Expand All @@ -201,6 +211,8 @@ def depsdict(self):
ans["url"] = self.url
if self.rev is not None:
ans["rev"] = self.rev
if self.preferences is not None:
ans["preferences"] = self.preferences
return ans


Expand Down Expand Up @@ -405,6 +417,34 @@ def merge_any(dep, kfvs, k):
if fvs is not None:
dep[k] = any(fvs.values())

# merges preferences dicts: union of keys across files; the same key
# given different values in different files is an error
def merge_preferences(dep, kfvs, k):
fvs = kfvs.pop(k, None)
if fvs is not None:
# key -> file -> value
keyfvs = {}
for f, v in fvs.items():
for pk, pv in v.items():
keyfvs.setdefault(pk, {})[f] = pv
prefs = {}
for pk, pfvs in keyfvs.items():
vs = list(pfvs.values())
if all(v == vs[0] for v in vs):
prefs[pk] = vs[0]
else:
raise Exception(
"'{}' entries for key '{}' are not unique:\n{}".format(
k,
pk,
"\n".join(
["- {!r} at {}".format(v, f) for (f, v) in pfvs.items()]
),
)
)
if prefs:
dep[k] = prefs

# merge dependencies: name -> key -> value
deps = []
for name, kfvs in all_deps.items():
Expand All @@ -416,6 +456,7 @@ def merge_any(dep, kfvs, k):
merge_unique(kw, kfvs, "rev")
merge_compat(kw, kfvs, "version")
merge_any(kw, kfvs, "dev")
merge_preferences(kw, kfvs, "preferences")
deps.append(PkgSpec(**kw))
# julia compat
compat = None
Expand Down Expand Up @@ -537,6 +578,13 @@ def resolve(force=False, dry_run=False, update=False):
projcompat[pkg.name] = pkg.version
else:
projcompat.pop(pkg.name, None)
# add/update the preferences table
projprefs = projtoml.setdefault("preferences", tomlkit.table())
for pkg in pkgs:
if pkg.preferences:
projprefs[pkg.name] = pkg.preferences
else:
projprefs.pop(pkg.name, None)
# write it out
projtomlstr = tomlkit.dumps(projtoml)
log_script(
Expand Down
39 changes: 39 additions & 0 deletions test/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
import tempfile
from multiprocessing import Pool

try:
import tomllib
except ImportError:
import tomli as tomllib

import juliapkg


Expand Down Expand Up @@ -48,6 +53,40 @@ def test_resolve_contention():
Pool(5).map(resolve_in_tempdir, [tempdir] * 5)


def test_resolve_preferences():
with tempfile.TemporaryDirectory() as tempdir:
# the default deps file for a project is <project>/pyjuliapkg/juliapkg.json
depsdir = os.path.join(tempdir, "pyjuliapkg")
os.makedirs(depsdir)
with open(os.path.join(depsdir, "juliapkg.json"), "w") as f:
f.write("""
{
"julia": "1",
"packages": {
"Example": {
"uuid": "7876af07-990d-54b4-ab0e-23690620f79a",
"version": "0.5",
"preferences": {
"use_jl_def": true,
"greeting": "hello"
}
}
}
}
""")
subprocess.run(
["python", "-c", "import juliapkg; juliapkg.resolve()"],
env=dict(os.environ, PYTHON_JULIAPKG_PROJECT=tempdir),
check=True,
)
with open(os.path.join(tempdir, "Project.toml"), "rb") as f:
proj = tomllib.load(f)
assert proj["preferences"]["Example"] == {
"use_jl_def": True,
"greeting": "hello",
}


def test_status():
assert juliapkg.status() is None

Expand Down
108 changes: 108 additions & 0 deletions test/test_internals.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import json
import os

import pytest

import juliapkg
Expand Down Expand Up @@ -104,3 +107,108 @@ def test_pkgspec_validation():
# Test invalid rev type
with pytest.raises(TypeError, match="package rev must be a 'str' or 'None'"):
PkgSpec(name="Example", uuid=spec.uuid, rev=123)

# Test invalid preferences type
with pytest.raises(
TypeError, match="package preferences must be a 'dict' or 'None', got 'int'"
):
PkgSpec(name="Example", uuid=spec.uuid, preferences=123)


def test_pkgspec_preferences():
uuid = "123e4567-e89b-12d3-a456-426614174000"

# Preferences are absent by default
spec = PkgSpec(name="Example", uuid=uuid)
assert spec.preferences is None
assert "preferences" not in spec.dict()
assert "preferences" not in spec.depsdict()

# Preferences round-trip through dict() and depsdict()
prefs = {"precompile_float64": False, "mode": "fast", "levels": [1, 2]}
spec = PkgSpec(name="Example", uuid=uuid, preferences=prefs)
assert spec.preferences == prefs
assert spec.dict()["preferences"] == prefs
assert spec.depsdict()["preferences"] == prefs

# An empty preferences dict is kept (explicitly provided)
spec = PkgSpec(name="Example", uuid=uuid, preferences={})
assert spec.dict()["preferences"] == {}
assert spec.depsdict()["preferences"] == {}


def _write_juliapkg_json(dirpath, packages):
fn = os.path.join(dirpath, "juliapkg.json")
with open(fn, "w") as fp:
json.dump({"packages": packages}, fp)
return fn


def test_find_requirements_preferences(monkeypatch, tmp_path):
uuid = "123e4567-e89b-12d3-a456-426614174000"
dir1 = tmp_path / "a"
dir2 = tmp_path / "b"
dir1.mkdir()
dir2.mkdir()
_write_juliapkg_json(
dir1,
{
"Example": {
"uuid": uuid,
"preferences": {"use_jl_def": True, "mode": "fast"},
}
},
)
_write_juliapkg_json(
dir2,
{
"Example": {
"uuid": uuid,
"preferences": {"mode": "fast", "extra": [1, 2]},
}
},
)
monkeypatch.setattr(
juliapkg.deps,
"deps_files",
lambda: [
os.path.join(str(dir1), "juliapkg.json"),
os.path.join(str(dir2), "juliapkg.json"),
],
)
compat, pkgs = juliapkg.deps.find_requirements()
assert compat is None
assert len(pkgs) == 1
assert pkgs[0].name == "Example"
# union of preference keys across files
assert pkgs[0].preferences == {
"use_jl_def": True,
"mode": "fast",
"extra": [1, 2],
}


def test_find_requirements_preferences_conflict(monkeypatch, tmp_path):
uuid = "123e4567-e89b-12d3-a456-426614174000"
dir1 = tmp_path / "a"
dir2 = tmp_path / "b"
dir1.mkdir()
dir2.mkdir()
_write_juliapkg_json(
dir1,
{"Example": {"uuid": uuid, "preferences": {"mode": "fast"}}},
)
_write_juliapkg_json(
dir2,
{"Example": {"uuid": uuid, "preferences": {"mode": "slow"}}},
)
monkeypatch.setattr(
juliapkg.deps,
"deps_files",
lambda: [
os.path.join(str(dir1), "juliapkg.json"),
os.path.join(str(dir2), "juliapkg.json"),
],
)
with pytest.raises(Exception, match="'preferences' entries for key 'mode'"):
juliapkg.deps.find_requirements()