Skip to content

Commit 80b3c86

Browse files
authored
Treat all packages listed in requirements.txt as direct dependencies (#7320)
* Treat all packages listed in requirements.txt as direct dependencies Previously, `dependenciesFromResolved()` only treated graph-root packages (those not depended on by any other installed package) as direct. This caused packages like `aiohttp`, `cryptography`, `Jinja2` etc. to be classified as transitive when they were explicitly pinned in the requirements.txt but also happened to be dependencies of other listed packages. As a result, vulnerability fix recipes skipped upgrading them. Now, the parser scans the requirements.txt file content and passes the declared package names to `dependenciesFromResolved()`, ensuring every explicitly listed package is treated as a direct dependency regardless of the dependency graph. Fixes moderneinc/customer-requests#2157 * Add test for mixed declared/undeclared transitive dependencies Verifies that packages listed in requirements.txt are treated as direct while packages only present in the freeze output (true transitives) are excluded from the dependencies list. * Remove excessive comments * Reuse PyProjectHelper.extractPackageName for parsing declared packages
1 parent 4929215 commit 80b3c86

3 files changed

Lines changed: 80 additions & 4 deletions

File tree

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.openrewrite.ExecutionContext;
2020
import org.openrewrite.Parser;
2121
import org.openrewrite.SourceFile;
22+
import org.openrewrite.python.internal.PyProjectHelper;
2223
import org.openrewrite.python.marker.PythonResolutionResult;
2324
import org.openrewrite.python.marker.PythonResolutionResult.Dependency;
2425
import org.openrewrite.python.marker.PythonResolutionResult.PackageManager;
@@ -85,7 +86,8 @@ public Stream<SourceFile> parseInputs(Iterable<Input> sources, @Nullable Path re
8586
return sf;
8687
}
8788

88-
List<Dependency> deps = dependenciesFromResolved(resolvedDeps);
89+
List<Dependency> deps = dependenciesFromResolved(resolvedDeps,
90+
parseDeclaredPackageNames(text.getText()));
8991

9092
PythonResolutionResult marker = new PythonResolutionResult(
9193
randomId(),
@@ -142,7 +144,11 @@ static List<ResolvedDependency> parseFreezeLines(String freezeContent) {
142144
* are treated as direct so that client code traversing {@code getDependencies()} finds every package.
143145
*/
144146
public static List<Dependency> dependenciesFromResolved(List<ResolvedDependency> resolved) {
145-
// Collect all packages that appear as a transitive dependency of another package
147+
return dependenciesFromResolved(resolved, Collections.emptySet());
148+
}
149+
150+
public static List<Dependency> dependenciesFromResolved(List<ResolvedDependency> resolved,
151+
Set<String> declaredPackageNames) {
146152
Set<String> transitive = new HashSet<>();
147153
for (ResolvedDependency r : resolved) {
148154
if (r.getDependencies() != null) {
@@ -154,13 +160,31 @@ public static List<Dependency> dependenciesFromResolved(List<ResolvedDependency>
154160

155161
List<Dependency> deps = new ArrayList<>();
156162
for (ResolvedDependency r : resolved) {
157-
if (transitive.isEmpty() || !transitive.contains(PythonResolutionResult.normalizeName(r.getName()))) {
163+
String normalizedName = PythonResolutionResult.normalizeName(r.getName());
164+
if (transitive.isEmpty() ||
165+
!transitive.contains(normalizedName) ||
166+
declaredPackageNames.contains(normalizedName)) {
158167
deps.add(new Dependency(r.getName(), "==" + r.getVersion(), null, null, r));
159168
}
160169
}
161170
return deps;
162171
}
163172

173+
static Set<String> parseDeclaredPackageNames(String requirementsTxtContent) {
174+
Set<String> names = new HashSet<>();
175+
for (String line : requirementsTxtContent.split("\n")) {
176+
String trimmed = line.trim();
177+
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
178+
continue;
179+
}
180+
String name = PyProjectHelper.extractPackageName(trimmed);
181+
if (name != null) {
182+
names.add(PythonResolutionResult.normalizeName(name));
183+
}
184+
}
185+
return names;
186+
}
187+
164188
/**
165189
* Link transitive dependencies by reading installed package METADATA files from site-packages.
166190
* Uses a two-pass approach: first builds a name→entry map, then reads each package's

rewrite-python/src/main/java/org/openrewrite/python/internal/PyProjectHelper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public static String normalizeVersionConstraint(String version) {
6565
int end = 0;
6666
while (end < trimmed.length()) {
6767
char c = trimmed.charAt(end);
68-
if (c == '[' || c == '>' || c == '<' || c == '=' || c == '!' || c == '~' || c == ';' || c == ' ') {
68+
if (c == '[' || c == '>' || c == '<' || c == '=' || c == '!' || c == '~' || c == ';' || c == ' ' || c == '@') {
6969
break;
7070
}
7171
end++;

rewrite-python/src/test/java/org/openrewrite/python/RequirementsTxtParserTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.nio.file.Paths;
3434
import java.util.Collections;
3535
import java.util.List;
36+
import java.util.Set;
3637
import java.util.stream.Collectors;
3738

3839
import static org.assertj.core.api.Assertions.assertThat;
@@ -107,6 +108,57 @@ void dependenciesFromResolvedExcludesTransitives() {
107108
assertThat(deps.get(0).getResolved()).isSameAs(requests);
108109
}
109110

111+
@Test
112+
void dependenciesFromResolvedTreatsDeclaredPackagesAsDirect() {
113+
ResolvedDependency certifi = new ResolvedDependency("certifi", "2024.2.2", null, null);
114+
ResolvedDependency requests = new ResolvedDependency("requests", "2.31.0", null, List.of(certifi));
115+
116+
List<ResolvedDependency> resolved = List.of(certifi, requests);
117+
Set<String> declared = RequirementsTxtParser.parseDeclaredPackageNames(
118+
"certifi==2024.2.2\nrequests==2.31.0\n");
119+
List<Dependency> deps = RequirementsTxtParser.dependenciesFromResolved(resolved, declared);
120+
121+
assertThat(deps).hasSize(2);
122+
assertThat(deps.get(0).getName()).isEqualTo("certifi");
123+
assertThat(deps.get(1).getName()).isEqualTo("requests");
124+
}
125+
126+
@Test
127+
void declaredPackagesAreDirectAndUndeclaredTransitivesAreExcluded() {
128+
ResolvedDependency urllib3 = new ResolvedDependency("urllib3", "2.2.1", null, null);
129+
ResolvedDependency charsetNormalizer = new ResolvedDependency("charset-normalizer", "3.3.2", null, null);
130+
ResolvedDependency certifi = new ResolvedDependency("certifi", "2024.2.2", null, null);
131+
ResolvedDependency requests = new ResolvedDependency("requests", "2.31.0", null,
132+
List.of(certifi, urllib3, charsetNormalizer));
133+
134+
List<ResolvedDependency> resolved = List.of(urllib3, charsetNormalizer, certifi, requests);
135+
Set<String> declared = RequirementsTxtParser.parseDeclaredPackageNames(
136+
"requests==2.31.0\ncertifi==2024.2.2\n");
137+
List<Dependency> deps = RequirementsTxtParser.dependenciesFromResolved(resolved, declared);
138+
139+
assertThat(deps).hasSize(2);
140+
assertThat(deps.stream().map(Dependency::getName))
141+
.containsExactly("certifi", "requests");
142+
}
143+
144+
@Test
145+
void parseDeclaredPackageNamesExtractsNames() {
146+
Set<String> names = RequirementsTxtParser.parseDeclaredPackageNames("""
147+
# This is a comment
148+
requests>=2.28.0
149+
certifi==2024.2.2
150+
charset-normalizer<4,>=2
151+
Jinja2~=3.1.5
152+
-r other-requirements.txt
153+
aiohttp==3.13.3
154+
155+
langchain-core==1.2.12
156+
""");
157+
assertThat(names).containsExactlyInAnyOrder(
158+
"requests", "certifi", "charset_normalizer", "jinja2",
159+
"aiohttp", "langchain_core");
160+
}
161+
110162
@Test
111163
void linkDependenciesFromMetadataBuildsGraph(@TempDir Path tempDir) throws IOException {
112164
// Create a fake site-packages with METADATA files

0 commit comments

Comments
 (0)