@@ -529,6 +529,16 @@ def is_poetry_pyproject_toml(location):
529529 return False
530530
531531
532+ def is_uv_pyproject_toml (location ):
533+ with open (location , 'r' ) as file :
534+ data = file .read ()
535+
536+ if "tool.uv" in data :
537+ return True
538+ else :
539+ return False
540+
541+
532542class BasePoetryPythonLayout (BaseExtractedPythonLayout ):
533543 """
534544 Base class for poetry python projects.
@@ -832,6 +842,310 @@ def parse(cls, location, package_only=False):
832842 yield models .PackageData .from_data (package_data , package_only )
833843
834844
845+ def parse_dependency_requirement (requirement , scope = 'dependencies' , is_runtime = True ):
846+ """
847+ Parse a dependency requirement string and return a DependentPackage or None.
848+
849+ Args:
850+ requirement: A requirement string (e.g., "requests>=2.0.0")
851+ scope: The dependency scope (e.g., 'dependencies', 'dev-dependencies')
852+ is_runtime: Whether this is a runtime dependency
853+
854+ Returns:
855+ models.DependentPackage or None
856+ """
857+ if not requirement :
858+ return None
859+
860+ try :
861+ req = Requirement (requirement )
862+ name = canonicalize_name (req .name )
863+ is_pinned = False
864+ purl = PackageURL (type = 'pypi' , name = name )
865+
866+ specifiers_set = req .specifier
867+ specifiers = specifiers_set ._specs
868+ extracted_requirement = None
869+
870+ if specifiers :
871+ extracted_requirement = str (specifiers_set )
872+ if len (specifiers ) == 1 :
873+ specifier = list (specifiers )[0 ]
874+ if specifier .operator in ('==' , '===' ):
875+ is_pinned = True
876+ purl = purl ._replace (version = specifier .version )
877+
878+ extra_data = {}
879+ if req .marker :
880+ platform = get_python_version_os (req .marker )
881+ if platform :
882+ extra_data = platform
883+
884+ is_optional = bool (get_extra (req .marker ) if req .marker else False )
885+
886+ return models .DependentPackage (
887+ purl = purl .to_string (),
888+ scope = scope ,
889+ is_runtime = is_runtime ,
890+ is_optional = is_optional ,
891+ is_pinned = is_pinned ,
892+ is_direct = True ,
893+ extracted_requirement = extracted_requirement ,
894+ extra_data = extra_data if extra_data else None ,
895+ )
896+ except Exception :
897+ return None
898+
899+
900+ class BaseUvPythonLayout (BaseExtractedPythonLayout ):
901+
902+ @classmethod
903+ def assemble (cls , package_data , resource , codebase , package_adder ):
904+ package_resource = None
905+ if resource .name == 'pyproject.toml' :
906+ package_resource = resource
907+ elif resource .name == 'uv.lock' :
908+ if resource .has_parent ():
909+ siblings = resource .siblings (codebase )
910+ package_resource = [r for r in siblings if r .name == 'pyproject.toml' ]
911+ if package_resource :
912+ package_resource = package_resource [0 ]
913+
914+ if not package_resource :
915+ # we do not have a pyproject.toml
916+ yield from yield_dependencies_from_package_resource (resource )
917+ return
918+
919+ if codebase .has_single_resource :
920+ yield from models .DatafileHandler .assemble (package_data , resource , codebase , package_adder )
921+ return
922+
923+ assert len (package_resource .package_data ) == 1 , f'Invalid pyproject.toml for { package_resource .path } '
924+ pkg_data = package_resource .package_data [0 ]
925+ pkg_data = models .PackageData .from_dict (pkg_data )
926+
927+ if pkg_data .purl :
928+ package = models .Package .from_package_data (
929+ package_data = pkg_data ,
930+ datafile_path = package_resource .path ,
931+ )
932+ package_uid = package .package_uid
933+ package .populate_license_fields ()
934+ yield package
935+
936+ root = package_resource .parent (codebase )
937+ if root :
938+ for pypi_res in cls .walk_pypi (resource = root , codebase = codebase ):
939+ if package_uid and package_uid not in pypi_res .for_packages :
940+ package_adder (package_uid , pypi_res , codebase )
941+ yield pypi_res
942+
943+ yield package_resource
944+
945+ else :
946+ # we have no package, so deps are not for a specific package uid
947+ package_uid = None
948+
949+ # in all cases yield possible dependencies
950+ yield from yield_dependencies_from_package_data (pkg_data , package_resource .path , package_uid )
951+
952+ # we yield this as we do not want this further processed
953+ yield package_resource
954+
955+ for lock_file in package_resource .siblings (codebase ):
956+ if lock_file .name == 'uv.lock' :
957+ yield from yield_dependencies_from_package_resource (lock_file , package_uid )
958+
959+ if package_uid and package_uid not in lock_file .for_packages :
960+ package_adder (package_uid , lock_file , codebase )
961+ yield lock_file
962+
963+
964+ class UvPyprojectTomlHandler (BaseUvPythonLayout ):
965+ datasource_id = 'pypi_uv_pyproject_toml'
966+ path_patterns = ('*pyproject.toml' ,)
967+ default_package_type = 'pypi'
968+ default_primary_language = 'Python'
969+ description = 'Python UV pyproject.toml'
970+ documentation_url = 'https://docs.astral.sh/uv/'
971+
972+ @classmethod
973+ def is_datafile (cls , location , filetypes = tuple ()):
974+ """
975+ Return True if the file at location is likely a UV pyproject.toml file.
976+ """
977+ if super ().is_datafile (location , filetypes = filetypes ) is False :
978+ return False
979+ return is_uv_pyproject_toml (location )
980+
981+ @classmethod
982+ def parse (cls , location , package_only = False ):
983+ """
984+ Parse a UV pyproject.toml file and yield a PackageData.
985+ """
986+ with open (location , "rb" ) as fp :
987+ pyproject_data = tomllib .load (fp )
988+
989+ project = pyproject_data .get ('project' , {})
990+ tool_uv = pyproject_data .get ('tool' , {}).get ('uv' , {})
991+
992+ name = project .get ('name' )
993+ version = project .get ('version' )
994+ description = project .get ('description' )
995+
996+ # Standard dependencies
997+ dependencies = []
998+ for dep_requirement in project .get ('dependencies' , []):
999+ dependency = parse_dependency_requirement (
1000+ requirement = dep_requirement ,
1001+ scope = 'dependencies' ,
1002+ is_runtime = True ,
1003+ )
1004+ if dependency :
1005+ dependencies .append (dependency .to_dict ())
1006+
1007+ # UV dev dependencies
1008+ dev_dependencies = tool_uv .get ('dev-dependencies' , [])
1009+ for dep_requirement in dev_dependencies :
1010+ dependency = parse_dependency_requirement (
1011+ requirement = dep_requirement ,
1012+ scope = 'dev-dependencies' ,
1013+ is_runtime = False ,
1014+ )
1015+ if dependency :
1016+ dependencies .append (dependency .to_dict ())
1017+
1018+ # Extra dependencies (optional dependency groups)
1019+ optional_dependencies = project .get ('optional-dependencies' , {})
1020+ for group_name , group_deps in optional_dependencies .items ():
1021+ for dep_requirement in group_deps :
1022+ dependency = parse_dependency_requirement (
1023+ requirement = dep_requirement ,
1024+ scope = group_name ,
1025+ is_runtime = False ,
1026+ )
1027+ if dependency :
1028+ dependencies .append (dependency .to_dict ())
1029+
1030+ extra_data = {}
1031+ if tool_uv :
1032+ extra_data ['uv_config' ] = tool_uv
1033+
1034+ requires_python = project .get ('requires-python' )
1035+ if requires_python :
1036+ extra_data ['python_version' ] = requires_python
1037+
1038+ package_data = dict (
1039+ datasource_id = cls .datasource_id ,
1040+ type = cls .default_package_type ,
1041+ primary_language = 'Python' ,
1042+ name = name ,
1043+ version = version ,
1044+ description = description ,
1045+ extra_data = extra_data if extra_data else None ,
1046+ dependencies = dependencies ,
1047+ )
1048+
1049+ yield models .PackageData .from_data (package_data , package_only )
1050+
1051+
1052+ class UvLockHandler (BaseUvPythonLayout ):
1053+ datasource_id = 'pypi_uv_lock'
1054+ path_patterns = ('*uv.lock' ,)
1055+ default_package_type = 'pypi'
1056+ default_primary_language = 'Python'
1057+ description = 'Python UV lockfile'
1058+ documentation_url = 'https://docs.astral.sh/uv/'
1059+
1060+ @classmethod
1061+ def parse (cls , location , package_only = False ):
1062+ with open (location , "rb" ) as fp :
1063+ toml_data = tomllib .load (fp )
1064+
1065+ packages = toml_data .get ('package' )
1066+ if not packages :
1067+ return
1068+
1069+ version = toml_data .get ('version' )
1070+ requires_python = toml_data .get ('requires-python' )
1071+
1072+ dependencies = []
1073+ for package in packages :
1074+ dependencies_for_resolved = []
1075+
1076+ # Handle dependencies - UV uses a different format than Poetry
1077+ deps = package .get ("dependencies" ) or []
1078+ for dep in deps :
1079+ if isinstance (dep , dict ):
1080+ # UV format: {name: "package-name", marker: "condition"}
1081+ dep_name = dep .get ('name' )
1082+ marker = dep .get ('marker' )
1083+ purl = PackageURL (
1084+ type = cls .default_package_type ,
1085+ name = dep_name ,
1086+ )
1087+ dependency = models .DependentPackage (
1088+ purl = purl .to_string (),
1089+ extracted_requirement = marker ,
1090+ scope = "dependencies" ,
1091+ is_runtime = True ,
1092+ is_optional = False ,
1093+ is_direct = True ,
1094+ is_pinned = False ,
1095+ )
1096+ dependencies_for_resolved .append (dependency .to_dict ())
1097+ elif isinstance (dep , str ):
1098+ # Simple string dependency
1099+ dependency = parse_dependency_requirement (
1100+ requirement = dep ,
1101+ scope = 'dependencies' ,
1102+ is_runtime = True ,
1103+ )
1104+ if dependency :
1105+ dependencies_for_resolved .append (dependency .to_dict ())
1106+
1107+ name = package .get ('name' )
1108+ version = package .get ('version' )
1109+ urls = get_pypi_urls (name , version )
1110+
1111+ package_data = dict (
1112+ datasource_id = cls .datasource_id ,
1113+ type = cls .default_package_type ,
1114+ primary_language = 'Python' ,
1115+ name = name ,
1116+ version = version ,
1117+ is_virtual = True ,
1118+ dependencies = dependencies_for_resolved ,
1119+ ** urls ,
1120+ )
1121+ resolved_package = models .PackageData .from_data (package_data , package_only )
1122+
1123+ dependency = models .DependentPackage (
1124+ purl = resolved_package .purl ,
1125+ extracted_requirement = None ,
1126+ scope = None ,
1127+ is_runtime = True ,
1128+ is_optional = False ,
1129+ is_direct = False ,
1130+ is_pinned = True ,
1131+ resolved_package = resolved_package .to_dict ()
1132+ )
1133+ dependencies .append (dependency .to_dict ())
1134+
1135+ extra_data = {}
1136+ extra_data ['python_version' ] = requires_python
1137+ extra_data ['lock_version' ] = version
1138+
1139+ package_data = dict (
1140+ datasource_id = cls .datasource_id ,
1141+ type = cls .default_package_type ,
1142+ primary_language = 'Python' ,
1143+ extra_data = extra_data ,
1144+ dependencies = dependencies ,
1145+ )
1146+ yield models .PackageData .from_data (package_data , package_only )
1147+
1148+
8351149class PipInspectDeplockHandler (models .DatafileHandler ):
8361150 datasource_id = 'pypi_inspect_deplock'
8371151 path_patterns = ('*pip-inspect.deplock' ,)
0 commit comments