Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased
* Add version pinning: `freeze()` records resolved versions to `juliapkg.pinned.json`,
which are preferred on subsequent resolves wherever compatible.

## v0.1.25 (2026-08-07)
* Add preferences support.

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ python -m juliapkg remove Example
- `juliapkg.resolve(force=False, dry_run=False)` ensures all the dependencies are installed. You don't
normally need to do this because the other functions resolve automatically.
- `juliapkg.update(dry_run=False)` updates the dependencies.
- `juliapkg.freeze(target=None)` pins the currently resolved package versions.

## Pinning versions

`freeze(target)` (or `python -m juliapkg freeze --target=...`) records the exact version
of every resolved package into a `juliapkg.pinned.json` file next to the `juliapkg.json`
given by `target`. On subsequent resolves these versions are preferred wherever they are
compatible with all requirements; conflicting pins are relaxed with a warning (see the
`pins` option in Configuration to error instead, or to ignore pins). Ship this file with
your Python package and your users get exactly the dependency versions you tested
against. To upgrade: `update()` (which ignores pins), test, then `freeze()` again.
Comment thread
MilesCranmerBot marked this conversation as resolved.
Outdated

## Details

Expand All @@ -95,6 +106,7 @@ option to `python`. The `-X` option has higher precedence.
| `PYTHON_JULIAPKG_EXE=<exe>` | `-X juliapkg-exe=<exe>` | The Julia executable to use. |
| `PYTHON_JULIAPKG_PROJECT=<project>` | `-X juliapkg-project=<project>` | The Julia project where packages are installed. |
| `PYTHON_JULIAPKG_OFFLINE=<yes/no>` | `-X juliapkg-offline=<yes/no>` | Work in Offline Mode - does not install Julia or any packages. |
| `PYTHON_JULIAPKG_PINS=<prefer/strict/ignore>` | `-X juliapkg-pins=<prefer/strict/ignore>` | How to treat pinned versions from `juliapkg.pinned.json` files: prefer them where compatible (default), error if any cannot be honoured, or ignore them. |

### Which Julia gets used?

Expand Down
2 changes: 2 additions & 0 deletions src/juliapkg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
PkgSpec,
add,
executable,
freeze,
libjulia,
offline,
project,
Expand All @@ -15,6 +16,7 @@
__all__ = [
"status",
"resolve",
"freeze",
"executable",
"libjulia",
"project",
Expand Down
14 changes: 13 additions & 1 deletion src/juliapkg/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import subprocess
import sys

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

try:
import click
Expand Down Expand Up @@ -86,6 +86,18 @@ def resolve_cli(force, dry_run, update):
resolve(force=force, dry_run=dry_run, update=update)
click.echo("Resolved dependencies.")

@cli.command(name="freeze")
@click.option("--target", help="Target environment")
def freeze_cli(target):
"""Pin the currently resolved package versions.

Writes the exact version of every resolved package to a
juliapkg.pinned.json file next to the target juliapkg.json. These
versions are preferred on subsequent resolves wherever compatible.
"""
fn = freeze(target=target)
click.echo(f"Wrote {fn}")

@cli.command(name="remove")
@click.argument("package")
@click.option("--target", help="Target environment")
Expand Down
216 changes: 198 additions & 18 deletions src/juliapkg/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

logger = logging.getLogger("juliapkg")

PINNED_FILE_NAME = "juliapkg.pinned.json"

### META

# Meta format version history:
Expand All @@ -27,8 +29,9 @@
# 4 - changed from timestamp/sys_path to deps_files tracking
# 5 - added hash_sha256 to deps_files for content verification
# 6 - added libjulia path to meta
# 7 - added pins mode and pinned files tracking
# increment whenever the format changes
META_VERSION = 7
META_VERSION = 8


def load_meta():
Expand Down Expand Up @@ -252,8 +255,12 @@ def can_skip_resolve():
if isdev != STATE["dev"]:
logger.debug("changed dev %s to %s", isdev, STATE["dev"])
return False
# resolve whenever the pins mode changes
if deps.get("pins") != STATE["pins"]:
logger.debug("changed pins %s to %s", deps.get("pins"), STATE["pins"])
return False
# resolve whenever any deps files change
files0 = set(deps_files())
files0 = set(tracked_files())
files = deps["deps_files"]
filesdiff = set(files.keys()).difference(files0)
if filesdiff:
Expand Down Expand Up @@ -321,6 +328,59 @@ def deps_files():
)


