From fe27cd648ef661d838fb5607c2d369c5ad618c79 Mon Sep 17 00:00:00 2001 From: Zexin Yuan Date: Tue, 2 Jun 2026 14:01:28 +0800 Subject: [PATCH] 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 39c81d9..99dd339 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,8 +180,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` @@ -230,6 +230,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 95306aa..1078de7 100644 --- a/src/main/java/org/apposed/appose/builder/UvBuilder.java +++ b/src/main/java/org/apposed/appose/builder/UvBuilder.java @@ -55,6 +55,7 @@ public final class UvBuilder extends BaseBuilder { private String pythonVersion; private final List packages = new ArrayList<>(); + private final List groups = new ArrayList<>(); // -- UvBuilder methods -- @@ -80,6 +81,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 @@ -92,6 +105,7 @@ protected void addStateFields(Map state) { super.addStateFields(state); state.put("pythonVersion", pythonVersion); state.put("packages", packages); + if (!groups.isEmpty()) state.put("groups", groups); } @Override @@ -136,6 +150,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. @@ -164,7 +184,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 9a325c6..023e076 100644 --- a/src/main/java/org/apposed/appose/tool/Uv.java +++ b/src/main/java/org/apposed/appose/tool/Uv.java @@ -262,13 +262,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 2d55c95..b02a846 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", +]