Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<envDir>/bin` (or `Scripts` on Windows)
- Location: `org.apposed.appose.builder.UvBuilder`
Expand Down Expand Up @@ -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()
Expand Down
43 changes: 42 additions & 1 deletion src/main/java/org/apposed/appose/builder/UvBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -56,6 +57,7 @@ public final class UvBuilder extends BaseBuilder<UvBuilder> {

private String pythonVersion;
private final List<String> packages = new ArrayList<>();
private final List<String> groups = new ArrayList<>();

// -- UvBuilder methods --

Expand All @@ -81,6 +83,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
Expand All @@ -93,6 +107,7 @@ protected void addStateFields(Map<String, Object> 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());
Expand Down Expand Up @@ -159,6 +174,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.
Expand Down Expand Up @@ -192,7 +213,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.
Expand Down Expand Up @@ -243,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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/org/apposed/appose/tool/Uv.java
Original file line number Diff line number Diff line change
Expand Up @@ -277,12 +277,34 @@ public void pipInstallFromRequirements(final File envDir, String requirementsFil
* @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<String> groups) throws IOException, InterruptedException {
List<String> 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]));
Expand Down
5 changes: 5 additions & 0 deletions src/test/java/org/apposed/appose/TestBase.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions src/test/java/org/apposed/appose/builder/UvBuilderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,23 @@
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;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/** End-to-end tests for {@link UvBuilder}. */
public class UvBuilderTest extends TestBase {
Expand Down Expand Up @@ -70,5 +82,54 @@ public void testUvPyproject() throws Exception {
.logDebug()
.build();
cowsayAndAssert(env, "pyproject");

// No groups were requested, so none should be recorded.
Map<String, Object> state = readState(env);
assertFalse(state.containsKey("groups"),
"appose.json should not contain 'groups' when none specified");
}

@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");

Map<String, Object> 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
public void testUvGroupRejectsWithoutPyproject() {
assertThrows(IllegalArgumentException.class, () ->
Appose.uv()
.content("appose\n")
.group("cowsay")
.base("target/envs/uv-group-no-pyproject")
.build());
}

@SuppressWarnings("unchecked")
private static Map<String, Object> 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<String, Object>) Json.parseJson(json);
}
}
18 changes: 18 additions & 0 deletions src/test/java/org/apposed/appose/builder/WrapTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions src/test/resources/envs/cowsay-pyproject-groups.toml
Original file line number Diff line number Diff line change
@@ -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",
]
Loading