Skip to content

Commit 8401825

Browse files
ctruedenclaude
andcommitted
Track builder state to skip redundant rebuilds
Each builder now serializes its configuration (builder type, content, scheme, channels, flags, envVars, and builder-specific fields) to appose.json in the environment directory after a successful build. On subsequent build() calls, if the state matches the recorded JSON the build is skipped entirely. If the state differs, the environment is rebuilt from scratch (programmatic builds wipe the dir first to avoid pixi init / mamba create conflicts with stale state). - BaseBuilder: addStateFields / buildStateString / isUpToDate / writeApposeStateFile infrastructure - PixiBuilder: addStateFields (condaPackages, pypiPackages, pixiEnvironment); removed isPixiDir gate in favour of appose.json; split createEnvironment into runPixiInstall + buildPixiEnvironment so the fast path can return without running pixi install - MambaBuilder: removed isCondaDir gate; wipes env dir before mamba create on rebuild; falls back to wrap-as-is for externally-managed conda envs that have no environment.yml and no appose.json - UvBuilder: addStateFields (pythonVersion, packages); removed outer isVenvBuilt gate; inner isVenvBuilt check retained for createVenv Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f7fc9dc commit 8401825

File tree

5 files changed

+139
-56
lines changed

5 files changed

+139
-56
lines changed

src/main/java/org/apposed/appose/Builder.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
import java.nio.charset.StandardCharsets;
3939
import java.nio.file.Files;
4040
import java.nio.file.Path;
41-
import java.nio.file.Paths;
4241
import java.util.Arrays;
4342
import java.util.List;
4443
import java.util.Map;

src/main/java/org/apposed/appose/builder/BaseBuilder.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,20 @@
3535
import org.apposed.appose.Scheme;
3636
import org.apposed.appose.util.Environments;
3737
import org.apposed.appose.util.FilePaths;
38+
import org.apposed.appose.util.Json;
3839
import org.apposed.appose.scheme.Schemes;
3940

4041
import java.io.File;
4142
import java.io.IOException;
43+
import java.nio.charset.StandardCharsets;
44+
import java.nio.file.Files;
4245
import java.nio.file.Paths;
4346
import java.util.ArrayList;
4447
import java.util.HashMap;
48+
import java.util.LinkedHashMap;
4549
import java.util.List;
4650
import java.util.Map;
51+
import java.util.TreeMap;
4752
import java.util.function.Consumer;
4853

4954
/**
@@ -164,6 +169,49 @@ private T typedThis() {
164169
return (T) this;
165170
}
166171

172+
/**
173+
* Populates the given map with this builder's state fields for
174+
* {@code appose.json} comparison. Subclasses should override this method,
175+
* calling {@code super.addStateFields(state)} first, then adding their own fields.
176+
*
177+
* @param state The map to populate with state fields.
178+
*/
179+
protected void addStateFields(Map<String, Object> state) {
180+
state.put("content", content);
181+
state.put("scheme", scheme != null ? scheme.name() : null);
182+
state.put("channels", channels);
183+
state.put("flags", flags);
184+
state.put("envVars", new TreeMap<>(envVars));
185+
}
186+
187+
/**
188+
* Returns true if {@code appose.json} in the given directory matches
189+
* the current builder's state, meaning no rebuild is needed.
190+
*
191+
* @param envDir The environment directory to check.
192+
* @return True if up to date, false if a rebuild is needed.
193+
* @throws IOException If reading {@code appose.json} fails.
194+
*/
195+
protected boolean isUpToDate(File envDir) throws IOException {
196+
File apposeJson = new File(envDir, "appose.json");
197+
if (!apposeJson.isFile()) return false;
198+
String existing = new String(Files.readAllBytes(apposeJson.toPath()), StandardCharsets.UTF_8);
199+
return existing.equals(buildStateString());
200+
}
201+
202+
/**
203+
* Writes the current builder state to {@code appose.json} in the given directory.
204+
* This should be called after a successful build to record the state,
205+
* so that future calls can skip the build when the state is unchanged.
206+
*
207+
* @param envDir The environment directory.
208+
* @throws IOException If writing fails.
209+
*/
210+
protected void writeApposeStateFile(File envDir) throws IOException {
211+
File apposeJson = new File(envDir, "appose.json");
212+
Files.write(apposeJson.toPath(), buildStateString().getBytes(StandardCharsets.UTF_8));
213+
}
214+
167215
/** Determines the environment directory path. */
168216
protected File resolveEnvDir() {
169217
if (envDir != null) return envDir;
@@ -193,4 +241,17 @@ protected Environment createEnv(String base, List<String> binPaths, List<String>
193241
@Override public Builder<?> builder() { return BaseBuilder.this; }
194242
};
195243
}
244+
245+
/**
246+
* Builds a JSON string representing this builder's current configuration state.
247+
* Used to determine whether an existing environment needs to be rebuilt.
248+
*
249+
* @return JSON string of builder state.
250+
*/
251+
private final String buildStateString() {
252+
Map<String, Object> state = new LinkedHashMap<>();
253+
state.put("builder", envType());
254+
addStateFields(state);
255+
return Json.toJson(state);
256+
}
196257
}

