diff --git a/CLAUDE.md b/CLAUDE.md index b95392b..2641785 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,12 +169,14 @@ The project provides type-safe builder classes for different environment types: - Supports `pixi.toml` and `environment.yml` files - Uses `pixi run --manifest-path /pixi.toml` for activation - Environment structure: `/.pixi/envs/default` +- Lock files: `.lockFile()/.lockContent()/.lockUrl()` for reproducible builds (copies `pixi.lock` into envDir, installs with `pixi install --locked`) - Location: `org.apposed.appose.builder.PixiBuilder` **MambaBuilder** - Traditional conda environments via micromamba - Created via `Appose.mamba()` or `Appose.mamba(source)` - Supports `environment.yml` files - Uses `mamba run -p ` for activation +- Does not yet support lock files - Location: `org.apposed.appose.builder.MambaBuilder` **UvBuilder** - Fast Python virtual environments via uv @@ -183,6 +185,7 @@ The project provides type-safe builder classes for different environment types: - Supports `requirements.txt` and `pyproject.toml` - Standard Python venv structure (no special activation needed) - Environment structure: `/bin` (or `Scripts` on Windows) +- Lock files: `.lockFile()/.lockContent()/.lockUrl()` for reproducible builds (copies `uv.lock` into envDir, installs with `uv sync --locked`; requires a `pyproject.toml` declaration) - Location: `org.apposed.appose.builder.UvBuilder` **DynamicBuilder** - Auto-detects appropriate builder based on configuration content @@ -196,6 +199,7 @@ The project provides type-safe builder classes for different environment types: - Created via `Appose.custom()` or implicitly via `Appose.system()` - No package installation; uses whatever executables are on the system - Methods: `binPaths(paths...)`, `appendSystemPath()`, `inheritRunningJava()` +- Does not support lock files (no package management) - Location: `org.apposed.appose.builder.SimpleBuilder` ### API Examples @@ -241,6 +245,16 @@ Environment env = Appose.file("path/to/environment.yml") .logDebug() .build(); +// Reproducible build pinned by a lock file (uv: uv.lock -> uv sync --locked) +Environment env = Appose.uv("path/to/pyproject.toml") + .lockFile("path/to/uv.lock") + .build(); + +// Reproducible build pinned by a lock file (pixi: pixi.lock -> pixi install --locked) +Environment env = Appose.pixi("path/to/pixi.toml") + .lockFile("path/to/pixi.lock") + .build(); + // Wrap existing environment Environment env = Appose.wrap("/path/to/existing/env"); @@ -270,6 +284,28 @@ All builders support subscription methods for monitoring: - `subscribeError(consumer)` - Error output from build process - `logDebug()` - Convenience method that logs output and errors to stderr +### Lock Files & Reproducible Builds + +`PixiBuilder` and `UvBuilder` support reproducible, lock-file-pinned builds via the +`lockContent(String)`, `lockFile(String|File)`, and `lockUrl(String|URL)` methods (mirroring the +`content`/`file`/`url` declaration API). When a lock is supplied: + +- The lock is copied into the environment directory (`uv.lock` / `pixi.lock`) before install. +- The install runs with `--locked` (`uv sync` / `pixi install`), so the environment matches the lock + exactly, and the build fails if the lock is out of date with the manifest. +- uv requires a `pyproject.toml` declaration; pixi requires `pixi.toml` or `pyproject.toml`, and + rejects programmatic `channels()` (which would rewrite the lock). +- The `appose.json` state snapshot records a `lockHash` (SHA-256 of the lock content), so a change to + the lock forces a rebuild via the normal `isUpToDate()` check. +- `DynamicBuilder` (`Appose.file/url/content`) forwards the lock to the detected pixi/uv builder. +- `wrap()` restores the lock only if `appose.json` has a `lockHash`, since pixi and uv write a lock + file even for lock-less builds. +- `MambaBuilder` and `SimpleBuilder` do **not** support lock files and throw + `UnsupportedOperationException`. + +When no lock is supplied, behavior is unchanged: no strict flag is passed and `appose.json` omits +`lockHash`, so the snapshot is byte-identical to pre-lock-file builds (no spurious rebuilds). + ## Related Projects - appose-python: Python implementation of Appose (https://github.com/apposed/appose-python) diff --git a/src/main/java/org/apposed/appose/Builder.java b/src/main/java/org/apposed/appose/Builder.java index da3abc4..25889ef 100644 --- a/src/main/java/org/apposed/appose/Builder.java +++ b/src/main/java/org/apposed/appose/Builder.java @@ -29,15 +29,13 @@ package org.apposed.appose; -import java.io.ByteArrayOutputStream; +import org.apposed.appose.util.Downloads; +import org.apposed.appose.util.FilePaths; + import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -238,12 +236,7 @@ default T file(String path) throws BuildException { */ default T file(File file) throws BuildException { try { - Path filePath = file.toPath(); - String fileContent = new String( - Files.readAllBytes(filePath), - StandardCharsets.UTF_8 - ); - return content(fileContent); + return content(FilePaths.readText(file)); } catch (IOException e) { throw new BuildException(this, e); @@ -276,14 +269,8 @@ default T url(String path) throws BuildException { * @throws BuildException If the URL cannot be read */ default T url(URL url) throws BuildException { - try (InputStream stream = url.openStream()) { - ByteArrayOutputStream result = new ByteArrayOutputStream(); - byte[] buffer = new byte[8192]; - int length; - while ((length = stream.read(buffer)) != -1) { - result.write(buffer, 0, length); - } - return content(result.toString(StandardCharsets.UTF_8.name())); + try { + return content(Downloads.readText(url)); } catch (IOException e) { throw new BuildException(this, e); @@ -308,6 +295,88 @@ default T url(URL url) throws BuildException { */ T scheme(String scheme); + /** + * Specifies lock file content for reproducible builds. When provided, the + * lock file is copied into the environment directory, and the environment + * is installed strictly from it (via {@code --locked}), failing if the + * lock is out of date with the configuration file. + *

