Skip to content

Commit e84d176

Browse files
a-orenclaude
andauthored
fix: handle ~= and != version operators in Python dependency name parsing (guacsec#447)
## Summary - Fix `getDependencyName()` to recognize `~` and `!` as PEP 508 version operator characters, preventing them from being included in the package name (e.g. `urllib3~=1.26.0` was parsed as package `urllib3~` instead of `urllib3`) - Replace the fragile three-index `getFirstSign()` approach with a simple loop over all PEP 508 operator characters (`>`, `<`, `=`, `~`, `!`) - Add unit tests for compatibility (`~=`) and exclusion (`!=`) operators, including combined extras + special operators Implements [TC-4041](https://redhat.atlassian.net/browse/TC-4041) ## Test plan - [x] All 358 existing unit tests pass - [x] New unit tests verify `~=` and `!=` parsing - [x] Manual CLI test with example `requirements.txt` from the ticket produces correct SBOM 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by Sourcery Fix Python requirement parsing to correctly extract dependency names when PEP 508 version operators are present, and extend tests to cover these cases. Bug Fixes: - Correct dependency name extraction from Python requirement strings that include PEP 508 operators such as ~=, !=, >=, <=, >, and < so operator characters are not treated as part of the package name. Enhancements: - Simplify version operator detection in Python requirement parsing by scanning for any PEP 508 operator character instead of relying on multiple index lookups. Tests: - Add unit tests covering compatibility (~=) and exclusion (!=) operators, including cases combining extras with special version operators. [TC-4041]: https://redhat.atlassian.net/browse/TC-4041?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b2119d6 commit e84d176

4 files changed

Lines changed: 70 additions & 18 deletions

File tree

src/main/java/io/github/guacsec/trustifyda/providers/PythonPipProvider.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ private static String extractDepFull(String requirementLine) {
210210
}
211211

212212
private String[] splitToNameVersion(String nameVersion) {
213+
// Strip PEP 508 environment markers (everything after ";")
214+
int markerIndex = nameVersion.indexOf(';');
215+
if (markerIndex != -1) {
216+
nameVersion = nameVersion.substring(0, markerIndex).trim();
217+
}
213218
String[] result;
214219
if (nameVersion.matches(
215220
"[a-zA-Z0-9-_()]+={2}[0-9]{1,4}[.][0-9]{1,4}(([.][0-9]{1,4})|([.][a-zA-Z0-9]+)|([a-zA-Z0-9]+)|([.][a-zA-Z0-9]+[.][a-z-A-Z0-9]+))?")) {

src/main/java/io/github/guacsec/trustifyda/utils/PythonControllerBase.java

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -388,31 +388,24 @@ public static String getDependencyName(String dep) {
388388
requirement = requirement.substring(0, extrasStart) + requirement.substring(extrasEnd + 1);
389389
}
390390
}
391-
int rightTriangleBracket = requirement.indexOf(">");
392-
int leftTriangleBracket = requirement.indexOf("<");
393-
int equalsSign = requirement.indexOf("=");
394-
int minimumIndex = getFirstSign(rightTriangleBracket, leftTriangleBracket, equalsSign);
391+
// Find the first PEP 508 version operator character (~=, !=, ==, >=, <=, >, <, ===)
392+
int firstOperatorChar = -1;
393+
for (int i = 0; i < requirement.length(); i++) {
394+
char c = requirement.charAt(i);
395+
if (c == '>' || c == '<' || c == '=' || c == '~' || c == '!') {
396+
firstOperatorChar = i;
397+
break;
398+
}
399+
}
395400
String depName;
396-
if (rightTriangleBracket == -1 && leftTriangleBracket == -1 && equalsSign == -1) {
401+
if (firstOperatorChar == -1) {
397402
depName = requirement;
398403
} else {
399-
depName = requirement.substring(0, minimumIndex);
404+
depName = requirement.substring(0, firstOperatorChar);
400405
}
401406
return depName.trim();
402407
}
403408

404-
private static int getFirstSign(
405-
int rightTriangleBracket, int leftTriangleBracket, int equalsSign) {
406-
rightTriangleBracket = rightTriangleBracket == -1 ? 999 : rightTriangleBracket;
407-
leftTriangleBracket = leftTriangleBracket == -1 ? 999 : leftTriangleBracket;
408-
equalsSign = equalsSign == -1 ? 999 : equalsSign;
409-
return equalsSign < leftTriangleBracket && equalsSign < rightTriangleBracket
410-
? equalsSign
411-
: (leftTriangleBracket < equalsSign && leftTriangleBracket < rightTriangleBracket
412-
? leftTriangleBracket
413-
: rightTriangleBracket);
414-
}
415-
416409
static List<String> splitPipShowLines(String pipShowOutput) {
417410
return Arrays.stream(
418411
pipShowOutput.split(System.lineSeparator() + "---" + System.lineSeparator()))

src/test/java/io/github/guacsec/trustifyda/providers/Python_Provider_Test.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,39 @@ void Test_The_ProvideComponent_Path_Should_Throw_Exception() {
267267
.isThrownBy(() -> new PythonPipProvider(Path.of(".")).provideComponent());
268268
}
269269

270+
@Test
271+
void getIgnoredDependencies_strips_environment_markers() throws IOException {
272+
Path tempFile = Files.createTempFile("requirements", ".txt");
273+
try {
274+
Files.writeString(
275+
tempFile,
276+
String.join(
277+
System.lineSeparator(),
278+
"requests==2.25.1 ; python_version >= \"3.6\" #trustify-da-ignore",
279+
"idna==2.10 ; python_version >= \"3.6\" # trustify-da-ignore",
280+
"six==1.16.0 ; python_version < \"3.0\" or python_version >= \"3.3\""
281+
+ " #trustify-da-ignore",
282+
"chardet==4.0.0 ; python_version >= \"3.6\" and sys_platform == \"linux\""
283+
+ " #trustify-da-ignore",
284+
"flask==2.0.3"));
285+
var provider = new PythonPipProvider(tempFile);
286+
var ignored = provider.getIgnoredDependencies(Files.readString(tempFile));
287+
var ignoredMap =
288+
ignored.stream()
289+
.collect(
290+
java.util.stream.Collectors.toMap(
291+
com.github.packageurl.PackageURL::getName,
292+
com.github.packageurl.PackageURL::getVersion));
293+
assertThat(ignoredMap).containsEntry("requests", "2.25.1");
294+
assertThat(ignoredMap).containsEntry("idna", "2.10");
295+
assertThat(ignoredMap).containsEntry("six", "1.16.0");
296+
assertThat(ignoredMap).containsEntry("chardet", "4.0.0");
297+
assertThat(ignoredMap).doesNotContainKey("flask");
298+
} finally {
299+
Files.deleteIfExists(tempFile);
300+
}
301+
}
302+
270303
private String dropIgnored(String s) {
271304
return s.replaceAll("\\s+", "").replaceAll("\"timestamp\":\"[a-zA-Z0-9\\-\\:]+\"", "");
272305
}

src/test/java/io/github/guacsec/trustifyda/utils/PythonControllerRealEnvTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,16 @@ void get_Dependency_Name_with_markers() {
301301
PythonControllerRealEnv.getDependencyName("certifi==2023.7.22 ; python_version >= \"3\""));
302302
}
303303

304+
/** Verifies getDependencyName handles compatibility (~=) and exclusion (!=) operators. */
305+
@Test
306+
void get_Dependency_Name_with_compatibility_and_exclusion_operators() {
307+
assertEquals("urllib3", PythonControllerRealEnv.getDependencyName("urllib3~=1.26.0"));
308+
assertEquals("click", PythonControllerRealEnv.getDependencyName("click!=7.1.1"));
309+
assertEquals("certifi", PythonControllerRealEnv.getDependencyName("certifi>=2021.0.0"));
310+
assertEquals("package", PythonControllerRealEnv.getDependencyName("package~=2.0"));
311+
assertEquals("package", PythonControllerRealEnv.getDependencyName("package!=1.0.0"));
312+
}
313+
304314
/** Verifies getDependencyName strips PEP 508 extras from requirements. */
305315
@Test
306316
void get_Dependency_Name_with_extras() {
@@ -313,6 +323,17 @@ void get_Dependency_Name_with_extras() {
313323
"package[extra1]>=1.0 ; python_version >= \"3.8\""));
314324
}
315325

326+
/** Verifies getDependencyName handles extras combined with special version operators. */
327+
@Test
328+
void get_Dependency_Name_with_extras_and_special_operators() {
329+
assertEquals(
330+
"requests", PythonControllerRealEnv.getDependencyName("requests[security,socks]==2.25.1"));
331+
assertEquals("httpx", PythonControllerRealEnv.getDependencyName("httpx [http2] >=0.23.0"));
332+
assertEquals(
333+
"package",
334+
PythonControllerRealEnv.getDependencyName("package[extra]~=1.0 ; python_version >= \"3\""));
335+
}
336+
316337
@Test
317338
void automaticallyInstallPackageOnEnvironment() {
318339
assertFalse(pythonControllerRealEnv.automaticallyInstallPackageOnEnvironment());

0 commit comments

Comments
 (0)