Skip to content

Commit 9c6748e

Browse files
ctruedenclaude
andcommitted
Validate content scheme before tool installation; add BuilderContentTest
- UvBuilder: move scheme inference+validation before uv.install(), so passing environment.yml or pixi.toml content fails immediately rather than after downloading uv - PixiBuilder: add early scheme validation before pixi.install(), so passing requirements.txt content throws IllegalArgumentException instead of silently creating a broken environment - PixiBuilderFactory: add pyproject.toml to supportsScheme() so Appose.content(pyprojectToml) can delegate to PixiBuilder - BuilderContentTest: 20 tests covering all 4 builders × 5 content types (pyproject.toml, requirements.txt, environment.yml, pixi.toml, unrecognized); fast-fail tests throw before any tool installation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 693006c commit 9c6748e

4 files changed

Lines changed: 271 additions & 9 deletions

File tree

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ public Environment build() throws BuildException {
9797
throw new BuildException(this, "Cannot use PixiBuilder: environment already managed by uv/venv at " + envDir);
9898
}
9999

100+
// Validate content/scheme BEFORE installing any tools.
101+
if (content != null) {
102+
if (scheme == null) scheme = Schemes.fromContent(content);
103+
if (!"pixi.toml".equals(scheme.name()) && !"pyproject.toml".equals(scheme.name()) && !"environment.yml".equals(scheme.name())) {
104+
throw new IllegalArgumentException(
105+
"PixiBuilder only supports pixi.toml, pyproject.toml, and environment.yml schemes, got: " + scheme);
106+
}
107+
}
108+
100109
Pixi pixi = new Pixi();
101110

102111
// Set up progress/output consumers.
@@ -130,9 +139,6 @@ public Environment build() throws BuildException {
130139
return createEnvironment(pixi, envDir);
131140
}
132141