def pinned_files():
return sorted(
set(
fn
for fn in (
os.path.join(os.path.dirname(f), PINNED_FILE_NAME) for f in deps_files()
)
if os.path.isfile(fn)
)
)


def tracked_files():
return sorted(set(deps_files()) | set(pinned_files()))


def find_pins(pkgs, files=None):
"""Find pinned versions from juliapkg.pinned.json files.

Args:
pkgs (list): The required PkgSpecs, used to exclude packages whose source
is fixed some other way (dev, path, url, rev).
files (list): The pinned files to read (defaults to all discovered ones).

Returns:
dict: name -> {"uuid": str, "version": str, "file": str}.
"""
if files is None:
files = pinned_files()
unpinnable = {p.name for p in pkgs if p.dev or p.path or p.url or p.rev}
pins = {}
for fn in sorted(files):
with open(fn) as fp:
data = json.load(fp)
for name, info in sorted(data.get("packages", {}).items()):
if name in unpinnable:
continue
if name in pins:
if pins[name]["version"] != info["version"]:
log(
f"WARNING: conflicting pins for {name}:"
f" keeping {pins[name]['version']} from {pins[name]['file']},"
f" ignoring {info['version']} from {fn}"
)
continue
pins[name] = {
"uuid": info["uuid"],
"version": info["version"],
"file": fn,
}
return pins


def openssl_compat(version=None):
if version is None:
import ssl
Expand Down Expand Up @@ -476,6 +536,76 @@ def merge_preferences(dep, kfvs, k):
return compat, deps


def _install_script(dev_pkgs, add_pkgs, pins, strict, update):
Comment thread
MilesCranmerBot marked this conversation as resolved.
script = ["import Pkg", "Pkg.Registry.update()"]
if pins:
# seed the environment with the pinned versions, so that the subsequent
# Pkg.add preserves them wherever they are compatible
script.append("pins = Pkg.PackageSpec[")
for name in sorted(pins):
info = pins[name]
script.append(
f' Pkg.PackageSpec(name="{name}", uuid="{info["uuid"]}",'
f' version="{info["version"]}"),'
)
script.append("]")
if strict:
script.append("Pkg.add(pins)")
else:
script.append(
'try Pkg.add(pins); catch err; @warn "JuliaPkg: could not install'
' pinned versions, resolving without pins" err; end'
)
if dev_pkgs:
script.append("Pkg.develop([")
for pkg in dev_pkgs:
script.append(f" {pkg.jlstr()},")
script.append("])")
if add_pkgs:
script.append("Pkg.add([")
for pkg in add_pkgs:
script.append(f" {pkg.jlstr()},")
script.append("])")
if pins:
# pins are not direct dependencies: remove them from the project again,
# which also prunes any that are not needed
required = sorted(
{pkg.name for pkg in dev_pkgs} | {pkg.name for pkg in add_pkgs}
)
script.append(
"keep = String[" + ", ".join(f'"{name}"' for name in required) + "]"
)
script.append(
"rmnames = setdiff!(intersect!([p.name for p in pins],"
" keys(Pkg.project().dependencies)), keep)"
)
script.append("isempty(rmnames) || Pkg.rm(rmnames)")
# report any pins that did not survive resolution
script.append(
"vers = Dict(d.name => string(d.version)"
" for d in values(Pkg.dependencies()) if d.version !== nothing)"
)
script.append(
'relaxed = sort!([string(p.name, ": pinned ", p.version, ", resolved ",'
" vers[p.name]) for p in pins"
" if get(vers, p.name, string(p.version)) != string(p.version)])"
)
msg = (
'string("JuliaPkg: pinned versions were relaxed to satisfy'
' compatibility:\\n ", join(relaxed, "\\n "))'
)
if strict:
script.append(f"isempty(relaxed) || error({msg})")
else:
script.append(f"isempty(relaxed) || @warn {msg}")
if update:
script.append("Pkg.update()")
else:
script.append("Pkg.resolve()")
script.append("Pkg.precompile()")
return script


