@@ -392,6 +392,177 @@ void test_parseUvExport_via_skips_non_bare_package_names() throws IOException {
392392 assertThat (data .graph ().values ().stream ().allMatch (p -> p .children ().isEmpty ())).isTrue ();
393393 }
394394
395+ /** Verifies that PEP 440 direct references (name @ url) are skipped without throwing. */
396+ @ Test
397+ void test_parseUvExport_skips_direct_references () throws IOException {
398+ Path pyprojectPath = Path .of (UV_FIXTURE , "pyproject.toml" );
399+ var provider = new PythonUvProvider (pyprojectPath );
400+
401+ String exportOutput =
402+ "# This file was autogenerated by uv\n "
403+ + "certifi @ git+https://github.com/certifi/python-certifi.git@abcdef1234567890\n "
404+ + " # via requests\n "
405+ + "anyio==3.6.2\n "
406+ + " # via test-project\n " ;
407+
408+ // Given/When
409+ var data = provider .parseUvExport (exportOutput );
410+
411+ // Then — direct reference is skipped, not in graph
412+ assertThat (data .graph ()).doesNotContainKey ("certifi" );
413+ // anyio after the skipped line is still parsed correctly
414+ assertThat (data .graph ()).containsKey ("anyio" );
415+ assertThat (data .graph ().get ("anyio" ).version ()).isEqualTo ("3.6.2" );
416+ assertThat (data .directDeps ()).contains ("anyio" );
417+ }
418+
419+ /** Verifies that path dependencies (./local-package) are skipped without throwing. */
420+ @ Test
421+ void test_parseUvExport_skips_path_dependencies () throws IOException {
422+ Path pyprojectPath = Path .of (UV_FIXTURE , "pyproject.toml" );
423+ var provider = new PythonUvProvider (pyprojectPath );
424+
425+ String exportOutput =
426+ "# This file was autogenerated by uv\n "
427+ + "./local-package\n "
428+ + " # via test-project\n "
429+ + "../sibling-package\n "
430+ + " # via test-project\n "
431+ + "/absolute/path/package\n "
432+ + " # via test-project\n "
433+ + "anyio==3.6.2\n "
434+ + " # via test-project\n " ;
435+
436+ // Given/When
437+ var data = provider .parseUvExport (exportOutput );
438+
439+ // Then — path dependencies are skipped
440+ assertThat (data .graph ()).doesNotContainKey ("./local-package" );
441+ assertThat (data .graph ()).doesNotContainKey ("../sibling-package" );
442+ assertThat (data .graph ()).doesNotContainKey ("/absolute/path/package" );
443+ // anyio is still parsed correctly
444+ assertThat (data .graph ()).containsKey ("anyio" );
445+ assertThat (data .directDeps ()).contains ("anyio" );
446+ }
447+
448+ /**
449+ * Verifies that # via comments after skipped packages do not create incorrect parent-child
450+ * relationships with the previous package.
451+ */
452+ @ Test
453+ void test_parseUvExport_via_after_skipped_does_not_corrupt_graph () throws IOException {
454+ Path pyprojectPath = Path .of (UV_FIXTURE , "pyproject.toml" );
455+ var provider = new PythonUvProvider (pyprojectPath );
456+
457+ // anyio is parsed first, then a direct reference is skipped. The "# via requests" after
458+ // the skipped package should NOT make anyio a child of requests.
459+ String exportOutput =
460+ "# This file was autogenerated by uv\n "
461+ + "anyio==3.6.2\n "
462+ + " # via test-project\n "
463+ + "certifi @ git+https://github.com/certifi/python-certifi.git@abcdef\n "
464+ + " # via requests\n "
465+ + "requests==2.25.1\n "
466+ + " # via test-project\n " ;
467+
468+ // Given/When
469+ var data = provider .parseUvExport (exportOutput );
470+
471+ // Then — requests should NOT have anyio as a child (that would be a corruption)
472+ assertThat (data .graph ().get ("requests" ).children ()).doesNotContain ("anyio" );
473+ // certifi is skipped
474+ assertThat (data .graph ()).doesNotContainKey ("certifi" );
475+ // Both anyio and requests are direct deps
476+ assertThat (data .directDeps ()).containsExactlyInAnyOrder ("anyio" , "requests" );
477+ }
478+
479+ /**
480+ * Verifies that parseUvExport correctly handles a fixture file containing both direct references
481+ * and path dependencies mixed with normal packages.
482+ */
483+ @ Test
484+ void test_parseUvExport_with_direct_refs_fixture () throws IOException {
485+ Path exportPath = Path .of (UV_FIXTURE , "uv_export_direct_refs.txt" );
486+ Path pyprojectPath = Path .of (UV_FIXTURE , "pyproject.toml" );
487+ var provider = new PythonUvProvider (pyprojectPath );
488+ String exportOutput = Files .readString (exportPath );
489+
490+ // Given/When
491+ var data = provider .parseUvExport (exportOutput );
492+
493+ // Then — direct reference and path dependency are skipped
494+ assertThat (data .graph ()).doesNotContainKey ("certifi" );
495+ assertThat (data .graph ()).doesNotContainKey ("./local-package" );
496+
497+ // Normal packages are parsed correctly
498+ assertThat (data .graph ()).containsKeys ("anyio" , "flask" , "requests" , "idna" , "sniffio" );
499+ assertThat (data .graph ().get ("anyio" ).version ()).isEqualTo ("3.6.2" );
500+ assertThat (data .graph ().get ("flask" ).version ()).isEqualTo ("2.0.3" );
501+ assertThat (data .graph ().get ("requests" ).version ()).isEqualTo ("2.25.1" );
502+
503+ // Direct deps are correctly identified
504+ assertThat (data .directDeps ()).containsExactlyInAnyOrder ("anyio" , "flask" , "requests" );
505+
506+ // charset-normalizer is a child of requests (not corrupted by the skipped certifi)
507+ assertThat (data .graph ().get ("requests" ).children ()).contains ("charset-normalizer" );
508+ }
509+
510+ /**
511+ * Verifies that provideStack succeeds with export output containing direct references and path
512+ * dependencies.
513+ */
514+ @ Test
515+ void test_provideStack_with_direct_refs () throws IOException {
516+ Path pyprojectPath = Path .of (UV_FIXTURE , "pyproject.toml" );
517+ String exportOutput = Files .readString (Path .of (UV_FIXTURE , "uv_export_direct_refs.txt" ));
518+
519+ System .setProperty (PythonUvProvider .PROP_TRUSTIFY_DA_UV_EXPORT , exportOutput );
520+ try {
521+ var provider = new PythonUvProvider (pyprojectPath );
522+ var content = provider .provideStack ();
523+ assertThat (content .type ).isEqualTo (Api .CYCLONEDX_MEDIA_TYPE );
524+ String sbomJson = new String (content .buffer );
525+ assertThat (sbomJson ).contains ("CycloneDX" );
526+ // Skipped packages should not appear
527+ assertThat (sbomJson ).doesNotContain ("pkg:pypi/certifi@" );
528+ // Normal packages should appear
529+ assertThat (sbomJson ).contains ("pkg:pypi/anyio@3.6.2" );
530+ assertThat (sbomJson ).contains ("pkg:pypi/flask@2.0.3" );
531+ assertThat (sbomJson ).contains ("pkg:pypi/requests@2.25.1" );
532+ } catch (RuntimeException | NoClassDefFoundError e ) {
533+ Assumptions .assumeTrue (false , "Skipping: SBOM serialization unavailable - " + e .getMessage ());
534+ } finally {
535+ System .clearProperty (PythonUvProvider .PROP_TRUSTIFY_DA_UV_EXPORT );
536+ }
537+ }
538+
539+ /**
540+ * Verifies that provideComponent succeeds with export output containing direct references and
541+ * path dependencies.
542+ */
543+ @ Test
544+ void test_provideComponent_with_direct_refs () throws IOException {
545+ Path pyprojectPath = Path .of (UV_FIXTURE , "pyproject.toml" );
546+ String exportOutput = Files .readString (Path .of (UV_FIXTURE , "uv_export_direct_refs.txt" ));
547+
548+ System .setProperty (PythonUvProvider .PROP_TRUSTIFY_DA_UV_EXPORT , exportOutput );
549+ try {
550+ var provider = new PythonUvProvider (pyprojectPath );
551+ var content = provider .provideComponent ();
552+ assertThat (content .type ).isEqualTo (Api .CYCLONEDX_MEDIA_TYPE );
553+ String sbomJson = new String (content .buffer );
554+ assertThat (sbomJson ).contains ("CycloneDX" );
555+ assertThat (sbomJson ).doesNotContain ("pkg:pypi/certifi@" );
556+ assertThat (sbomJson ).contains ("pkg:pypi/anyio@3.6.2" );
557+ assertThat (sbomJson ).contains ("pkg:pypi/flask@2.0.3" );
558+ assertThat (sbomJson ).contains ("pkg:pypi/requests@2.25.1" );
559+ } catch (RuntimeException | NoClassDefFoundError e ) {
560+ Assumptions .assumeTrue (false , "Skipping: SBOM serialization unavailable - " + e .getMessage ());
561+ } finally {
562+ System .clearProperty (PythonUvProvider .PROP_TRUSTIFY_DA_UV_EXPORT );
563+ }
564+ }
565+
395566 @ Test
396567 void test_parseUvExport_throws_on_unpinned_version () {
397568 Path pyprojectPath = Path .of (UV_FIXTURE , "pyproject.toml" );
0 commit comments