133-
// Infer scheme if not explicitly set.
134-
if (scheme == null) scheme = Schemes.fromContent(content);
135-
136142
if (!envDir.exists() && !envDir.mkdirs()) {
137143
throw new BuildException(this, "Failed to create environment directory: " + envDir);
138144
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public String envType() {
5252
public boolean supportsScheme(String scheme) {
5353
switch (scheme) {
5454
case "pixi.toml":
55+
case "pyproject.toml":
5556
case "environment.yml":
5657
case "conda":
5758
case "pypi":

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ public Environment build() throws BuildException {
119119
"'--index-url' or '--extra-index-url' directives.");
120120
}
121121

122+
// Validate content/scheme BEFORE installing any tools.
123+
if (content != null) {
124+
if (scheme == null) scheme = Schemes.fromContent(content);
125+
if (!"requirements.txt".equals(scheme.name()) && !"pyproject.toml".equals(scheme.name())) {
126+
throw new IllegalArgumentException(
127+
"UvBuilder only supports requirements.txt and pyproject.toml schemes, got: " + scheme);
128+
}
129+
}
130+
122131
try {
123132
uv.install();
124133

@@ -132,12 +141,6 @@ public Environment build() throws BuildException {
132141

133142
// Handle source-based build (file or content).
134143
if (content != null) {
135-
// Infer scheme if not explicitly set.
136-
if (scheme == null) scheme = Schemes.fromContent(content);
137-
138-
if (!"requirements.txt".equals(scheme.name()) && !"pyproject.toml".equals(scheme.name())) {
139-
throw new IllegalArgumentException("UvBuilder only supports requirements.txt and pyproject.toml schemes, got: " + scheme);
140-
}
141144

142145
if ("pyproject.toml".equals(scheme.name())) {
143146
// Handle pyproject.toml - uses uv sync.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/*-
2+
* #%L
3+
* Appose: multi-language interprocess cooperation with shared memory.
4+
* %%
5+
* Copyright (C) 2023 - 2026 Appose developers.
6+
* %%
7+
* Redistribution and use in source and binary forms, with or without
8+
* modification, are permitted provided that the following conditions are met:
9+
*
10+
* 1. Redistributions of source code must retain the above copyright notice,
11+
* this list of conditions and the following disclaimer.
12+
* 2. Redistributions in binary form must reproduce the above copyright notice,
13+
* this list of conditions and the following disclaimer in the documentation
14+
* and/or other materials provided with the distribution.
15+
*
16+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17+
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18+
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
19+
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
20+
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21+
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22+
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23+
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24+
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25+
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26+
* POSSIBILITY OF SUCH DAMAGE.
27+
* #L%
28+
*/
29+
30+
package org.apposed.appose.builder;
31+
32+
import org.apposed.appose.Appose;
33+
import org.apposed.appose.Environment;
34+
import org.apposed.appose.TestBase;
35+
import org.junit.jupiter.api.Test;
36+
37+
import java.io.IOException;
38+
import java.nio.charset.StandardCharsets;
39+
import java.nio.file.Files;
40+
import java.nio.file.Paths;
41+
42+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
43+
import static org.junit.jupiter.api.Assertions.assertThrows;
44+
45+
/**
46+
* Tests for the builder {@code .content()} API across all builder/scheme combinations.
47+
* <p>
48+
* Verifies that each builder:
49+
* <ul>
50+
* <li>correctly accepts its supported schemes and builds a working environment</li>
51+
* <li>fails fast (before any tool installation) with a clear error for unsupported schemes</li>
52+
* </ul>
53+
* Matrix: 4 builders × 5 content types = 20 tests:
54+
* <ul>
55+
* <li>uv: accepts requirements.txt, pyproject.toml; rejects environment.yml, pixi.toml, unrecognized</li>
56+
* <li>pixi: accepts pixi.toml, pyproject.toml, environment.yml; rejects requirements.txt, unrecognized</li>
57+
* <li>mamba: accepts environment.yml only; rejects all others</li>
58+
* <li>content (dynamic): auto-detects requirements.txt→uv, environment.yml→pixi, pixi.toml→pixi, pyproject.toml→pixi; rejects unrecognized</li>
59+
* </ul>
60+
*/
61+
public class BuilderContentTest extends TestBase {
62+
63+
// Minimal content stubs for "fail fast" tests — just enough to trigger correct
64+
// scheme detection, but deliberately incompatible with the builder under test.
65+
66+
private static final String PIXI_TOML_STUB =
67+
"[project]\n" +
68+
"name = \"stub\"\n" +
69+
"channels = [\"conda-forge\"]\n" +
70+
"platforms = [\"linux-64\"]\n" +
71+
"\n" +
72+
"[dependencies]\n" +
73+
"python = \"*\"\n";
74+
75+
private static final String ENV_YML_STUB =
76+
"name: stub-env\n" +
77+
"dependencies:\n" +
78+
" - python\n";
79+
80+
private static final String REQUIREMENTS_TXT_STUB = "appose\n";
81+
82+
private static final String PYPROJECT_TOML_STUB =
83+
"[project]\n" +
84+
"name = \"stub\"\n" +
85+
"version = \"0.1.0\"\n" +
86+
"dependencies = []\n";
87+
88+
// Must not match any scheme (does not start with a letter/digit or TOML/YAML markers).
89+
private static final String UNRECOGNIZED = "## not a valid config format\n";
90+
91+
private static String readResource(String path) throws IOException {
92+
return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8);
93+
}
94+
95+
// ======================== UvBuilder ========================
96+
97+
@Test
98+
public void uvWithRequirementsTxt() throws Exception {
99+
String content = readResource("src/test/resources/envs/cowsay-requirements.txt");
100+
Environment env = Appose.uv()
101+
.content(content).base("target/envs/content-uv-requirements").logDebug().build();
102+
assertInstanceOf(UvBuilder.class, env.builder());
103+
cowsayAndAssert(env, "uv-req");
104+
}
105+
106+
@Test
107+
public void uvWithPyprojectToml() throws Exception {
108+
String content = readResource("src/test/resources/envs/cowsay-pyproject.toml");
109+
Environment env = Appose.uv()
110+
.content(content).base("target/envs/content-uv-pyproject").logDebug().build();
111+
assertInstanceOf(UvBuilder.class, env.builder());
112+
cowsayAndAssert(env, "uv-pyproject");
113+
}
114+
115+
@Test
116+
public void uvWithEnvironmentYml() {
117+
assertThrows(IllegalArgumentException.class, () ->
118+
Appose.uv().content(ENV_YML_STUB).base("target/envs/content-uv-envyml").build());
119+
}
120+
121+
@Test
122+
public void uvWithPixiToml() {
123+
assertThrows(IllegalArgumentException.class, () ->
124+
Appose.uv().content(PIXI_TOML_STUB).base("target/envs/content-uv-pixi").build());
125+
}
126+
127+
@Test
128+
public void uvWithUnrecognized() {
129+
assertThrows(IllegalArgumentException.class, () ->
130+
Appose.uv().content(UNRECOGNIZED).base("target/envs/content-uv-unknown").build());
131+
}
132+
133+
// ======================== PixiBuilder ========================
134+
135+
@Test
136+
public void pixiWithPixiToml() throws Exception {
137+
String content = readResource("src/test/resources/envs/cowsay-pixi.toml");
138+
Environment env = Appose.pixi()
139+
.content(content).base("target/envs/content-pixi-pixi").logDebug().build();
140+
assertInstanceOf(PixiBuilder.class, env.builder());
141+
cowsayAndAssert(env, "pixi-pixi");
142+
}
143+
144+
@Test
145+
public void pixiWithPyprojectToml() throws Exception {
146+
String content = readResource("src/test/resources/envs/cowsay-pixi-pyproject.toml");
147+
Environment env = Appose.pixi()
148+
.content(content).base("target/envs/content-pixi-pyproject").logDebug().build();
149+
assertInstanceOf(PixiBuilder.class, env.builder());
150+
cowsayAndAssert(env, "pixi-pyproject");
151+
}
152+
153+
@Test
154+
public void pixiWithEnvironmentYml() throws Exception {
155+
String content = readResource("src/test/resources/envs/cowsay.yml");
156+
Environment env = Appose.pixi()
157+
.content(content).base("target/envs/content-pixi-envyml").logDebug().build();
158+
assertInstanceOf(PixiBuilder.class, env.builder());
159+
cowsayAndAssert(env, "pixi-envyml");
160+
}
161+
162+
@Test
163+
public void pixiWithRequirementsTxt() {
164+
assertThrows(IllegalArgumentException.class, () ->
165+
Appose.pixi().content(REQUIREMENTS_TXT_STUB).base("target/envs/content-pixi-requirements").build());
166+
}
167+
168+
@Test
169+
public void pixiWithUnrecognized() {
170+
assertThrows(IllegalArgumentException.class, () ->
171+
Appose.pixi().content(UNRECOGNIZED).base("target/envs/content-pixi-unknown").build());
172+
}
173+
174+
// ======================== MambaBuilder ========================
175+
176+
@Test
177+
public void mambaWithEnvironmentYml() throws Exception {
178+
String content = readResource("src/test/resources/envs/cowsay.yml");
179+
Environment env = Appose.mamba()
180+
.content(content).base("target/envs/content-mamba-envyml").logDebug().build();
181+
assertInstanceOf(MambaBuilder.class, env.builder());
182+
cowsayAndAssert(env, "mamba-envyml");
183+
}
184+
185+
@Test
186+
public void mambaWithPyprojectToml() {
187+
assertThrows(IllegalArgumentException.class, () ->
188+
Appose.mamba().content(PYPROJECT_TOML_STUB).base("target/envs/content-mamba-pyproject").build());
189+
}
190+
191+
@Test
192+
public void mambaWithRequirementsTxt() {
193+
assertThrows(IllegalArgumentException.class, () ->
194+
Appose.mamba().content(REQUIREMENTS_TXT_STUB).base("target/envs/content-mamba-requirements").build());
195+
}
196+
197+
@Test
198+
public void mambaWithPixiToml() {
199+
assertThrows(IllegalArgumentException.class, () ->
200+
Appose.mamba().content(PIXI_TOML_STUB).base("target/envs/content-mamba-pixi").build());
201+
}
202+
203+
@Test
204+
public void mambaWithUnrecognized() {
205+
assertThrows(IllegalArgumentException.class, () ->
206+
Appose.mamba().content(UNRECOGNIZED).base("target/envs/content-mamba-unknown").build());
207+
}
208+
209+
// ======================== DynamicBuilder (Appose.content) ========================
210+
211+
@Test
212+
public void contentWithRequirementsTxt() throws Exception {
213+
String content = readResource("src/test/resources/envs/cowsay-requirements.txt");
214+
Environment env = Appose.content(content)
215+
.base("target/envs/content-dynamic-requirements").logDebug().build();
216+
assertInstanceOf(UvBuilder.class, env.builder());
217+
cowsayAndAssert(env, "dynamic-req");
218+
}
219+
220+
@Test
221+
public void contentWithEnvironmentYml() throws Exception {
222+
String content = readResource("src/test/resources/envs/cowsay.yml");
223+
Environment env = Appose.content(content)
224+
.base("target/envs/content-dynamic-envyml").logDebug().build();
225+
assertInstanceOf(PixiBuilder.class, env.builder());
226+
cowsayAndAssert(env, "dynamic-envyml");
227+
}
228+
229+
@Test
230+
public void contentWithPixiToml() throws Exception {
231+
String content = readResource("src/test/resources/envs/cowsay-pixi.toml");
232+
Environment env = Appose.content(content)
233+
.base("target/envs/content-dynamic-pixi").logDebug().build();
234+
assertInstanceOf(PixiBuilder.class, env.builder());
235+
cowsayAndAssert(env, "dynamic-pixi");
236+
}
237+
238+
@Test
239+
public void contentWithPyprojectToml() throws Exception {
240+
String content = readResource("src/test/resources/envs/cowsay-pixi-pyproject.toml");
241+
Environment env = Appose.content(content)
242+
.base("target/envs/content-dynamic-pyproject").logDebug().build();
243+
assertInstanceOf(PixiBuilder.class, env.builder());
244+
cowsayAndAssert(env, "dynamic-pyproject");
245+
}
246+
247+
@Test
248+
public void contentWithUnrecognized() {
249+
assertThrows(IllegalArgumentException.class, () ->
250+
Appose.content(UNRECOGNIZED).base("target/envs/content-dynamic-unknown").build());
251+
}
252+
}

0 commit comments

Comments
 (0)