def resolve(force=False, dry_run=False, update=False):
"""
Resolve the dependencies.
Expand Down Expand Up @@ -604,22 +734,15 @@ def resolve(force=False, dry_run=False, update=False):
# install the packages
dev_pkgs = [pkg for pkg in pkgs if pkg.dev]
add_pkgs = [pkg for pkg in pkgs if not pkg.dev]
script = ["import Pkg", "Pkg.Registry.update()"]
if dev_pkgs:
script.append("Pkg.develop([")
for pkg in dev_pkgs:
script.append(f" {pkg.jlstr()},")
script.append("])")
if add_pkgs:
script.append("Pkg.add([")
for pkg in add_pkgs:
script.append(f" {pkg.jlstr()},")
script.append("])")
if update:
script.append("Pkg.update()")
# find pinned versions (updating ignores pins so they can be refreshed
# with freeze() afterwards)
if update or STATE["pins"] == "ignore":
pins = {}
else:
script.append("Pkg.resolve()")
script.append("Pkg.precompile()")
pins = find_pins(pkgs)
script = _install_script(
dev_pkgs, add_pkgs, pins, STATE["pins"] == "strict", update
)
log_script(script, "Installing packages:")
run_julia(script, executable=exe, project=project)
# record that we resolved
Expand All @@ -636,11 +759,12 @@ def resolve(force=False, dry_run=False, update=False):
"timestamp": os.path.getmtime(filename),
"hash_sha256": _get_hash(filename),
}
for filename in deps_files()
for filename in tracked_files()
},
"pkgs": [pkg.dict() for pkg in pkgs],
"offline": bool(STATE["offline"]),
"override_executable": STATE["override_executable"],
"pins": STATE["pins"],
}
)
STATE["resolved"] = True
Expand Down Expand Up @@ -868,6 +992,62 @@ def _rm(deps, pkg):
_rm(deps, p)


def _pins_from_manifest(manifest):
deps = manifest.get("deps")
if deps is None:
# manifest format 1 has the packages at the top level
deps = {k: v for (k, v) in manifest.items() if isinstance(v, list)}
pins = {}
for name, entries in deps.items():
if len(entries) != 1:
log(f"WARNING: not pinning {name}: multiple packages with this name")
continue
entry = entries[0]
if "path" in entry or "repo-url" in entry:
# dev/path/url packages are already pinned by their source
continue
if "git-tree-sha1" not in entry or "version" not in entry:
# stdlibs cannot be pinned
continue
pins[name] = {"uuid": str(entry["uuid"]), "version": str(entry["version"])}
return pins


def freeze(target=None):
"""
Pin the currently resolved package versions.

Resolves the dependencies, then records the exact version of every package in
the resolved manifest into a juliapkg.pinned.json file next to the juliapkg.json
file given by target. On subsequent resolves, these versions are preferred
wherever they are compatible with all requirements.

Args:
target (str): Where to write the pins, as for add(). Typically the
directory of the package whose dependencies you are pinning.

Returns:
str: The path of the written file.
"""
resolve()
project = STATE["project"]
for fn in ["JuliaManifest.toml", "Manifest.toml"]:
manifest_path = os.path.join(project, fn)
if os.path.isfile(manifest_path):
break
else:
raise Exception(f"no manifest found at {project}, cannot freeze")
with open(manifest_path) as fp:
manifest = tomlkit.load(fp)
pins = _pins_from_manifest(manifest)
fn = os.path.join(os.path.dirname(cur_deps_file(target=target)), PINNED_FILE_NAME)
with open(fn, "w") as fp:
json.dump({"packages": pins}, fp, indent=2, sort_keys=True)
fp.write("\n")
STATE["resolved"] = False
return fn


def offline(value=True):
if value is not None:
STATE["offline"] = value
Expand Down
6 changes: 6 additions & 0 deletions src/juliapkg/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ def reset_state():
# offline
STATE["offline"], _ = get_config_bool("offline")

# pins: how to treat pinned versions from juliapkg.pinned.json files
# - prefer: use pinned versions wherever compatible, relax with a warning
# - strict: error instead of relaxing
# - ignore: do not use pins at all
STATE["pins"], _ = get_config_opts("pins", ("prefer", "strict", "ignore"), "prefer")

# resolution
STATE["resolved"] = False

Expand Down
Loading