src/main/java/org/apposed/appose/builder/MambaBuilder.java

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -69,27 +69,22 @@ public Environment build() throws BuildException {
6969
throw new BuildException(this, "Cannot use MambaBuilder: environment already managed by uv/venv at " + envDir);
7070
}
7171

72-
// Create Mamba tool instance early so it's available for wrapping.
73-
Mamba mamba = new Mamba();
72+
// Infer scheme from content if available.
73+
if (content != null && scheme == null) scheme = Schemes.fromContent(content);
7474

75-
// Is this envDir an already-existing conda directory?
76-
boolean isCondaDir = new File(envDir, "conda-meta").isDirectory();
77-
if (isCondaDir) {
78-
// Environment already exists, just wrap it.
79-
return createEnvironment(mamba, envDir);
75+
// Validate content and scheme when content is provided.
76+
if (content != null && !"environment.yml".equals(scheme.name())) {
77+
throw new IllegalArgumentException("MambaBuilder only supports environment.yml scheme, got: " + scheme);
8078
}
8179

82-
// Building a new environment - config content is required.
83-
if (content == null) {
84-
throw new IllegalStateException("No source specified for MambaBuilder. Use .file() or .content()");
80+
// Check for unsupported features.
81+
if (!channels.isEmpty()) {
82+
throw new UnsupportedOperationException(
83+
"MambaBuilder does not yet support programmatic channel configuration. " +
84+
"Please specify channels in your environment.yml file.");
8585
}
8686

87-
// Infer scheme if not explicitly set.
88-
if (scheme == null) scheme = Schemes.fromContent(content);
89-
90-
if (!"environment.yml".equals(scheme.name())) {
91-
throw new IllegalArgumentException("MambaBuilder only supports environment.yml scheme, got: " + scheme);
92-
}
87+
Mamba mamba = new Mamba();
9388

9489
// Set up progress/output consumers.
9590
mamba.setOutputConsumer(msg -> outputSubscribers.forEach(sub -> sub.accept(msg)));
@@ -102,16 +97,27 @@ public Environment build() throws BuildException {
10297
mamba.setEnvVars(envVars);
10398
mamba.setFlags(flags);
10499

105-
// Check for unsupported features.
106-
if (!channels.isEmpty()) {
107-
throw new UnsupportedOperationException(
108-
"MambaBuilder does not yet support programmatic channel configuration. " +
109-
"Please specify channels in your environment.yml file.");
110-
}
111-
112100
try {
101+
// If the env state matches our current configuration,
102+
// skip all package management and return immediately.
103+
if (isUpToDate(envDir)) {
104+
return createEnvironment(mamba, envDir);
105+
}
106+
107+
// If no content was provided but this is an existing externally-managed
108+
// conda env (no appose.json), wrap it as-is without rebuilding.
109+
if (content == null) {
110+
if (new File(envDir, "conda-meta").isDirectory()) {
111+
return createEnvironment(mamba, envDir);
112+
}
113+
throw new IllegalStateException("No source specified for MambaBuilder. Use .file() or .content()");
114+
}
115+
113116
mamba.install();
114117

118+
// Wipe any existing env to avoid conflicts with mamba create.
119+
if (envDir.exists()) FilePaths.deleteRecursively(envDir);
120+
115121
// Two-step build: create empty env, write config, then update.
116122
// Step 1: Create empty environment.
117123
mamba.create(envDir);
@@ -123,6 +129,7 @@ public Environment build() throws BuildException {
123129
// Step 3: Update environment from yml.
124130
mamba.update(envDir, envYaml);
125131

132+
writeApposeStateFile(envDir);
126133
return createEnvironment(mamba, envDir);
127134
}
128135
catch (IOException | InterruptedException e) {

src/main/java/org/apposed/appose/builder/PixiBuilder.java

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import java.util.Arrays;
4444
import java.util.Collections;
4545
import java.util.List;
46+
import java.util.Map;
4647

4748
/**
4849
* Type-safe builder for Pixi-based environments.
@@ -100,6 +101,14 @@ public String envType() {
100101
return "pixi";
101102
}
102103

104+
@Override
105+
protected void addStateFields(Map<String, Object> state) {
106+
super.addStateFields(state);
107+
state.put("condaPackages", condaPackages);
108+
state.put("pypiPackages", pypiPackages);
109+
state.put("pixiEnvironment", pixiEnvironment);
110+
}
111+
103112
@Override
104113
public Environment build() throws BuildException {
105114
File envDir = resolveEnvDir();
@@ -135,25 +144,17 @@ public Environment build() throws BuildException {
135144
pixi.setFlags(flags);
136145

137146
try {
147+
// Always ensure the pixi tool itself is available.
138148
pixi.install();
139149

140-
// Check if this is already a pixi project.
141-
boolean isPixiDir = new File(envDir, "pixi.toml").isFile() ||
142-
new File(envDir, "pyproject.toml").isFile() ||
143-
new File(envDir, ".pixi").isDirectory();
144-
145-
if (isPixiDir && content == null && condaPackages.isEmpty() && pypiPackages.isEmpty()) {
146-
// Environment already exists, just use it.
147-
return createEnvironment(pixi, envDir);
150+
// If the env state matches our current configuration,
151+
// skip all package management and return immediately.
152+
if (isUpToDate(envDir)) {
153+
return buildPixiEnvironment(pixi, envDir);
148154
}
149155

150-
// Handle source-based build (file or content).
156+
// Build (or rebuild) the environment.
151157
if (content != null) {
152-
if (isPixiDir) {
153-
// Already initialized, just use it.
154-
return createEnvironment(pixi, envDir);
155-
}
156-
157158
if (!envDir.exists() && !envDir.mkdirs()) {
158159
throw new BuildException(this, "Failed to create environment directory: " + envDir);
159160
}
@@ -181,12 +182,10 @@ else if ("environment.yml".equals(scheme.name())) {
181182
}
182183
} else {
183184
// Programmatic package building.
184-
if (isPixiDir) {
185-
// Already initialized, just use it.
186-
return createEnvironment(pixi, envDir);
187-
}
188-
189-
if (!envDir.exists() && !envDir.mkdirs()) {
185+
// Wipe any existing env before reinitializing, to avoid conflicts
186+
// with pixi init and to ensure no stale packages remain.
187+
if (envDir.exists()) FilePaths.deleteRecursively(envDir);
188+
if (!envDir.mkdirs()) {
190189
throw new BuildException(this, "Failed to create environment directory: " + envDir);
191190
}
192191

@@ -230,7 +229,9 @@ else if ("environment.yml".equals(scheme.name())) {
230229
}
231230
}
232231

233-
return createEnvironment(pixi, envDir);
232+
runPixiInstall(pixi, envDir);
233+
writeApposeStateFile(envDir);
234+
return buildPixiEnvironment(pixi, envDir);
234235
}
235236
catch (IOException | InterruptedException e) {
236237
throw new BuildException(this, e);
@@ -276,8 +277,7 @@ private static List<String> withFlag(List<String> flags, String flag) {
276277
return result;
277278
}
278279

279-
private Environment createEnvironment(Pixi pixi, File envDir) throws IOException, InterruptedException {
280-
// Check which manifest file exists (pyproject.toml takes precedence).
280+
private void runPixiInstall(Pixi pixi, File envDir) throws IOException, InterruptedException {
281281
File manifestFile = new File(envDir, "pyproject.toml");
282282
if (!manifestFile.exists()) manifestFile = new File(envDir, "pixi.toml");
283283

@@ -315,6 +315,11 @@ private Environment createEnvironment(Pixi pixi, File envDir) throws IOException
315315
pixi.setFlags(flags);
316316
}
317317
}
318+
}
319+
320+
private Environment buildPixiEnvironment(Pixi pixi, File envDir) {
321+
File manifestFile = new File(envDir, "pyproject.toml");
322+
if (!manifestFile.exists()) manifestFile = new File(envDir, "pixi.toml");
318323

319324
String base = envDir.getAbsolutePath();
320325
String envName = pixiEnvironment != null ? pixiEnvironment : "default";

src/main/java/org/apposed/appose/builder/UvBuilder.java

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import java.util.Arrays;
4545
import java.util.Collections;
4646
import java.util.List;
47+
import java.util.Map;
4748

4849
/**
4950
* Type-safe builder for uv-based virtual environments.
@@ -86,6 +87,13 @@ public String envType() {
8687
return "uv";
8788
}
8889

90+
@Override
91+
protected void addStateFields(Map<String, Object> state) {
92+
super.addStateFields(state);
93+
state.put("pythonVersion", pythonVersion);
94+
state.put("packages", packages);
95+
}
96+
8997
@Override
9098
public Environment build() throws BuildException {
9199
File envDir = resolveEnvDir();
@@ -129,16 +137,18 @@ public Environment build() throws BuildException {
129137
}
130138

131139
try {
132-
uv.install();
133-
134-
// Check if this is already a uv virtual environment.
135-
boolean isUvVenv = new File(envDir, "pyvenv.cfg").isFile();
136-
137-
if (isUvVenv && content == null && packages.isEmpty()) {
138-
// Environment already exists and no new config/packages, just use it.
140+
// If the env state matches our current configuration,
141+
// skip all package management and return immediately.
142+
if (isUpToDate(envDir)) {
139143
return createEnvironment(envDir);
140144
}
141145

146+
uv.install();
147+
148+
// Determine whether the venv already exists.
149+
boolean isVenvBuilt = new File(envDir, "pyvenv.cfg").isFile() ||
150+
new File(envDir, ".venv").isDirectory();
151+
142152
// Handle source-based build (file or content).
143153
if (content != null) {
144154

@@ -158,7 +168,7 @@ public Environment build() throws BuildException {
158168
} else {
159169
// Handle requirements.txt - traditional venv + pip install.
160170
// Create virtual environment if it doesn't exist.
161-
if (!isUvVenv) {
171+
if (!isVenvBuilt) {
162172
uv.createVenv(envDir, pythonVersion);
163173
}
164174

@@ -171,7 +181,7 @@ public Environment build() throws BuildException {
171181
}
172182
} else {
173183
// Programmatic package building.
174-
if (!isUvVenv) {
184+
if (!isVenvBuilt) {
175185
// Create virtual environment.
176186
uv.createVenv(envDir, pythonVersion);
177187
}
@@ -187,6 +197,7 @@ public Environment build() throws BuildException {
187197
}
188198
}
189199

200+
writeApposeStateFile(envDir);
190201
return createEnvironment(envDir);
191202
}
192203
catch (IOException | InterruptedException e) {

0 commit comments

Comments
 (0)