diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af8b87..491601f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 340e45b..6b502cd 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ 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. ## Details @@ -95,6 +96,7 @@ option to `python`. The `-X` option has higher precedence. | `PYTHON_JULIAPKG_EXE=` | `-X juliapkg-exe=` | The Julia executable to use. | | `PYTHON_JULIAPKG_PROJECT=` | `-X juliapkg-project=` | The Julia project where packages are installed. | | `PYTHON_JULIAPKG_OFFLINE=` | `-X juliapkg-offline=` | Work in Offline Mode - does not install Julia or any packages. | +| `PYTHON_JULIAPKG_PINS=` | `-X juliapkg-pins=` | 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? @@ -136,6 +138,19 @@ package, then JuliaPkg will find those dependencies and install them. You can use `add`, `rm` etc. above with `target='/path/to/your/package'` to modify the dependencies of your package. +### 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. + +Packages tracked by `path`, `url` or `rev` are not pinned (note a branch `rev` is not +reproducible). Pinning requires Julia 1.4+. + ### Offline mode If you set the environment variable `PYTHON_JULIAPKG_OFFLINE=yes` (or call `python` with the diff --git a/src/juliapkg/__init__.py b/src/juliapkg/__init__.py index a6dcee2..e5b8179 100644 --- a/src/juliapkg/__init__.py +++ b/src/juliapkg/__init__.py @@ -2,6 +2,7 @@ PkgSpec, add, executable, + freeze, libjulia, offline, project, @@ -15,6 +16,7 @@ __all__ = [ "status", "resolve", + "freeze", "executable", "libjulia", "project", diff --git a/src/juliapkg/cli.py b/src/juliapkg/cli.py index 4d37443..8098beb 100644 --- a/src/juliapkg/cli.py +++ b/src/juliapkg/cli.py @@ -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 @@ -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") diff --git a/src/juliapkg/deps.py b/src/juliapkg/deps.py index 857587f..b65f4df 100644 --- a/src/juliapkg/deps.py +++ b/src/juliapkg/deps.py @@ -18,6 +18,8 @@ logger = logging.getLogger("juliapkg") +PINNED_FILE_NAME = "juliapkg.pinned.json" + ### META # Meta format version history: @@ -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(): @@ -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: @@ -321,6 +328,83 @@ 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, strict=False): + """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). + strict (bool): Raise on invalid or conflicting pins instead of warning. + + 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} + compats = {p.name: Compat.parse(str(p.version)) for p in pkgs if p.version} + 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 + try: + PkgSpec(name=name, uuid=info["uuid"], version=info["version"]) + version = Version.parse(info["version"]) + except (KeyError, TypeError, ValueError) as err: + msg = f"invalid pin for {name} at {fn}: {err}" + if strict: + raise Exception(msg) from err + log(f"WARNING: ignoring {msg}") + continue + if name in compats and version not in compats[name]: + msg = ( + f"pin {name} = {info['version']} at {fn} conflicts with" + f" the required compat {compats[name]}" + ) + if strict: + raise Exception(msg) + log(f"WARNING: ignoring {msg}") + continue + if name in pins: + prev = pins[name] + if (prev["uuid"], prev["version"]) != (info["uuid"], info["version"]): + msg = ( + f"conflicting pins for {name}:" + f" {prev['version']} ({prev['uuid']}) at {prev['file']}," + f" {info['version']} ({info['uuid']}) at {fn}" + ) + if strict: + raise Exception(msg) + log(f"WARNING: {msg}; keeping the first") + continue + pins[name] = { + "uuid": info["uuid"], + "version": info["version"], + "file": fn, + } + return pins + + def openssl_compat(version=None): if version is None: import ssl @@ -476,6 +560,73 @@ def merge_preferences(dep, kfvs, k): return compat, deps +def _install_script(dev_pkgs, add_pkgs, pins, strict, update): + 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("]") + # snapshot the direct dependencies so that only packages added by the + # seeding below get removed again (shared projects may have others) + script.append("predeps = Set(keys(Pkg.project().dependencies))") + script.append("Pkg.add(pins)") + 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, predeps)" + ) + 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. @@ -604,22 +755,19 @@ 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, strict=STATE["pins"] == "strict") + if pins and ver < Version.parse("1.4.0"): + # the generated pins code uses Pkg.project()/Pkg.dependencies() + log("WARNING: version pins require Julia 1.4+, ignoring pins") + pins = {} + 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 @@ -636,11 +784,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 @@ -868,6 +1017,77 @@ 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. + """ + deps_fn = cur_deps_file(target=target) + if not os.path.isfile(deps_fn): + raise Exception( + f"no dependencies file at {deps_fn}: pinned files are only used when" + " next to a juliapkg.json, so add dependencies first or pass a" + " different target" + ) + resolve() + project = STATE["project"] + # version-specific manifests take precedence when they exist + ver = STATE["version"] + names = [ + f"JuliaManifest-v{ver.major}.{ver.minor}.toml", + f"Manifest-v{ver.major}.{ver.minor}.toml", + "JuliaManifest.toml", + "Manifest.toml", + ] + for fn in names: + 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(deps_fn), 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 diff --git a/src/juliapkg/state.py b/src/juliapkg/state.py index 4021e3d..1b6f7eb 100644 --- a/src/juliapkg/state.py +++ b/src/juliapkg/state.py @@ -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 diff --git a/test/test_all.py b/test/test_all.py index 0cf7189..292605b 100644 --- a/test/test_all.py +++ b/test/test_all.py @@ -87,6 +87,136 @@ def test_resolve_preferences(): } +EXAMPLE_UUID = "7876af07-990d-54b4-ab0e-23690620f79a" +CRAYONS_UUID = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" + + +def _write_pins_project(tempdir, pinned_version): + depsdir = os.path.join(tempdir, "pyjuliapkg") + os.makedirs(depsdir) + with open(os.path.join(depsdir, "juliapkg.json"), "w") as f: + json.dump( + { + "julia": "1", + "packages": { + "Example": {"uuid": EXAMPLE_UUID, "version": "0.5"}, + }, + }, + f, + ) + with open(os.path.join(depsdir, "juliapkg.pinned.json"), "w") as f: + json.dump( + { + "packages": { + "Example": {"uuid": EXAMPLE_UUID, "version": pinned_version}, + "Crayons": {"uuid": CRAYONS_UUID, "version": "4.1.1"}, + } + }, + f, + ) + + +def _manifest(tempdir): + with open(os.path.join(tempdir, "Manifest.toml"), "rb") as f: + manifest = tomllib.load(f) + return manifest.get("deps", manifest) + + +def test_resolve_pinned(): + with tempfile.TemporaryDirectory() as tempdir: + # Example is pinned to 0.5.4, which is not the latest 0.5.x + _write_pins_project(tempdir, "0.5.4") + subprocess.run( + ["python", "-c", "import juliapkg; juliapkg.resolve()"], + env=dict(os.environ, PYTHON_JULIAPKG_PROJECT=tempdir), + check=True, + ) + deps = _manifest(tempdir) + assert deps["Example"][0]["version"] == "0.5.4" + # pin-only packages are pruned again + assert "Crayons" not in deps + with open(os.path.join(tempdir, "Project.toml"), "rb") as f: + proj = tomllib.load(f) + assert "Crayons" not in proj["deps"] + + +def test_resolve_pinned_shared_preserves_user_deps(): + with tempfile.TemporaryDirectory() as tempdir: + # a pre-existing user project with Crayons as a direct dependency, which + # is also pinned; the pin cleanup must not remove it + with open(os.path.join(tempdir, "Project.toml"), "w") as f: + f.write(f'[deps]\nCrayons = "{CRAYONS_UUID}"\n') + _write_pins_project(tempdir, "0.5.4") + 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 "Crayons" in proj["deps"] + deps = _manifest(tempdir) + assert deps["Crayons"][0]["version"] == "4.1.1" + assert deps["Example"][0]["version"] == "0.5.4" + + +def test_resolve_pinned_relaxes_on_conflict(): + with tempfile.TemporaryDirectory() as tempdir: + # the pin is incompatible with the required compat "0.5", so it gets + # dropped with a warning and resolution proceeds + _write_pins_project(tempdir, "0.4.1") + subprocess.run( + ["python", "-c", "import juliapkg; juliapkg.resolve()"], + env=dict(os.environ, PYTHON_JULIAPKG_PROJECT=tempdir), + check=True, + ) + deps = _manifest(tempdir) + assert deps["Example"][0]["version"].startswith("0.5.") + + +def test_resolve_pinned_strict_errors_on_conflict(): + with tempfile.TemporaryDirectory() as tempdir: + _write_pins_project(tempdir, "0.4.1") + proc = subprocess.run( + ["python", "-c", "import juliapkg; juliapkg.resolve()"], + env=dict( + os.environ, + PYTHON_JULIAPKG_PROJECT=tempdir, + PYTHON_JULIAPKG_PINS="strict", + ), + ) + assert proc.returncode != 0 + + +def test_freeze(): + with tempfile.TemporaryDirectory() as tempdir: + depsdir = os.path.join(tempdir, "pyjuliapkg") + os.makedirs(depsdir) + with open(os.path.join(depsdir, "juliapkg.json"), "w") as f: + json.dump( + { + "julia": "1", + "packages": { + "Example": {"uuid": EXAMPLE_UUID, "version": "0.5"}, + }, + }, + f, + ) + subprocess.run( + [ + "python", + "-c", + "import juliapkg; print(juliapkg.freeze())", + ], + env=dict(os.environ, PYTHON_JULIAPKG_PROJECT=tempdir), + check=True, + ) + with open(os.path.join(depsdir, "juliapkg.pinned.json")) as f: + pins = json.load(f) + assert pins["packages"]["Example"]["uuid"] == EXAMPLE_UUID + assert pins["packages"]["Example"]["version"].startswith("0.5.") + + def test_status(): assert juliapkg.status() is None diff --git a/test/test_pins.py b/test/test_pins.py new file mode 100644 index 0000000..6dc9b00 --- /dev/null +++ b/test/test_pins.py @@ -0,0 +1,212 @@ +import json + +import pytest + +from juliapkg.deps import ( + PkgSpec, + _install_script, + _pins_from_manifest, + find_pins, +) + +EXAMPLE_UUID = "7876af07-990d-54b4-ab0e-23690620f79a" +CRAYONS_UUID = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" + + +def test_pins_from_manifest(): + manifest = { + "julia_version": "1.11.0", + "manifest_format": "2.0", + "project_hash": "abc", + "deps": { + "Example": [ + { + "uuid": EXAMPLE_UUID, + "version": "0.5.4", + "git-tree-sha1": "11820aa9c229fd3833d4bd69e5e75ef4e7273bf1", + } + ], + # stdlib: no git-tree-sha1, not pinnable + "LinearAlgebra": [ + {"uuid": "37e2e46d-f89d-539d-b4ee-838fcccc9c8e", "version": "1.11.0"} + ], + # dev/path package: pinned by its source already + "MyLocalPkg": [ + { + "uuid": "123e4567-e89b-12d3-a456-426614174000", + "version": "0.1.0", + "git-tree-sha1": "0000000000000000000000000000000000000000", + "path": "/some/where", + } + ], + # two packages with the same name: ambiguous, skipped + "Dup": [ + {"uuid": "123e4567-e89b-12d3-a456-426614174001", "version": "1.0.0"}, + {"uuid": "123e4567-e89b-12d3-a456-426614174002", "version": "2.0.0"}, + ], + }, + } + pins = _pins_from_manifest(manifest) + assert pins == {"Example": {"uuid": EXAMPLE_UUID, "version": "0.5.4"}} + + +def test_pins_from_manifest_format_1(): + manifest = { + "Example": [ + { + "uuid": EXAMPLE_UUID, + "version": "0.5.4", + "git-tree-sha1": "11820aa9c229fd3833d4bd69e5e75ef4e7273bf1", + } + ], + } + pins = _pins_from_manifest(manifest) + assert pins == {"Example": {"uuid": EXAMPLE_UUID, "version": "0.5.4"}} + + +def test_find_pins(tmp_path): + fn1 = tmp_path / "a" / "juliapkg.pinned.json" + fn2 = tmp_path / "b" / "juliapkg.pinned.json" + fn1.parent.mkdir() + fn2.parent.mkdir() + fn1.write_text( + json.dumps( + { + "packages": { + "Example": {"uuid": EXAMPLE_UUID, "version": "0.5.4"}, + "DevPkg": { + "uuid": "123e4567-e89b-12d3-a456-426614174000", + "version": "0.1.0", + }, + } + } + ) + ) + fn2.write_text( + json.dumps( + { + "packages": { + "Example": {"uuid": EXAMPLE_UUID, "version": "0.5.5"}, + "Crayons": {"uuid": CRAYONS_UUID, "version": "4.1.1"}, + } + } + ) + ) + pkgs = [ + PkgSpec( + name="DevPkg", + uuid="123e4567-e89b-12d3-a456-426614174000", + path="/some/where", + ) + ] + pins = find_pins(pkgs, files=[str(fn2), str(fn1)]) + # files are processed in sorted order, so fn1 wins the Example conflict + assert pins["Example"]["version"] == "0.5.4" + assert pins["Crayons"]["version"] == "4.1.1" + # packages with a fixed source are not pinnable + assert "DevPkg" not in pins + # in strict mode the conflict is an error + with pytest.raises(Exception, match="conflicting pins for Example"): + find_pins([], files=[str(fn2), str(fn1)], strict=True) + + +def test_find_pins_uuid_conflict(tmp_path): + fn1 = tmp_path / "a.json" + fn2 = tmp_path / "b.json" + other_uuid = "123e4567-e89b-12d3-a456-426614174000" + fn1.write_text( + json.dumps( + {"packages": {"Example": {"uuid": EXAMPLE_UUID, "version": "0.5.4"}}} + ) + ) + fn2.write_text( + json.dumps({"packages": {"Example": {"uuid": other_uuid, "version": "0.5.4"}}}) + ) + # same version but different uuid is still a conflict + with pytest.raises(Exception, match="conflicting pins for Example"): + find_pins([], files=[str(fn1), str(fn2)], strict=True) + pins = find_pins([], files=[str(fn1), str(fn2)]) + assert pins["Example"]["uuid"] == EXAMPLE_UUID + + +def test_find_pins_invalid_entry(tmp_path): + fn = tmp_path / "a.json" + fn.write_text( + json.dumps( + { + "packages": { + "Example": {"uuid": EXAMPLE_UUID, "version": "0.5.4"}, + "BadUuid": {"uuid": "nope", "version": "1.0.0"}, + "BadVersion": {"uuid": EXAMPLE_UUID, "version": "latest"}, + "Missing": {"uuid": EXAMPLE_UUID}, + } + } + ) + ) + # invalid entries are skipped in prefer mode + pins = find_pins([], files=[str(fn)]) + assert set(pins) == {"Example"} + # and are an error in strict mode + with pytest.raises(Exception, match="invalid pin"): + find_pins([], files=[str(fn)], strict=True) + + +def test_find_pins_compat_conflict(tmp_path): + fn = tmp_path / "a.json" + fn.write_text( + json.dumps( + {"packages": {"Example": {"uuid": EXAMPLE_UUID, "version": "0.4.1"}}} + ) + ) + pkgs = [PkgSpec(name="Example", uuid=EXAMPLE_UUID, version="0.5")] + # a pin conflicting with a required compat is skipped in prefer mode + assert find_pins(pkgs, files=[str(fn)]) == {} + with pytest.raises(Exception, match="conflicts with the required compat"): + find_pins(pkgs, files=[str(fn)], strict=True) + + +def test_install_script_no_pins(): + spec = PkgSpec(name="Example", uuid=EXAMPLE_UUID) + script = _install_script([], [spec], {}, False, False) + assert script == [ + "import Pkg", + "Pkg.Registry.update()", + "Pkg.add([", + f' Pkg.PackageSpec(name="Example", uuid="{EXAMPLE_UUID}"),', + "])", + "Pkg.resolve()", + "Pkg.precompile()", + ] + + +def test_install_script_pins(): + spec = PkgSpec(name="Example", uuid=EXAMPLE_UUID) + pins = { + "Example": {"uuid": EXAMPLE_UUID, "version": "0.5.4", "file": "x"}, + "Crayons": {"uuid": CRAYONS_UUID, "version": "4.1.1", "file": "x"}, + } + script = _install_script([], [spec], pins, False, False) + text = "\n".join(script) + # pins are seeded before the real packages are added + assert text.index("pins = Pkg.PackageSpec[") < text.index("Pkg.add([\n") + assert ( + f'Pkg.PackageSpec(name="Example", uuid="{EXAMPLE_UUID}", version="0.5.4")' + in text + ) + # only packages added by the seeding are removed from the project again + assert text.index("predeps = ") < text.index("Pkg.add(pins)") + assert 'keep = String["Example"]' in text + assert "predeps)" in text + assert "Pkg.rm(rmnames)" in text + # relaxed pins produce a warning, not an error + assert text.count("@warn") == 1 + assert "error(" not in text + + +def test_install_script_pins_strict(): + spec = PkgSpec(name="Example", uuid=EXAMPLE_UUID) + pins = {"Example": {"uuid": EXAMPLE_UUID, "version": "0.5.4", "file": "x"}} + script = _install_script([], [spec], pins, True, False) + text = "\n".join(script) + assert "error(" in text + assert "@warn" not in text