Skip to content

Commit 878b52a

Browse files
Python: Support legacy project manifests (setup.cfg etc.) (#6716)
* Add requirements.txt parser with dependency resolution and insight support Parse requirements.txt files into PlainText with a PythonResolutionResult marker containing declared dependencies and optionally resolved versions via uv pip freeze. Extend DependencyInsight recipe to search requirements.txt in addition to pyproject.toml. Include manifest files (pyproject.toml or requirements.txt) in ParseProject results from the Java side. * Add setup.cfg and setup.py parser support with dependency resolution Add SetupCfgParser (PlainText-based) for setup.cfg files and setup.py marker attachment in PythonRewriteRpc. Both use uv pip install + freeze for dependency resolution, with all resolved packages treated as dependencies since pip freeze doesn't distinguish direct from transitive. - SetupCfgParser: accepts setup.cfg, delegates to PlainTextParser, resolves via DependencyWorkspace.getOrCreateSetuptoolsWorkspace() - PythonRewriteRpc: setup.cfg added to parseManifest() priority chain (pyproject.toml > setup.cfg > requirements.txt); setup.py-only projects get marker attached to Py.CompilationUnit in RPC stream - DependencyInsight: extended for setup.cfg (PlainText) and setup.py (Py.CompilationUnit with marker) - Assertions: setupCfg() test helpers added - RequirementsTxtParser: simplified to derive dependencies from freeze output using shared dependenciesFromResolved() method - DependencyWorkspace: added getOrCreateSetuptoolsWorkspace() using uv pip install <projectDir> * Link transitive dependency graph from installed package METADATA Read Requires-Dist entries from .dist-info/METADATA files in site-packages to build the dependency graph. This allows distinguishing direct from transitive dependencies: only root packages (those not depended on by any other installed package) are included in the declared dependencies list. * Check for PythonResolutionResult marker in isAcceptable for all file types * Add missing constraintDependencies and overrideDependencies params to PythonResolutionResult constructors * Fix review items: Windows site-packages, dist-info version matching, extra marker regex, SetupCfgParser test coverage
1 parent 3e337ad commit 878b52a

11 files changed

Lines changed: 1361 additions & 52 deletions

File tree

rewrite-python/src/integTest/java/org/openrewrite/python/ParseProjectIntegTest.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.openrewrite.SourceFile;
2525
import org.openrewrite.python.marker.PythonResolutionResult;
2626
import org.openrewrite.python.rpc.PythonRewriteRpc;
27+
import org.openrewrite.text.PlainText;
2728
import org.openrewrite.toml.tree.Toml;
2829

2930
import java.io.IOException;
@@ -196,6 +197,63 @@ void includesPyprojectToml() throws IOException {
196197
assertThat(pyproject.getMarkers().findFirst(PythonResolutionResult.class)).isPresent();
197198
}
198199

200+
@Test
201+
@Timeout(value = 60, unit = TimeUnit.SECONDS)
202+
void includesRequirementsTxt() throws IOException {
203+
Path projectDir = tempDir.resolve("with_requirements");
204+
Files.createDirectories(projectDir);
205+
206+
Files.writeString(projectDir.resolve("main.py"), "x = 1");
207+
Files.writeString(projectDir.resolve("requirements.txt"), """
208+
requests>=2.28.0
209+
click>=8.0
210+
""");
211+
212+
List<SourceFile> sources = client()
213+
.parseProject(projectDir, new InMemoryExecutionContext())
214+
.collect(Collectors.toList());
215+
216+
assertThat(sources)
217+
.extracting(sf -> sf.getSourcePath().getFileName().toString())
218+
.contains("main.py", "requirements.txt");
219+
220+
SourceFile reqsTxt = sources.stream()
221+
.filter(sf -> sf.getSourcePath().getFileName().toString().equals("requirements.txt"))
222+
.findFirst()
223+
.orElseThrow();
224+
assertThat(reqsTxt).isInstanceOf(PlainText.class);
225+
assertThat(reqsTxt.getMarkers().findFirst(PythonResolutionResult.class)).isPresent();
226+
}
227+
228+
@Test
229+
@Timeout(value = 60, unit = TimeUnit.SECONDS)
230+
void pyprojectTomlTakesPriorityOverRequirementsTxt() throws IOException {
231+
Path projectDir = tempDir.resolve("both_manifests");
232+
Files.createDirectories(projectDir);
233+
234+
Files.writeString(projectDir.resolve("main.py"), "x = 1");
235+
Files.writeString(projectDir.resolve("pyproject.toml"), """
236+
[project]
237+
name = "myapp"
238+
version = "1.0.0"
239+
dependencies = ["requests>=2.28.0"]
240+
""");
241+
Files.writeString(projectDir.resolve("requirements.txt"), """
242+
requests>=2.28.0
243+
""");
244+
245+
List<SourceFile> sources = client()
246+
.parseProject(projectDir, new InMemoryExecutionContext())
247+
.collect(Collectors.toList());
248+
249+
// pyproject.toml should be included but not requirements.txt
250+
assertThat(sources)
251+
.extracting(sf -> sf.getSourcePath().getFileName().toString())
252+
.contains("pyproject.toml")
253+
.doesNotContain("requirements.txt");
254+
}
255+
256+
199257
private PythonRewriteRpc client() {
200258
return PythonRewriteRpc.getOrStart();
201259
}

rewrite-python/src/main/java/org/openrewrite/python/Assertions.java

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.openrewrite.python.tree.Py;
2121
import org.openrewrite.test.SourceSpec;
2222
import org.openrewrite.test.SourceSpecs;
23+
import org.openrewrite.text.PlainText;
2324
import org.openrewrite.toml.tree.Toml;
2425

2526
import java.io.IOException;
@@ -145,6 +146,84 @@ public static SourceSpecs uvLock(@Language("toml") @Nullable String before,
145146
return toml;
146147
}
147148

149+
public static SourceSpecs requirementsTxt(@Nullable String before) {
150+
return requirementsTxt(before, s -> {
151+
});
152+
}
153+
154+
public static SourceSpecs requirementsTxt(@Nullable String before,
155+
Consumer<SourceSpec<PlainText>> spec) {
156+
SourceSpec<PlainText> text = new SourceSpec<>(
157+
PlainText.class, null, RequirementsTxtParser.builder(), before,
158+
SourceSpec.ValidateSource.noop,
159+
ctx -> {
160+
}
161+
);
162+
text.path("requirements.txt");
163+
spec.accept(text);
164+
return text;
165+
}
166+
167+
public static SourceSpecs requirementsTxt(@Nullable String before,
168+
@Nullable String after) {
169+
return requirementsTxt(before, after, s -> {
170+
});
171+
}
172+
173+
public static SourceSpecs requirementsTxt(@Nullable String before,
174+
@Nullable String after,
175+
Consumer<SourceSpec<PlainText>> spec) {
176+
SourceSpec<PlainText> text = new SourceSpec<>(
177+
PlainText.class, null, RequirementsTxtParser.builder(), before,
178+
SourceSpec.ValidateSource.noop,
179+
ctx -> {
180+
}
181+
);
182+
text.path("requirements.txt");
183+
text.after(s -> after);
184+
spec.accept(text);
185+
return text;
186+
}
187+
188+
public static SourceSpecs setupCfg(@Nullable String before) {
189+
return setupCfg(before, s -> {
190+
});
191+
}
192+
193+
public static SourceSpecs setupCfg(@Nullable String before,
194+
Consumer<SourceSpec<PlainText>> spec) {
195+
SourceSpec<PlainText> text = new SourceSpec<>(
196+
PlainText.class, null, SetupCfgParser.builder(), before,
197+
SourceSpec.ValidateSource.noop,
198+
ctx -> {
199+
}
200+
);
201+
text.path("setup.cfg");
202+
spec.accept(text);
203+
return text;
204+
}
205+
206+
public static SourceSpecs setupCfg(@Nullable String before,
207+
@Nullable String after) {
208+
return setupCfg(before, after, s -> {
209+
});
210+
}
211+
212+
public static SourceSpecs setupCfg(@Nullable String before,
213+
@Nullable String after,
214+
Consumer<SourceSpec<PlainText>> spec) {
215+
SourceSpec<PlainText> text = new SourceSpec<>(
216+
PlainText.class, null, SetupCfgParser.builder(), before,
217+
SourceSpec.ValidateSource.noop,
218+
ctx -> {
219+
}
220+
);
221+
text.path("setup.cfg");
222+
text.after(s -> after);
223+
spec.accept(text);
224+
return text;
225+
}
226+
148227
public static SourceSpecs python(@Language("py") @Nullable String before) {
149228
return python(before, s -> {
150229
});

rewrite-python/src/main/java/org/openrewrite/python/DependencyWorkspace.java

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package org.openrewrite.python;
1717

1818
import lombok.experimental.UtilityClass;
19+
import org.jspecify.annotations.Nullable;
1920
import org.openrewrite.python.internal.UvExecutor;
2021

2122
import java.io.IOException;
@@ -39,7 +40,7 @@
3940
* to enable proper type attribution via ty LSP.
4041
*/
4142
@UtilityClass
42-
class DependencyWorkspace {
43+
public class DependencyWorkspace {
4344
private static final Path WORKSPACE_BASE = Paths.get(
4445
System.getProperty("java.io.tmpdir"),
4546
"openrewrite-python-workspaces"
@@ -132,6 +133,197 @@ static Path getOrCreateWorkspace(String pyprojectContent) {
132133
}
133134
}
134135

136+
/**
137+
* Gets or creates a workspace directory for a requirements.txt file.
138+
* Returns null (graceful degradation) when uv is unavailable.
139+
*
140+
* @param requirementsContent The complete requirements.txt content
141+
* @param originalFilePath The original file path on disk (supports -r includes), or null
142+
* @return Path to the workspace directory, or null if uv is unavailable
143+
*/
144+
static @Nullable Path getOrCreateRequirementsWorkspace(String requirementsContent,
145+
@Nullable Path originalFilePath) {
146+
String uvPath = UvExecutor.findUvExecutable();
147+
if (uvPath == null) {
148+
return null;
149+
}
150+
151+
String hash = "req-" + hashContent(requirementsContent);
152+
153+
// Check in-memory cache
154+
Path cached = cache.get(hash);
155+
if (cached != null && isRequirementsWorkspaceValid(cached)) {
156+
return cached;
157+
}
158+
159+
// Check disk cache
160+
Path workspaceDir = WORKSPACE_BASE.resolve(hash);
161+
if (isRequirementsWorkspaceValid(workspaceDir)) {
162+
cache.put(hash, workspaceDir);
163+
return workspaceDir;
164+
}
165+
166+
// Create new workspace
167+
try {
168+
Files.createDirectories(WORKSPACE_BASE);
169+
170+
Path tempDir = Files.createTempDirectory(WORKSPACE_BASE, hash + ".tmp-");
171+
172+
try {
173+
// Create virtualenv
174+
runCommandWithPath(tempDir, uvPath, "venv");
175+
176+
// Install dependencies from requirements file
177+
Path reqFile;
178+
if (originalFilePath != null && Files.exists(originalFilePath)) {
179+
reqFile = originalFilePath;
180+
} else {
181+
reqFile = tempDir.resolve("requirements.txt");
182+
Files.write(reqFile, requirementsContent.getBytes(StandardCharsets.UTF_8));
183+
}
184+
runCommandWithPath(tempDir, uvPath, "pip", "install", "-r", reqFile.toString());
185+
186+
// Capture freeze output BEFORE installing ty
187+
UvExecutor.RunResult freezeResult = UvExecutor.run(tempDir, uvPath, "pip", "freeze");
188+
if (freezeResult.isSuccess()) {
189+
Files.write(
190+
tempDir.resolve("freeze.txt"),
191+
freezeResult.getStdout().getBytes(StandardCharsets.UTF_8)
192+
);
193+
}
194+
195+
// Install ty for type stubs (after freeze so it's not in the dep model)
196+
runCommandWithPath(tempDir, uvPath, "pip", "install", "ty");
197+
198+
// Move to final location
199+
try {
200+
Files.move(tempDir, workspaceDir);
201+
} catch (IOException e) {
202+
if (isRequirementsWorkspaceValid(workspaceDir)) {
203+
cleanupDirectory(tempDir);
204+
} else {
205+
throw e;
206+
}
207+
}
208+
209+
cache.put(hash, workspaceDir);
210+
return workspaceDir;
211+
212+
} catch (Exception e) {
213+
cleanupDirectory(tempDir);
214+
return null;
215+
}
216+
217+
} catch (IOException e) {
218+
return null;
219+
}
220+
}
221+
222+
/**
223+
* Gets or creates a workspace directory for a setuptools project (setup.cfg / setup.py).
224+
* Uses {@code uv pip install <projectDir>} to install the project and its dependencies.
225+
* Returns null (graceful degradation) when uv is unavailable.
226+
*
227+
* @param manifestContent The setup.cfg (or setup.py) content for hashing
228+
* @param projectDir The project directory to install from, or null
229+
* @return Path to the workspace directory, or null if uv is unavailable
230+
*/
231+
public static @Nullable Path getOrCreateSetuptoolsWorkspace(String manifestContent,
232+
@Nullable Path projectDir) {
233+
String uvPath = UvExecutor.findUvExecutable();
234+
if (uvPath == null) {
235+
return null;
236+
}
237+
238+
String hash = "setup-" + hashContent(manifestContent);
239+
240+
// Check in-memory cache
241+
Path cached = cache.get(hash);
242+
if (cached != null && isRequirementsWorkspaceValid(cached)) {
243+
return cached;
244+
}
245+
246+
// Check disk cache
247+
Path workspaceDir = WORKSPACE_BASE.resolve(hash);
248+
if (isRequirementsWorkspaceValid(workspaceDir)) {
249+
cache.put(hash, workspaceDir);
250+
return workspaceDir;
251+
}
252+
253+
if (projectDir == null || !Files.isDirectory(projectDir)) {
254+
return null;
255+
}
256+
257+
// Create new workspace
258+
try {
259+
Files.createDirectories(WORKSPACE_BASE);
260+
261+
Path tempDir = Files.createTempDirectory(WORKSPACE_BASE, hash + ".tmp-");
262+
263+
try {
264+
// Create virtualenv
265+
runCommandWithPath(tempDir, uvPath, "venv");
266+
267+
// Install from the project directory
268+
runCommandWithPath(tempDir, uvPath, "pip", "install", projectDir.toString());
269+
270+
// Capture freeze output BEFORE installing ty
271+
UvExecutor.RunResult freezeResult = UvExecutor.run(tempDir, uvPath, "pip", "freeze");
272+
if (freezeResult.isSuccess()) {
273+
Files.write(
274+
tempDir.resolve("freeze.txt"),
275+
freezeResult.getStdout().getBytes(StandardCharsets.UTF_8)
276+
);
277+
}
278+
279+
// Install ty for type stubs (after freeze so it's not in the dep model)
280+
runCommandWithPath(tempDir, uvPath, "pip", "install", "ty");
281+
282+
// Move to final location
283+
try {
284+
Files.move(tempDir, workspaceDir);
285+
} catch (IOException e) {
286+
if (isRequirementsWorkspaceValid(workspaceDir)) {
287+
cleanupDirectory(tempDir);
288+
} else {
289+
throw e;
290+
}
291+
}
292+
293+
cache.put(hash, workspaceDir);
294+
return workspaceDir;
295+
296+
} catch (Exception e) {
297+
cleanupDirectory(tempDir);
298+
return null;
299+
}
300+
301+
} catch (IOException e) {
302+
return null;
303+
}
304+
}
305+
306+
static String readFreezeOutput(Path workspaceDir) {
307+
try {
308+
return new String(Files.readAllBytes(workspaceDir.resolve("freeze.txt")), StandardCharsets.UTF_8);
309+
} catch (IOException e) {
310+
return "";
311+
}
312+
}
313+
314+
private static boolean isRequirementsWorkspaceValid(Path workspaceDir) {
315+
return Files.exists(workspaceDir) &&
316+
Files.isDirectory(workspaceDir.resolve(".venv")) &&
317+
Files.exists(workspaceDir.resolve("freeze.txt"));
318+
}
319+
320+
private static void runCommandWithPath(Path dir, String uvPath, String... args) throws IOException, InterruptedException {
321+
UvExecutor.RunResult result = UvExecutor.run(dir, uvPath, args);
322+
if (!result.isSuccess()) {
323+
throw new RuntimeException("uv " + String.join(" ", args) + " failed with exit code: " + result.getExitCode());
324+
}
325+
}
326+
135327
private static void runCommand(Path dir, String... command) throws IOException, InterruptedException {
136328
String uvPath = UvExecutor.findUvExecutable();
137329
if (uvPath == null) {
@@ -200,7 +392,13 @@ private static void initializeCacheFromDisk() {
200392
Files.list(WORKSPACE_BASE)
201393
.filter(Files::isDirectory)
202394
.filter(dir -> !dir.getFileName().toString().contains(".tmp-"))
203-
.filter(DependencyWorkspace::isWorkspaceValid)
395+
.filter(dir -> {
396+
String name = dir.getFileName().toString();
397+
if (name.startsWith("req-") || name.startsWith("setup-")) {
398+
return isRequirementsWorkspaceValid(dir);
399+
}
400+
return isWorkspaceValid(dir);
401+
})
204402
.sorted((a, b) -> {
205403
try {
206404
return Files.getLastModifiedTime(a).compareTo(Files.getLastModifiedTime(b));

0 commit comments

Comments
 (0)