+ * Not all builders support lock files; builders that do not will throw + * {@link UnsupportedOperationException}. + *

+ * + * @param lockContent Lock file content (e.g., uv.lock, pixi.lock) + * @return This builder instance, for fluent-style programming. + * @throws UnsupportedOperationException If this builder does not support lock files. + */ + default T lockContent(String lockContent) { + throw new UnsupportedOperationException( + getClass().getSimpleName() + " does not support lock files"); + } + + /** + * Specifies a lock file path for reproducible builds. + * Reads the file content immediately and delegates to {@link #lockContent(String)}. + * + * @param path Path to the lock file (e.g., "uv.lock", "pixi.lock") + * @return This builder instance, for fluent-style programming. + * @throws BuildException If the file cannot be read + */ + default T lockFile(String path) throws BuildException { + return lockFile(new File(path)); + } + + /** + * Specifies a lock file for reproducible builds. + * Reads the file content immediately and delegates to {@link #lockContent(String)}. + * + * @param file Lock file (e.g., uv.lock, pixi.lock) + * @return This builder instance, for fluent-style programming. + * @throws BuildException If the file cannot be read + */ + default T lockFile(File file) throws BuildException { + try { + return lockContent(FilePaths.readText(file)); + } + catch (IOException e) { + throw new BuildException(this, e); + } + } + + /** + * Specifies a URL to fetch lock file content from for reproducible builds. + * Reads the URL content immediately and delegates to {@link #lockContent(String)}. + * + * @param path URL path of the lock file + * @return This builder instance, for fluent-style programming. + * @throws BuildException If the URL cannot be read or is invalid + */ + default T lockUrl(String path) throws BuildException { + try { + return lockUrl(new URL(path)); + } + catch (MalformedURLException e) { + throw new BuildException(this, e); + } + } + + /** + * Specifies a URL to fetch lock file content from for reproducible builds. + * Reads the URL content immediately and delegates to {@link #lockContent(String)}. + * + * @param url URL to the lock file + * @return This builder instance, for fluent-style programming. + * @throws BuildException If the URL cannot be read + */ + default T lockUrl(URL url) throws BuildException { + try { + return lockContent(Downloads.readText(url)); + } + catch (IOException e) { + throw new BuildException(this, e); + } + } + /** * Registers a callback method to be invoked when progress happens during environment building. * diff --git a/src/main/java/org/apposed/appose/builder/BaseBuilder.java b/src/main/java/org/apposed/appose/builder/BaseBuilder.java index a1d16cc..dbda806 100644 --- a/src/main/java/org/apposed/appose/builder/BaseBuilder.java +++ b/src/main/java/org/apposed/appose/builder/BaseBuilder.java @@ -44,6 +44,8 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -73,6 +75,9 @@ public abstract class BaseBuilder> implements Builder state) { state.put("channels", channels); state.put("flags", flags); state.put("envVars", new TreeMap<>(envVars)); + // Record a hash of the lock file content so that lock changes trigger a + // rebuild via the exact-match isUpToDate() comparison. Only added when a + // lock is supplied, so lock-less builds produce a byte-identical + // appose.json (backward compatibility). + if (lockContent != null) state.put("lockHash", computeLockHash(lockContent)); + } + + /** + * Computes a SHA-256 hash (lowercase hex) of the given content, or null if + * the content is null. Used to snapshot lock files into {@code appose.json} + * without storing the (potentially large) lock content verbatim. + * + * @param content The content to hash (e.g., lock file content). + * @return The SHA-256 hex hash, or null if content is null. + */ + protected static String computeLockHash(String content) { + if (content == null) return null; + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(content.getBytes(StandardCharsets.UTF_8)); + char[] hex = new char[digest.length * 2]; + for (int i = 0; i < digest.length; i++) { + int v = digest[i] & 0xff; + hex[i * 2] = Character.forDigit(v >>> 4, 16); + hex[i * 2 + 1] = Character.forDigit(v & 0x0f, 16); + } + return new String(hex); + } + catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the JVM specification; this never happens. + throw new RuntimeException("SHA-256 algorithm not available", e); + } } /** @@ -239,6 +282,48 @@ protected void writeApposeStateFile(File envDir) throws IOException { Files.write(apposeJson.toPath(), buildStateString().getBytes(StandardCharsets.UTF_8)); } + /** + * Reads the builder state recorded in {@code appose.json} in the given directory. + * + * @param envDir The environment directory. + * @return The recorded state, or null if absent or unreadable. + * @throws IOException If reading {@code appose.json} fails. + */ + protected static Map readApposeState(File envDir) throws IOException { + File apposeJson = new File(envDir, "appose.json"); + if (!apposeJson.isFile()) return null; + Object state; + try { + state = Json.parseJson(FilePaths.readText(apposeJson)); + } + catch (RuntimeException e) { + return null; // Unreadable state; the env will just look stale. + } + return state instanceof Map ? (Map) state : null; + } + + /** + * Restores the lock file content of a wrapped environment, so that + * {@link #rebuild()} reproduces it even after the directory is deleted. + *

+ * Note: package managers write a lock file even for lock-less builds, so + * the lock is only restored if {@code appose.json} records that the + * environment was built from one. + *

+ * + * @param envDir The environment directory. + * @param lockFileName Name of the lock file (e.g., "uv.lock", "pixi.lock"). + * @throws IOException If reading the state or lock file fails. + */ + protected void restoreLockContent(File envDir, String lockFileName) throws IOException { + if (lockContent != null) return; + Map state = readApposeState(envDir); + if (state == null || !state.containsKey("lockHash")) return; + File lockFile = new File(envDir, lockFileName); + if (!lockFile.isFile()) return; + lockContent = FilePaths.readText(lockFile); + } + /** * Tests whether the given directory actually contains a usable environment * of this builder's type, regardless of who built it. Used by {@link #status()}. diff --git a/src/main/java/org/apposed/appose/builder/DynamicBuilder.java b/src/main/java/org/apposed/appose/builder/DynamicBuilder.java index 8b20a33..a0fce09 100644 --- a/src/main/java/org/apposed/appose/builder/DynamicBuilder.java +++ b/src/main/java/org/apposed/appose/builder/DynamicBuilder.java @@ -96,6 +96,9 @@ private void copyConfigToDelegate(Builder delegate) { if (envDir != null) delegate.base(envDir); if (content != null) delegate.content(content); if (scheme != null) delegate.scheme(scheme.name()); + // Forward the lock file if one was provided. Builders that do not + // support locks (mamba/custom) override lockContent() to throw. + if (lockContent != null) delegate.lockContent(lockContent); delegate.channels(channels); progressSubscribers.forEach(delegate::subscribeProgress); outputSubscribers.forEach(delegate::subscribeOutput); diff --git a/src/main/java/org/apposed/appose/builder/MambaBuilder.java b/src/main/java/org/apposed/appose/builder/MambaBuilder.java index 53992af..c5100a9 100644 --- a/src/main/java/org/apposed/appose/builder/MambaBuilder.java +++ b/src/main/java/org/apposed/appose/builder/MambaBuilder.java @@ -58,6 +58,12 @@ public String envType() { return "mamba"; } + @Override + public MambaBuilder lockContent(String lockContent) { + throw new UnsupportedOperationException( + "MambaBuilder does not yet support lock files"); + } + @Override protected boolean hasEnvironment(File envDir) { return new File(envDir, "conda-meta").isDirectory(); diff --git a/src/main/java/org/apposed/appose/builder/PixiBuilder.java b/src/main/java/org/apposed/appose/builder/PixiBuilder.java index 6ddc11a..b8935ad 100644 --- a/src/main/java/org/apposed/appose/builder/PixiBuilder.java +++ b/src/main/java/org/apposed/appose/builder/PixiBuilder.java @@ -120,6 +120,28 @@ public Environment build() throws BuildException { } } + // Validate lock-file compatibility. pixi lockfiles apply to manifest- + // based builds (pixi.toml / pyproject.toml); programmatic builds and + // imported environment.yml have no user manifest to lock against. + if (lockContent != null) { + if (content == null) { + throw new IllegalArgumentException( + "PixiBuilder lock files require a declaration file via .file()/.content(); " + + "programmatic builds cannot be locked."); + } + if (!"pixi.toml".equals(scheme.name()) && !"pyproject.toml".equals(scheme.name())) { + throw new IllegalArgumentException( + "PixiBuilder lock files require a pixi.toml or pyproject.toml declaration; " + + "environment.yml imports have no lockfile mechanism."); + } + // Note: adding channels re-resolves the manifest and rewrites the lock. + if (!channels.isEmpty()) { + throw new IllegalArgumentException( + "PixiBuilder lock files cannot be combined with programmatic channels; " + + "declare the channels in the manifest instead."); + } + } + Pixi pixi = new Pixi(); // Set up progress/output consumers. @@ -181,6 +203,13 @@ else if ("environment.yml".equals(scheme.name())) { pixi.exec("init", "--import", environmentYamlFile.getAbsolutePath(), envDir.getAbsolutePath()); } + // If a lock file was provided, copy it into the env dir so the + // subsequent install runs strictly from it (--locked). + if (lockContent != null) { + File pixiLockFile = new File(envDir, "pixi.lock"); + Files.write(pixiLockFile.toPath(), lockContent.getBytes(StandardCharsets.UTF_8)); + } + // Add any programmatic channels to augment source file. if (!channels.isEmpty()) { pixi.addChannels(envDir, channels.toArray(new String[0])); @@ -219,7 +248,7 @@ else if ("environment.yml".equals(scheme.name())) { } } - runPixiInstall(pixi, envDir); + runPixiInstall(pixi, envDir, lockContent != null); writeApposeStateFile(envDir); return buildPixiEnvironment(pixi, envDir); } @@ -264,6 +293,7 @@ public Environment wrap(File envDir) throws BuildException { scheme = Schemes.fromName("pyproject.toml"); } } + restoreLockContent(envDir, "pixi.lock"); } catch (IOException e) { throw new BuildException(this, e); @@ -283,7 +313,7 @@ private static List withFlag(List flags, String flag) { return result; } - private void runPixiInstall(Pixi pixi, File envDir) throws IOException, InterruptedException { + private void runPixiInstall(Pixi pixi, File envDir, boolean locked) throws IOException, InterruptedException { File manifestFile = new File(envDir, "pyproject.toml"); if (!manifestFile.exists()) manifestFile = new File(envDir, "pixi.toml"); @@ -300,9 +330,16 @@ private void runPixiInstall(Pixi pixi, File envDir) throws IOException, Interrup pixi.setErrorConsumer(monitor::intercept); } - // Ensure the pixi environment is fully installed. + // Ensure the pixi environment is fully installed. When a lock was + // provided, pass --locked so pixi installs exactly what pixi.lock + // specifies, failing if the lock is out of date with the manifest. try { - pixi.exec("install", "--manifest-path", manifestFile.getAbsolutePath()); + if (locked) { + pixi.exec("install", "--manifest-path", manifestFile.getAbsolutePath(), "--locked"); + } + else { + pixi.exec("install", "--manifest-path", manifestFile.getAbsolutePath()); + } } finally { if (monitor != null) { diff --git a/src/main/java/org/apposed/appose/builder/SimpleBuilder.java b/src/main/java/org/apposed/appose/builder/SimpleBuilder.java index 6bb0e9e..301c680 100644 --- a/src/main/java/org/apposed/appose/builder/SimpleBuilder.java +++ b/src/main/java/org/apposed/appose/builder/SimpleBuilder.java @@ -194,6 +194,13 @@ public SimpleBuilder channels(List channels) { "It uses existing executables without package management."); } + @Override + public SimpleBuilder lockContent(String lockContent) { + throw new UnsupportedOperationException( + "SimpleBuilder does not support lock files. " + + "Custom environments use existing executables without package management."); + } + // -- Internal methods -- @Override diff --git a/src/main/java/org/apposed/appose/builder/UvBuilder.java b/src/main/java/org/apposed/appose/builder/UvBuilder.java index 0e47858..3b2f373 100644 --- a/src/main/java/org/apposed/appose/builder/UvBuilder.java +++ b/src/main/java/org/apposed/appose/builder/UvBuilder.java @@ -33,7 +33,6 @@ import org.apposed.appose.EnvStatus; import org.apposed.appose.Environment; import org.apposed.appose.util.FilePaths; -import org.apposed.appose.util.Json; import org.apposed.appose.util.Platforms; import org.apposed.appose.scheme.Schemes; import org.apposed.appose.tool.Uv; @@ -180,6 +179,22 @@ public Environment build() throws BuildException { "Dependency groups are only supported with pyproject.toml scheme"); } + // Validate lock-file compatibility. uv lockfiles only apply to the + // pyproject.toml / uv sync path: requirements.txt uses pip install (no + // lockfile), and programmatic builds have no manifest to lock against. + if (lockContent != null) { + if (content == null) { + throw new IllegalArgumentException( + "UvBuilder lock files require a declaration file via .file()/.content(); " + + "programmatic builds cannot be locked."); + } + if (!"pyproject.toml".equals(scheme.name())) { + throw new IllegalArgumentException( + "UvBuilder lock files require a pyproject.toml declaration; " + + "requirements.txt has no lockfile mechanism."); + } + } + try { // If the env state matches our current configuration, // skip all package management and return immediately. @@ -212,8 +227,16 @@ public Environment build() throws BuildException { File pyprojectFile = new File(envDir, "pyproject.toml"); Files.write(pyprojectFile.toPath(), content.getBytes(StandardCharsets.UTF_8)); + // If a lock file was provided, copy it into the env dir and + // install strictly from it (--locked) for reproducibility. + boolean locked = lockContent != null; + if (locked) { + File uvLockFile = new File(envDir, "uv.lock"); + Files.write(uvLockFile.toPath(), lockContent.getBytes(StandardCharsets.UTF_8)); + } + // Run uv sync to create .venv and install dependencies. - uv.sync(envDir, pythonVersion, groups); + uv.sync(envDir, pythonVersion, groups, locked); } else { // Handle requirements.txt - traditional venv + pip install. // Create virtual environment if it doesn't exist. @@ -267,23 +290,16 @@ public Environment wrap(File envDir) throws BuildException { // Restore any dependency groups, which pyproject.toml does not record. // Otherwise, the environment looks stale, and gets synced without them. - File apposeJson = new File(envDir, "appose.json"); - if (groups.isEmpty() && apposeJson.isFile()) { - String json = new String(Files.readAllBytes(apposeJson.toPath()), StandardCharsets.UTF_8); - Object state; - try { - state = Json.parseJson(json); - } - catch (RuntimeException e) { - state = null; // Unreadable state; the env will just look stale. - } - if (state instanceof Map) { - Object stateGroups = ((Map) state).get("groups"); - if (stateGroups instanceof List) { - for (Object g : (List) stateGroups) groups.add(g.toString()); - } + Map state = groups.isEmpty() ? readApposeState(envDir) : null; + if (state != null) { + Object stateGroups = state.get("groups"); + if (stateGroups instanceof List) { + for (Object g : (List) stateGroups) groups.add(g.toString()); } } + + // Likewise, restore the lock file, if the env was built from one. + restoreLockContent(envDir, "uv.lock"); } else { // Fall back to requirements.txt. diff --git a/src/main/java/org/apposed/appose/tool/Uv.java b/src/main/java/org/apposed/appose/tool/Uv.java index 19092bb..a2f8fdd 100644 --- a/src/main/java/org/apposed/appose/tool/Uv.java +++ b/src/main/java/org/apposed/appose/tool/Uv.java @@ -293,6 +293,28 @@ public void sync(final File projectDir, String pythonVersion) throws IOException * @throws IllegalStateException if uv has not been installed */ public void sync(final File projectDir, String pythonVersion, List groups) throws IOException, InterruptedException { + sync(projectDir, pythonVersion, groups, false); + } + + /** + * Synchronize a project's dependencies from pyproject.toml, including the + * given PEP 735 dependency groups. + * Creates a virtual environment at projectDir/.venv and installs dependencies. + *

+ * When {@code locked} is true, runs with {@code --locked}, so uv installs + * from the existing lockfile ({@code uv.lock}), failing if it is missing + * or out of date relative to {@code pyproject.toml}. + *

+ * + * @param projectDir The project directory containing pyproject.toml. + * @param pythonVersion Optional Python version (e.g., "3.11"). Can be null for default. + * @param groups Optional dependency groups to include. Can be null for none. + * @param locked If true, pass {@code --locked} to enforce strict lockfile adherence. + * @throws IOException If an I/O error occurs. + * @throws InterruptedException If the current thread is interrupted. + * @throws IllegalStateException if uv has not been installed + */ + public void sync(final File projectDir, String pythonVersion, List groups, boolean locked) throws IOException, InterruptedException { List args = new ArrayList<>(); args.add("sync"); if (pythonVersion != null && !pythonVersion.isEmpty()) { @@ -305,6 +327,9 @@ public void sync(final File projectDir, String pythonVersion, List group args.add(group); } } + if (locked) { + args.add("--locked"); + } // Run uv sync with working directory set to projectDir. exec(projectDir, args.toArray(new String[0])); diff --git a/src/main/java/org/apposed/appose/util/Downloads.java b/src/main/java/org/apposed/appose/util/Downloads.java index 25139c9..ff8d94f 100644 --- a/src/main/java/org/apposed/appose/util/Downloads.java +++ b/src/main/java/org/apposed/appose/util/Downloads.java @@ -41,6 +41,7 @@ import org.apposed.appose.Nullable; import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -54,6 +55,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.nio.channels.Channels; @@ -463,6 +465,25 @@ public static String redirectLocation(String url) throws IOException { } } + /** + * Reads the given URL's content as a UTF-8 string. + * + * @param url The URL to read. + * @return The URL's content. + * @throws IOException If the URL cannot be read. + */ + public static String readText(URL url) throws IOException { + try (InputStream stream = url.openStream()) { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int length; + while ((length = stream.read(buffer)) != -1) { + result.write(buffer, 0, length); + } + return result.toString(StandardCharsets.UTF_8.name()); + } + } + /** * Gets the size of the file stored in the given URL. * @param url url where the file is stored diff --git a/src/main/java/org/apposed/appose/util/FilePaths.java b/src/main/java/org/apposed/appose/util/FilePaths.java index caf4ddc..f2109f0 100644 --- a/src/main/java/org/apposed/appose/util/FilePaths.java +++ b/src/main/java/org/apposed/appose/util/FilePaths.java @@ -35,6 +35,7 @@ import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; @@ -243,6 +244,17 @@ public static void deleteRecursively(File dir) throws IOException { Files.delete(path); } + /** + * Reads the given file's content as a UTF-8 string. + * + * @param file The file to read. + * @return The file's content. + * @throws IOException If the file cannot be read. + */ + public static String readText(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + /** * Checks that the given file is an existing directory, throwing an exception if not. * diff --git a/src/test/java/org/apposed/appose/TestBase.java b/src/test/java/org/apposed/appose/TestBase.java index f1dcf6d..db7c5f8 100644 --- a/src/test/java/org/apposed/appose/TestBase.java +++ b/src/test/java/org/apposed/appose/TestBase.java @@ -33,10 +33,14 @@ import org.apposed.appose.Service.Task; import org.apposed.appose.Service.TaskStatus; import org.apposed.appose.builder.ApposeRequirement; +import org.apposed.appose.util.FilePaths; +import org.apposed.appose.util.Json; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -274,4 +278,18 @@ public void assertComplete(Task task) { } assertEquals(TaskStatus.COMPLETE, task.status, errorMessage); } + + /** + * Reads and parses the {@code appose.json} state file from the given + * environment directory. + * + * @param envDir The environment directory containing {@code appose.json}. + * @return The parsed state. + * @throws IOException If the file cannot be read. + */ + public static Map apposeJsonMap(File envDir) throws IOException { + File apposeJson = new File(envDir, "appose.json"); + assertTrue(apposeJson.isFile(), "appose.json should exist"); + return (Map) Json.parseJson(FilePaths.readText(apposeJson)); + } } diff --git a/src/test/java/org/apposed/appose/builder/BaseBuilderTest.java b/src/test/java/org/apposed/appose/builder/BaseBuilderTest.java new file mode 100644 index 0000000..48fd128 --- /dev/null +++ b/src/test/java/org/apposed/appose/builder/BaseBuilderTest.java @@ -0,0 +1,106 @@ +/*- + * #%L + * Appose: multi-language interprocess cooperation with shared memory. + * %% + * Copyright (C) 2023 - 2026 Appose developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.apposed.appose.builder; + +import org.apposed.appose.BuildException; +import org.junit.jupiter.api.Test; + +import java.io.File; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link BaseBuilder} helpers. These are fast and require no + * external tools or network access. + */ +public class BaseBuilderTest { + + /** Same lock content must produce an identical hash. */ + @Test + public void testComputeLockHashDeterministic() { + String content = "version = 1\npackages = []\n"; + assertEquals(BaseBuilder.computeLockHash(content), BaseBuilder.computeLockHash(content), + "identical content must hash identically"); + } + + /** Different lock content must produce different hashes. */ + @Test + public void testComputeLockHashContentSensitive() { + String a = BaseBuilder.computeLockHash("packages = [\"a\"]\n"); + String b = BaseBuilder.computeLockHash("packages = [\"b\"]\n"); + assertNotEquals(a, b, "different content must hash differently"); + } + + /** Null content must hash to null (so no lockHash key is emitted). */ + @Test + public void testComputeLockHashNull() { + assertNull(BaseBuilder.computeLockHash(null)); + } + + /** The hash must be a 64-character lowercase hex string (SHA-256). */ + @Test + public void testComputeLockHashFormat() { + String hash = BaseBuilder.computeLockHash("appose"); + assertEquals(64, hash.length(), "SHA-256 hex must be 64 chars"); + assertTrue(hash.matches("[0-9a-f]{64}"), "hash must be lowercase hex: " + hash); + } + + /** Builders that cannot honor lock files reject them early. */ + @Test + public void testMambaRejectsLock() { + assertThrows(UnsupportedOperationException.class, + () -> new MambaBuilder().lockContent("anything")); + } + + /** Builders that cannot honor lock files reject them early. */ + @Test + public void testSimpleRejectsLock() { + assertThrows(UnsupportedOperationException.class, + () -> new SimpleBuilder().lockContent("anything")); + } + + /** A missing lock file surfaces as a BuildException (not a raw IOException). */ + @Test + public void testLockFileMissingThrowsBuildException() { + assertThrows(BuildException.class, + () -> new UvBuilder().lockFile(new File("this-lock-does-not-exist.lock"))); + } + + /** A malformed lock URL surfaces as a BuildException (not a raw MalformedURLException). */ + @Test + public void testLockUrlMalformedThrowsBuildException() { + assertThrows(BuildException.class, + () -> new UvBuilder().lockUrl("ht!tp://not a valid url")); + } +} diff --git a/src/test/java/org/apposed/appose/builder/MambaBuilderTest.java b/src/test/java/org/apposed/appose/builder/MambaBuilderTest.java index 962f07a..f9810e6 100644 --- a/src/test/java/org/apposed/appose/builder/MambaBuilderTest.java +++ b/src/test/java/org/apposed/appose/builder/MambaBuilderTest.java @@ -37,6 +37,7 @@ import java.io.File; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** End-to-end tests for {@link MambaBuilder}. */ @@ -62,4 +63,24 @@ public void testExplicitMambaBuilder() throws Exception { cowsayAndAssert(env, "yay"); } + + /** + * Forwarding a lock to a mamba delegate via a dynamic builder must fail + * clearly, since MambaBuilder does not yet support lock files. + */ + @Test + public void testDynamicLockMambaFails() { + String envYml = + "name: lock-mamba-fail\n" + + "channels:\n" + + " - conda-forge\n" + + "dependencies:\n" + + " - python>=3.8\n"; + assertThrows(UnsupportedOperationException.class, () -> + Appose.content(envYml) + .builder("mamba") + .lockContent("bogus") + .base("target/envs/mamba-lock-fail") + .build()); + } } diff --git a/src/test/java/org/apposed/appose/builder/PixiBuilderTest.java b/src/test/java/org/apposed/appose/builder/PixiBuilderTest.java index ad09717..ec7401b 100644 --- a/src/test/java/org/apposed/appose/builder/PixiBuilderTest.java +++ b/src/test/java/org/apposed/appose/builder/PixiBuilderTest.java @@ -30,6 +30,8 @@ package org.apposed.appose.builder; import org.apposed.appose.Appose; +import org.apposed.appose.BuildException; +import org.apposed.appose.EnvStatus; import org.apposed.appose.Environment; import org.apposed.appose.TestBase; import org.apposed.appose.util.FilePaths; @@ -44,7 +46,10 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -292,4 +297,185 @@ public void testURLSupport() throws Exception { assertInstanceOf(PixiBuilder.class, env.builder()); cowsayAndAssert(env, "url!"); } + + // -- Lock-file reproducible builds -- + + /** + * A user-supplied lock is copied into the env dir and the install runs with + * --locked, yielding a reproducible, working environment. Exercises both the + * {@code .lockFile(File)} and {@code .lockUrl(URL)} entry points. + */ + @Test + public void testPixiLocked() throws Exception { + // Build without a lock first to generate a valid pixi.lock. + String baseA = "target/envs/pixi-lock-src"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(baseA).logDebug().build(); + File lockFileA = new File(baseA, "pixi.lock"); + assertTrue(lockFileA.isFile(), "first build should generate a pixi.lock"); + String lockContent = FilePaths.readText(lockFileA); + + // .lockFile(File): lock copied in, install runs --locked. + String baseB = "target/envs/pixi-lock-file"; + FilePaths.deleteRecursively(new File(baseB)); + Environment envB = Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(baseB).lockFile(lockFileA).logDebug().build(); + assertTrue(new File(baseB, "pixi.lock").isFile(), "lock should be copied into the env dir"); + assertTrue(apposeJsonMap(new File(baseB)).containsKey("lockHash"), + "appose.json should record lockHash when a lock is supplied"); + cowsayAndAssert(envB, "locked"); + + // .lockUrl(URL): same outcome via a file:// URL. + String baseC = "target/envs/pixi-lock-url"; + FilePaths.deleteRecursively(new File(baseC)); + Environment envC = Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(baseC).lockUrl(lockFileA.toURI().toURL()).logDebug().build(); + assertTrue(apposeJsonMap(new File(baseC)).containsKey("lockHash")); + cowsayAndAssert(envC, "url-lock"); + } + + /** + * A lock that is out of date with the manifest must be rejected by + * --locked. (Without --locked, pixi would update the lock and succeed.) + */ + @Test + public void testPixiLockStaleFails() throws Exception { + // A valid lock for the cowsay manifest... + String baseA = "target/envs/pixi-stale-src"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(baseA).logDebug().build(); + String cowsayLock = FilePaths.readText(new File(baseA, "pixi.lock")); + + // ...is stale for the same workspace additionally requiring `requests`. + String pixiExtra = FilePaths.readText(new File("src/test/resources/envs/cowsay-pixi.toml")) + + "requests = \"*\"\n"; + String base = "target/envs/pixi-lock-stale"; + FilePaths.deleteRecursively(new File(base)); + assertThrows(BuildException.class, () -> + Appose.pixi().content(pixiExtra).base(base).lockContent(cowsayLock).logDebug().build()); + } + + /** + * Adding channels would re-resolve the manifest, rewriting the lock, + * so programmatic channels cannot be combined with a lock. + */ + @Test + public void testPixiLockWithChannelsUnsupported() { + assertThrows(IllegalArgumentException.class, () -> + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .channels("bioconda") + .base("target/envs/pixi-lock-channels") + .lockContent("bogus") + .build()); + } + + /** + * Programmatic builds (no manifest) cannot be locked. + */ + @Test + public void testPixiLockProgrammaticUnsupported() { + assertThrows(IllegalArgumentException.class, () -> + Appose.pixi() + .conda("python>=3.8") + .pypi("cowsay==6.1") + .base("target/envs/pixi-lock-prog") + .lockContent("bogus") + .build()); + } + + /** + * When no lock is supplied, appose.json must NOT contain a lockHash key, so + * the snapshot stays byte-identical to pre-lock-file builds (backward + * compatibility) and existing environments are never spuriously rebuilt. + */ + @Test + public void testPixiNoLockBackwardCompat() throws Exception { + String base = "target/envs/pixi-no-lock"; + FilePaths.deleteRecursively(new File(base)); + Environment env = Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(base).logDebug().build(); + assertFalse(apposeJsonMap(new File(base)).containsKey("lockHash"), + "appose.json must NOT contain lockHash when no lock is supplied"); + cowsayAndAssert(env, "nolock"); + } + + /** + * Changing the lock content must change the lockHash in appose.json and thus + * force a rebuild. The lock is edited with a trailing comment, which doesn't + * change the resolved package set, so --locked still succeeds. + */ + @Test + public void testPixiLockChangeTriggersRebuild() throws Exception { + String baseA = "target/envs/pixi-lock-change-src"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml").base(baseA).logDebug().build(); + String lock = FilePaths.readText(new File(baseA, "pixi.lock")); + + String base = "target/envs/pixi-lock-change"; + FilePaths.deleteRecursively(new File(base)); + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(base).lockContent(lock).logDebug().build(); + String hashBefore = (String) apposeJsonMap(new File(base)).get("lockHash"); + + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(base).lockContent(lock + "# trailing comment\n").logDebug().build(); + String hashAfter = (String) apposeJsonMap(new File(base)).get("lockHash"); + + assertNotNull(hashBefore, "lockHash should be present after a locked build"); + assertNotNull(hashAfter, "lockHash should be present after rebuild"); + assertNotEquals(hashBefore, hashAfter, + "a lock change must produce a different lockHash and force a rebuild"); + } + + /** + * wrap() captures the lock file into builder state, so rebuild() reproduces + * the locked environment even after its directory has been deleted. + */ + @Test + public void testPixiWrapLockSurvivesRebuild() throws Exception { + // Build a locked environment (generate a valid lock first). + String srcBase = "target/envs/pixi-wrap-src"; + FilePaths.deleteRecursively(new File(srcBase)); + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml").base(srcBase).logDebug().build(); + String lock = FilePaths.readText(new File(srcBase, "pixi.lock")); + + String baseA = "target/envs/pixi-wrap-locked"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(baseA).lockContent(lock).logDebug().build(); + assertTrue(apposeJsonMap(new File(baseA)).containsKey("lockHash")); + + // Wrap the locked env (capturing pixi.toml + pixi.lock), then wipe + rebuild. + Environment env = Appose.wrap(new File(baseA)); + assertInstanceOf(PixiBuilder.class, env.builder()); + assertEquals(EnvStatus.CURRENT, env.builder().status()); + Environment rebuilt = env.rebuild(); + + assertTrue(apposeJsonMap(new File(baseA)).containsKey("lockHash"), + "rebuild after wrap must reproduce lockHash from the captured lock"); + cowsayAndAssert(rebuilt, "rewrapped"); + } + + /** + * pixi install writes a pixi.lock even when no lock is supplied. Wrapping + * such an environment must not adopt that lock, or the env would look + * stale, and rebuild() would install from a lock never asked for. + */ + @Test + public void testPixiWrapIgnoresUnrequestedLock() throws Exception { + String base = "target/envs/pixi-wrap-unlocked"; + FilePaths.deleteRecursively(new File(base)); + Appose.pixi("src/test/resources/envs/cowsay-pixi.toml") + .base(base).logDebug().build(); + assertTrue(new File(base, "pixi.lock").isFile(), "pixi install should generate a pixi.lock"); + + Environment env = Appose.wrap(new File(base)); + assertEquals(EnvStatus.CURRENT, env.builder().status()); + Environment rebuilt = env.rebuild(); + assertFalse(apposeJsonMap(new File(base)).containsKey("lockHash"), + "rebuild after wrap must not lock to the generated pixi.lock"); + cowsayAndAssert(rebuilt, "unlocked"); + } } diff --git a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java index 08d17ee..10ff90d 100644 --- a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java +++ b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java @@ -30,21 +30,22 @@ package org.apposed.appose.builder; import org.apposed.appose.Appose; +import org.apposed.appose.BuildException; import org.apposed.appose.EnvStatus; import org.apposed.appose.Environment; import org.apposed.appose.TestBase; -import org.apposed.appose.util.Json; +import org.apposed.appose.util.FilePaths; import org.junit.jupiter.api.Test; import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.Arrays; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -84,7 +85,7 @@ public void testUvPyproject() throws Exception { cowsayAndAssert(env, "pyproject"); // No groups were requested, so none should be recorded. - Map state = readState(env); + Map state = apposeJsonMap(new File(env.base())); assertFalse(state.containsKey("groups"), "appose.json should not contain 'groups' when none specified"); } @@ -99,7 +100,7 @@ public void testUvPyprojectWithGroup() throws Exception { .build(); cowsayAndAssert(env, "groups"); - Map state = readState(env); + Map state = apposeJsonMap(new File(env.base())); assertEquals(Arrays.asList("cowsay"), state.get("groups")); // Wrapping (e.g. after an application restart) must retain the groups, @@ -112,7 +113,7 @@ public void testUvPyprojectWithGroup() throws Exception { // Rebuilding the wrapped environment must retain the groups too. Environment rebuilt = wrapped.rebuild(); cowsayAndAssert(rebuilt, "rebuilt"); - assertEquals(Arrays.asList("cowsay"), readState(rebuilt).get("groups")); + assertEquals(Arrays.asList("cowsay"), apposeJsonMap(new File(rebuilt.base())).get("groups")); } @Test @@ -125,11 +126,172 @@ public void testUvGroupRejectsWithoutPyproject() { .build()); } - @SuppressWarnings("unchecked") - private static Map readState(Environment env) throws Exception { - File apposeJson = new File(env.base(), "appose.json"); - assertTrue(apposeJson.isFile(), "appose.json should exist"); - String json = new String(Files.readAllBytes(apposeJson.toPath()), StandardCharsets.UTF_8); - return (Map) Json.parseJson(json); + // -- Lock-file reproducible builds -- + + /** + * A user-supplied lock is copied into the env dir and the install runs with + * --locked, yielding a reproducible, working environment. Exercises both the + * {@code .lockFile(File)} and {@code .lockUrl(URL)} entry points. + */ + @Test + public void testUvLocked() throws Exception { + // First, build without a lock to generate a valid uv.lock for the manifest. + String baseA = "target/envs/uv-lock-src"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(baseA).logDebug().build(); + File lockFileA = new File(baseA, "uv.lock"); + assertTrue(lockFileA.isFile(), "first build should generate a uv.lock"); + String lockContent = FilePaths.readText(lockFileA); + + // .lockFile(File): lock copied in, install runs --locked. + String baseB = "target/envs/uv-lock-file"; + FilePaths.deleteRecursively(new File(baseB)); + Environment envB = Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(baseB).lockFile(lockFileA).logDebug().build(); + assertTrue(new File(baseB, "uv.lock").isFile(), "lock should be copied into the env dir"); + assertTrue(apposeJsonMap(new File(baseB)).containsKey("lockHash"), + "appose.json should record lockHash when a lock is supplied"); + cowsayAndAssert(envB, "locked"); + + // .lockUrl(URL): same outcome via a file:// URL. + String baseC = "target/envs/uv-lock-url"; + FilePaths.deleteRecursively(new File(baseC)); + Environment envC = Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(baseC).lockUrl(lockFileA.toURI().toURL()).logDebug().build(); + assertTrue(apposeJsonMap(new File(baseC)).containsKey("lockHash")); + cowsayAndAssert(envC, "url-lock"); + } + + /** + * A lock that is out of date with the manifest must be rejected by + * --locked. (Without --locked, uv would update the lock and succeed.) + */ + @Test + public void testUvLockStaleFails() throws Exception { + // A valid lock for the cowsay manifest... + String baseA = "target/envs/uv-stale-src"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(baseA).logDebug().build(); + String cowsayLock = FilePaths.readText(new File(baseA, "uv.lock")); + + // ...is stale for the same project additionally requiring `requests`. + String pyprojectExtra = + "[project]\n" + + "name = \"cowsay-test\"\n" + + "version = \"0.1.0\"\n" + + "requires-python = \">=3.9\"\n" + + "dependencies = [\"cowsay>=6.0\", \"appose>=0.1.0\", \"requests\"]\n"; + String base = "target/envs/uv-lock-stale"; + FilePaths.deleteRecursively(new File(base)); + assertThrows(BuildException.class, () -> + Appose.uv().content(pyprojectExtra) + .base(base).lockContent(cowsayLock).logDebug().build()); + } + + /** + * Lock files only apply to the pyproject.toml / uv sync path; the + * requirements.txt path uses pip install and has no lockfile. + */ + @Test + public void testUvLockUnsupportedScheme() { + assertThrows(IllegalArgumentException.class, () -> + Appose.uv("src/test/resources/envs/cowsay-requirements.txt") + .base("target/envs/uv-lock-reqs").lockContent("bogus").build()); + } + + /** + * When no lock is supplied, appose.json must NOT contain a lockHash key, so + * the snapshot stays byte-identical to pre-lock-file builds (backward + * compatibility) and existing environments are never spuriously rebuilt. + */ + @Test + public void testUvNoLockBackwardCompat() throws Exception { + String base = "target/envs/uv-no-lock"; + FilePaths.deleteRecursively(new File(base)); + Environment env = Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(base).logDebug().build(); + assertFalse(apposeJsonMap(new File(base)).containsKey("lockHash"), + "appose.json must NOT contain lockHash when no lock is supplied"); + cowsayAndAssert(env, "nolock"); + } + + /** + * Changing the lock content must change the lockHash in appose.json and thus + * force a rebuild. The lock is edited with a trailing TOML comment, which + * doesn't change the resolved package set, so --locked still succeeds. + */ + @Test + public void testUvLockChangeTriggersRebuild() throws Exception { + String baseA = "target/envs/uv-lock-change-src"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml").base(baseA).logDebug().build(); + String lock = FilePaths.readText(new File(baseA, "uv.lock")); + + String base = "target/envs/uv-lock-change"; + FilePaths.deleteRecursively(new File(base)); + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(base).lockContent(lock).logDebug().build(); + String hashBefore = (String) apposeJsonMap(new File(base)).get("lockHash"); + + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(base).lockContent(lock + "# trailing comment\n").logDebug().build(); + String hashAfter = (String) apposeJsonMap(new File(base)).get("lockHash"); + + assertNotNull(hashBefore, "lockHash should be present after a locked build"); + assertNotNull(hashAfter, "lockHash should be present after rebuild"); + assertNotEquals(hashBefore, hashAfter, + "a lock change must produce a different lockHash and force a rebuild"); + } + + /** + * wrap() captures the lock file into builder state, so rebuild() reproduces + * the locked environment even after its directory has been deleted. + */ + @Test + public void testUvWrapLockSurvivesRebuild() throws Exception { + // Build a locked environment (generate a valid lock first). + String srcBase = "target/envs/uv-wrap-src"; + FilePaths.deleteRecursively(new File(srcBase)); + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml").base(srcBase).logDebug().build(); + String lock = FilePaths.readText(new File(srcBase, "uv.lock")); + + String baseA = "target/envs/uv-wrap-locked"; + FilePaths.deleteRecursively(new File(baseA)); + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(baseA).lockContent(lock).logDebug().build(); + assertTrue(apposeJsonMap(new File(baseA)).containsKey("lockHash")); + + // Wrap the locked env (capturing pyproject.toml + uv.lock), then wipe + rebuild. + Environment env = Appose.wrap(new File(baseA)); + assertInstanceOf(UvBuilder.class, env.builder()); + assertEquals(EnvStatus.CURRENT, env.builder().status()); + Environment rebuilt = env.rebuild(); + + assertTrue(apposeJsonMap(new File(baseA)).containsKey("lockHash"), + "rebuild after wrap must reproduce lockHash from the captured lock"); + cowsayAndAssert(rebuilt, "rewrapped"); + } + + /** + * uv sync writes a uv.lock even when no lock is supplied. Wrapping such an + * environment must not adopt that lock, or the env would look stale, and + * rebuild() would install from a lock the caller never asked for. + */ + @Test + public void testUvWrapIgnoresUnrequestedLock() throws Exception { + String base = "target/envs/uv-wrap-unlocked"; + FilePaths.deleteRecursively(new File(base)); + Appose.uv("src/test/resources/envs/cowsay-pyproject.toml") + .base(base).logDebug().build(); + assertTrue(new File(base, "uv.lock").isFile(), "uv sync should generate a uv.lock"); + + Environment env = Appose.wrap(new File(base)); + assertEquals(EnvStatus.CURRENT, env.builder().status()); + Environment rebuilt = env.rebuild(); + assertFalse(apposeJsonMap(new File(base)).containsKey("lockHash"), + "rebuild after wrap must not lock to the generated uv.lock"); + cowsayAndAssert(rebuilt, "unlocked"); } }