From 56843546c89501543cbdf2f123667f210f546798 Mon Sep 17 00:00:00 2001 From: Zexin Yuan Date: Tue, 2 Jun 2026 14:01:28 +0800 Subject: [PATCH 1/5] Add PEP 735 dependency group support to UvBuilder --- CLAUDE.md | 11 +++- .../org/apposed/appose/builder/UvBuilder.java | 22 ++++++- src/main/java/org/apposed/appose/tool/Uv.java | 8 ++- .../apposed/appose/builder/UvBuilderTest.java | 62 +++++++++++++++++++ .../envs/cowsay-pyproject-groups.toml | 13 ++++ 5 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 src/test/resources/envs/cowsay-pyproject-groups.toml diff --git a/CLAUDE.md b/CLAUDE.md index b35ebc2..b95392b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,8 +179,8 @@ The project provides type-safe builder classes for different environment types: **UvBuilder** - Fast Python virtual environments via uv - Created via `Appose.uv()` or `Appose.uv(source)` -- Type-safe methods: `include(packages...)`, `python(version)` -- Supports `requirements.txt` files +- Type-safe methods: `include(packages...)`, `python(version)`, `group(groups...)` +- Supports `requirements.txt` and `pyproject.toml` - Standard Python venv structure (no special activation needed) - Environment structure: `/bin` (or `Scripts` on Windows) - Location: `org.apposed.appose.builder.UvBuilder` @@ -229,6 +229,13 @@ Environment env = Appose.uv() .name("my-env") .build(); +// uv builder with dependency groups +Environment env = Appose.uv() + .content(pyprojectContent) + .scheme("pyproject.toml") + .group("appose") + .build(); + // Dynamic builder (auto-detects) Environment env = Appose.file("path/to/environment.yml") .logDebug() diff --git a/src/main/java/org/apposed/appose/builder/UvBuilder.java b/src/main/java/org/apposed/appose/builder/UvBuilder.java index 444baa7..0397dd5 100644 --- a/src/main/java/org/apposed/appose/builder/UvBuilder.java +++ b/src/main/java/org/apposed/appose/builder/UvBuilder.java @@ -56,6 +56,7 @@ public final class UvBuilder extends BaseBuilder { private String pythonVersion; private final List packages = new ArrayList<>(); + private final List groups = new ArrayList<>(); // -- UvBuilder methods -- @@ -81,6 +82,18 @@ public UvBuilder include(String... packages) { return this; } + /** + * Adds PEP 735 dependency groups to install via {@code uv sync --group}. + * Only supported with {@code pyproject.toml} scheme. + * + * @param groups Dependency group names defined in {@code [dependency-groups]}. + * @return This builder instance, for fluent-style programming. + */ + public UvBuilder group(String... groups) { + this.groups.addAll(Arrays.asList(groups)); + return this; + } + // -- Builder methods -- @Override @@ -93,6 +106,7 @@ protected void addStateFields(Map state) { super.addStateFields(state); state.put("pythonVersion", pythonVersion); state.put("packages", packages); + if (!groups.isEmpty()) state.put("groups", groups); if (addsAppose()) { // NB: Recorded, so that a change in Appose version triggers a rebuild. state.put("appose", ApposeRequirement.get().pipArgs()); @@ -159,6 +173,12 @@ public Environment build() throws BuildException { } } + // Validate groups are only used with pyproject.toml. + if (!groups.isEmpty() && !"pyproject.toml".equals(scheme == null ? null : scheme.name())) { + throw new IllegalArgumentException( + "Dependency groups are only supported with pyproject.toml scheme"); + } + try { // If the env state matches our current configuration, // skip all package management and return immediately. @@ -192,7 +212,7 @@ public Environment build() throws BuildException { Files.write(pyprojectFile.toPath(), content.getBytes(StandardCharsets.UTF_8)); // Run uv sync to create .venv and install dependencies. - uv.sync(envDir, pythonVersion); + uv.sync(envDir, pythonVersion, groups); } else { // Handle requirements.txt - traditional venv + pip install. // Create virtual environment if it doesn't exist. diff --git a/src/main/java/org/apposed/appose/tool/Uv.java b/src/main/java/org/apposed/appose/tool/Uv.java index efc7047..5b67704 100644 --- a/src/main/java/org/apposed/appose/tool/Uv.java +++ b/src/main/java/org/apposed/appose/tool/Uv.java @@ -276,13 +276,19 @@ public void pipInstallFromRequirements(final File envDir, String requirementsFil * @throws InterruptedException If the current thread is interrupted. * @throws IllegalStateException if uv has not been installed */ - public void sync(final File projectDir, String pythonVersion) throws IOException, InterruptedException { + public void sync(final File projectDir, String pythonVersion, List groups) throws IOException, InterruptedException { List args = new ArrayList<>(); args.add("sync"); if (pythonVersion != null && !pythonVersion.isEmpty()) { args.add("--python"); args.add(pythonVersion); } + if (groups != null) { + for (String group : groups) { + args.add("--group"); + args.add(group); + } + } // Run uv sync with working directory set to projectDir. exec(projectDir, args.toArray(new String[0])); diff --git a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java index 035b9ba..e449aa4 100644 --- a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java +++ b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java @@ -32,9 +32,18 @@ import org.apposed.appose.Appose; import org.apposed.appose.Environment; import org.apposed.appose.TestBase; +import org.apposed.appose.util.Json; import org.junit.jupiter.api.Test; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** End-to-end tests for {@link UvBuilder}. */ public class UvBuilderTest extends TestBase { @@ -71,4 +80,57 @@ public void testUvPyproject() throws Exception { .build(); cowsayAndAssert(env, "pyproject"); } + + @Test + public void testUvPyprojectWithGroup() throws Exception { + Environment env = Appose + .uv("src/test/resources/envs/cowsay-pyproject-groups.toml") + .group("cowsay") + .base("target/envs/uv-cowsay-groups") + .logDebug() + .build(); + cowsayAndAssert(env, "groups"); + } + + @Test + public void testUvGroupRejectsWithoutPyproject() { + assertThrows(IllegalArgumentException.class, () -> + Appose.uv() + .content("appose\n") + .group("cowsay") + .base("target/envs/uv-group-no-pyproject") + .build()); + } + + @Test + public void testUvStateNoGroupField() throws Exception { + Environment env = Appose + .uv("src/test/resources/envs/cowsay-pyproject.toml") + .base("target/envs/uv-state-no-groups") + .logDebug() + .build(); + 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); + @SuppressWarnings("unchecked") + java.util.Map state = (java.util.Map) Json.parseJson(json); + assertFalse(state.containsKey("groups"), "appose.json should not contain 'groups' when none specified"); + assertNotNull(state.get("packages"), "appose.json should contain 'packages'"); + } + + @Test + public void testUvStateGroupFieldPresent() throws Exception { + Environment env = Appose + .uv("src/test/resources/envs/cowsay-pyproject-groups.toml") + .group("cowsay") + .base("target/envs/uv-state-with-groups") + .logDebug() + .build(); + 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); + @SuppressWarnings("unchecked") + java.util.Map state = (java.util.Map) Json.parseJson(json); + assertTrue(state.containsKey("groups"), "appose.json should contain 'groups' when specified"); + } } diff --git a/src/test/resources/envs/cowsay-pyproject-groups.toml b/src/test/resources/envs/cowsay-pyproject-groups.toml new file mode 100644 index 0000000..4dc6b49 --- /dev/null +++ b/src/test/resources/envs/cowsay-pyproject-groups.toml @@ -0,0 +1,13 @@ +[project] +name = "cowsay-groups-test" +version = "0.1.0" +description = "Test project for cowsay with dependency groups" +requires-python = ">=3.10" +dependencies = [ + "appose>=0.1.0", +] + +[dependency-groups] +cowsay = [ + "cowsay>=6.0", +] From 87e008bf2c373e1166255af16e7b1e6273c93022 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 9 Oct 2026 00:29:02 -0500 Subject: [PATCH 2/5] Tidy up uv dependency group support Keep the two-argument Uv.sync for backward compatibility, and fold the appose.json state checks into the existing pyproject tests, to avoid building two extra environments. Co-Authored-By: Claude Opus 5.5 --- src/main/java/org/apposed/appose/tool/Uv.java | 16 +++++++ .../apposed/appose/builder/UvBuilderTest.java | 44 +++++++------------ 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/apposed/appose/tool/Uv.java b/src/main/java/org/apposed/appose/tool/Uv.java index 5b67704..19092bb 100644 --- a/src/main/java/org/apposed/appose/tool/Uv.java +++ b/src/main/java/org/apposed/appose/tool/Uv.java @@ -276,6 +276,22 @@ public void pipInstallFromRequirements(final File envDir, String requirementsFil * @throws InterruptedException If the current thread is interrupted. * @throws IllegalStateException if uv has not been installed */ + public void sync(final File projectDir, String pythonVersion) throws IOException, InterruptedException { + sync(projectDir, pythonVersion, null); + } + + /** + * 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. + * + * @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. + * @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) throws IOException, InterruptedException { List args = new ArrayList<>(); args.add("sync"); diff --git a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java index e449aa4..06418f2 100644 --- a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java +++ b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java @@ -38,10 +38,12 @@ 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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -79,6 +81,11 @@ public void testUvPyproject() throws Exception { .logDebug() .build(); cowsayAndAssert(env, "pyproject"); + + // No groups were requested, so none should be recorded. + Map state = readState(env); + assertFalse(state.containsKey("groups"), + "appose.json should not contain 'groups' when none specified"); } @Test @@ -90,6 +97,9 @@ public void testUvPyprojectWithGroup() throws Exception { .logDebug() .build(); cowsayAndAssert(env, "groups"); + + Map state = readState(env); + assertEquals(Arrays.asList("cowsay"), state.get("groups")); } @Test @@ -102,35 +112,11 @@ public void testUvGroupRejectsWithoutPyproject() { .build()); } - @Test - public void testUvStateNoGroupField() throws Exception { - Environment env = Appose - .uv("src/test/resources/envs/cowsay-pyproject.toml") - .base("target/envs/uv-state-no-groups") - .logDebug() - .build(); - 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); - @SuppressWarnings("unchecked") - java.util.Map state = (java.util.Map) Json.parseJson(json); - assertFalse(state.containsKey("groups"), "appose.json should not contain 'groups' when none specified"); - assertNotNull(state.get("packages"), "appose.json should contain 'packages'"); - } - - @Test - public void testUvStateGroupFieldPresent() throws Exception { - Environment env = Appose - .uv("src/test/resources/envs/cowsay-pyproject-groups.toml") - .group("cowsay") - .base("target/envs/uv-state-with-groups") - .logDebug() - .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); - @SuppressWarnings("unchecked") - java.util.Map state = (java.util.Map) Json.parseJson(json); - assertTrue(state.containsKey("groups"), "appose.json should contain 'groups' when specified"); + return (Map) Json.parseJson(json); } } From 8181d41876b2bbebb4717628ade028da7be934ab Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 9 Oct 2026 00:30:07 -0500 Subject: [PATCH 3/5] Recognize uv project environments when wrapping A pyproject.toml-based uv environment keeps its venv in .venv, with no pyvenv.cfg at the top level, so Appose.wrap fell back to SimpleBuilder. Co-Authored-By: Claude Opus 5.5 --- .../appose/builder/UvBuilderFactory.java | 6 ++++-- .../org/apposed/appose/builder/WrapTest.java | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apposed/appose/builder/UvBuilderFactory.java b/src/main/java/org/apposed/appose/builder/UvBuilderFactory.java index 63112f1..4e2dd99 100644 --- a/src/main/java/org/apposed/appose/builder/UvBuilderFactory.java +++ b/src/main/java/org/apposed/appose/builder/UvBuilderFactory.java @@ -68,8 +68,10 @@ public double priority() { public boolean canWrap(java.io.File envDir) { // Check for uv/venv environment markers. // uv creates standard Python venv, so look for pyvenv.cfg, - // but exclude conda and pixi environments. - boolean hasPyvenvCfg = new java.io.File(envDir, "pyvenv.cfg").isFile(); + // but exclude conda and pixi environments. For pyproject.toml + // projects, uv sync puts the venv in a .venv subdirectory. + boolean hasPyvenvCfg = new java.io.File(envDir, "pyvenv.cfg").isFile() || + new java.io.File(envDir, ".venv/pyvenv.cfg").isFile(); boolean isNotPixi = !new java.io.File(envDir, ".pixi").isDirectory() && !new java.io.File(envDir, "pixi.toml").isFile(); boolean isNotConda = !new java.io.File(envDir, "conda-meta").isDirectory(); diff --git a/src/test/java/org/apposed/appose/builder/WrapTest.java b/src/test/java/org/apposed/appose/builder/WrapTest.java index 0336350..c5e355d 100644 --- a/src/test/java/org/apposed/appose/builder/WrapTest.java +++ b/src/test/java/org/apposed/appose/builder/WrapTest.java @@ -121,6 +121,24 @@ public void testWrapUv() throws Exception { } } + /** Tests detecting a uv project environment, whose venv is in {@code .venv}. */ + @Test + public void testWrapUvProject() throws Exception { + File uvDir = new File("target/test-wrap-uv-project"); + File venvDir = new File(uvDir, ".venv"); + venvDir.mkdirs(); + File pyvenvCfg = new File(venvDir, "pyvenv.cfg"); + pyvenvCfg.createNewFile(); + + try { + assertEquals("uv", Builders.envType(uvDir)); + } finally { + pyvenvCfg.delete(); + venvDir.delete(); + uvDir.delete(); + } + } + /** Tests wrapping a plain directory (should fall back to SimpleBuilder). */ @Test public void testWrapCustom() throws Exception { From 8315d4c7db00e98d6a52a91c29c7e574780d8cad Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 9 Oct 2026 00:57:40 -0500 Subject: [PATCH 4/5] Wait for cowsay test workers to exit Service.close() only begins the worker's shutdown. A test that deletes the environment right afterward, e.g. to rebuild it, could then fail on Windows, where the still-running python.exe cannot be deleted. Co-Authored-By: Claude Opus 5.5 --- src/test/java/org/apposed/appose/TestBase.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/test/java/org/apposed/appose/TestBase.java b/src/test/java/org/apposed/appose/TestBase.java index c4cd6ff..f1dcf6d 100644 --- a/src/test/java/org/apposed/appose/TestBase.java +++ b/src/test/java/org/apposed/appose/TestBase.java @@ -37,6 +37,7 @@ import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -237,6 +238,10 @@ public void cowsayAndAssert(Environment env, String greeting, boolean checkVersi assertTrue(actual.contains("^__^"), "Output should contain cow face"); assertTrue(actual.contains("(oo)"), "Output should contain cow eyes"); assertTrue(actual.contains("||----w |"), "Output should contain cow legs"); + + // Note: Wait for the worker to exit, so that it no longer holds the + // environment's files open; on Windows, that would block deleting them. + service.close(30, TimeUnit.SECONDS); } finally { System.clearProperty(SKIP_VERSION_CHECK); From 3e6192f308cc22ba38f7a14ecfe2681ac3544f05 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Fri, 9 Oct 2026 00:30:27 -0500 Subject: [PATCH 5/5] Retain uv dependency groups when wrapping Groups are not recorded in pyproject.toml, so wrapping a group-enabled environment lost them: the environment then looked stale versus its appose.json, and was immediately re-synced without the groups, removing their packages. Restore them from appose.json instead. Co-Authored-By: Claude Opus 5.5 --- .../org/apposed/appose/builder/UvBuilder.java | 21 +++++++++++++++++++ .../apposed/appose/builder/UvBuilderTest.java | 13 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/main/java/org/apposed/appose/builder/UvBuilder.java b/src/main/java/org/apposed/appose/builder/UvBuilder.java index 0397dd5..0e47858 100644 --- a/src/main/java/org/apposed/appose/builder/UvBuilder.java +++ b/src/main/java/org/apposed/appose/builder/UvBuilder.java @@ -33,6 +33,7 @@ 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; @@ -263,6 +264,26 @@ public Environment wrap(File envDir) throws BuildException { // Read the content so rebuild() will work even after directory is deleted. content = new String(Files.readAllBytes(pyprojectToml.toPath()), StandardCharsets.UTF_8); scheme = Schemes.fromName("pyproject.toml"); + + // 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()); + } + } + } } else { // Fall back to requirements.txt. diff --git a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java index 06418f2..08d17ee 100644 --- a/src/test/java/org/apposed/appose/builder/UvBuilderTest.java +++ b/src/test/java/org/apposed/appose/builder/UvBuilderTest.java @@ -30,6 +30,7 @@ package org.apposed.appose.builder; import org.apposed.appose.Appose; +import org.apposed.appose.EnvStatus; import org.apposed.appose.Environment; import org.apposed.appose.TestBase; import org.apposed.appose.util.Json; @@ -100,6 +101,18 @@ public void testUvPyprojectWithGroup() throws Exception { Map state = readState(env); assertEquals(Arrays.asList("cowsay"), state.get("groups")); + + // Wrapping (e.g. after an application restart) must retain the groups, + // rather than treating the environment as stale and syncing without them. + Environment wrapped = Appose.wrap(new File(env.base())); + assertInstanceOf(UvBuilder.class, wrapped.builder()); + assertEquals(EnvStatus.CURRENT, wrapped.builder().status()); + cowsayAndAssert(wrapped, "wrapped"); + + // Rebuilding the wrapped environment must retain the groups too. + Environment rebuilt = wrapped.rebuild(); + cowsayAndAssert(rebuilt, "rebuilt"); + assertEquals(Arrays.asList("cowsay"), readState(rebuilt).get("groups")); } @Test