@@ -467,6 +467,7 @@ def is_datafile(cls, location, filetypes=tuple()):
467467 return (
468468 super ().is_datafile (location , filetypes = filetypes )
469469 and not is_poetry_pyproject_toml (location )
470+ and not is_uv_pyproject_toml (location )
470471 )
471472
472473 @classmethod
@@ -832,6 +833,299 @@ def parse(cls, location, package_only=False):
832833 yield models .PackageData .from_data (package_data , package_only )
833834
834835
836+ def is_uv_pyproject_toml (location ):
837+ """
838+ Return True if the pyproject.toml file at ``location`` is for a UV
839+ project (it contains a ``[tool.uv]`` table).
840+ """
841+ with open (location , 'r' ) as fp :
842+ if "[tool.uv]" in fp .read ():
843+ return True
844+ return False
845+
846+
847+ def get_dependency_group_dependencies (groups ):
848+ """
849+ Return a list of DependentPackage parsed from a PEP 735 ``[dependency-groups]``
850+ mapping as found in a pyproject.toml file. ``include-group`` references are
851+ skipped: their resolved members are emitted by their own group entry.
852+ """
853+ dependencies = []
854+ for group_name , group_items in (groups or {}).items ():
855+ requires = []
856+ for item in group_items :
857+ # entries are either a requirement string or a mapping such as
858+ # ``{include-group = "tests"}`` which we skip as a forward reference
859+ if isinstance (item , str ):
860+ requires .append (item )
861+ dependencies .extend (
862+ get_requires_dependencies (
863+ requires = requires ,
864+ default_scope = group_name ,
865+ is_optional = True ,
866+ is_runtime = False ,
867+ )
868+ )
869+ return dependencies
870+
871+
872+ class BaseUvPythonLayout (BaseExtractedPythonLayout ):
873+ """
874+ Base class for UV-managed Python projects, which has a ``pyproject.toml``
875+ paired with a ``uv.lock`` lockfile.
876+ """
877+
878+ @classmethod
879+ def assemble (cls , package_data , resource , codebase , package_adder ):
880+ if codebase .has_single_resource :
881+ yield from models .DatafileHandler .assemble (package_data , resource , codebase , package_adder )
882+ return
883+
884+ package_resource = None
885+ if resource .name == 'pyproject.toml' :
886+ package_resource = resource
887+ elif resource .name == 'uv.lock' :
888+ if resource .has_parent ():
889+ siblings = resource .siblings (codebase )
890+ pyprojects = [r for r in siblings if r .name == 'pyproject.toml' ]
891+ if pyprojects :
892+ package_resource = pyprojects [0 ]
893+
894+ if not package_resource :
895+ yield from yield_dependencies_from_package_resource (resource )
896+ return
897+
898+ assert len (package_resource .package_data ) == 1 , f'Invalid pyproject.toml for { package_resource .path } '
899+ pkg_data = package_resource .package_data [0 ]
900+ pkg_data = models .PackageData .from_dict (pkg_data )
901+
902+ package_uid = None
903+ if pkg_data .purl :
904+ package = models .Package .from_package_data (
905+ package_data = pkg_data ,
906+ datafile_path = package_resource .path ,
907+ )
908+ package_uid = package .package_uid
909+ package .populate_license_fields ()
910+ yield package
911+
912+ root = package_resource .parent (codebase )
913+ if root :
914+ for pypi_res in cls .walk_pypi (resource = root , codebase = codebase ):
915+ if package_uid and package_uid not in pypi_res .for_packages :
916+ package_adder (package_uid , pypi_res , codebase )
917+ yield pypi_res
918+
919+ yield package_resource
920+
921+ yield from yield_dependencies_from_package_data (pkg_data , package_resource .path , package_uid )
922+
923+ yield package_resource
924+
925+ for lock_file in package_resource .siblings (codebase ):
926+ if lock_file .name == 'uv.lock' :
927+ yield from yield_dependencies_from_package_resource (lock_file , package_uid )
928+
929+ if package_uid and package_uid not in lock_file .for_packages :
930+ package_adder (package_uid , lock_file , codebase )
931+ yield lock_file
932+
933+
934+ class UvPyprojectTomlHandler (BaseUvPythonLayout ):
935+ datasource_id = 'pypi_uv_pyproject_toml'
936+ path_patterns = ('*pyproject.toml' ,)
937+ default_package_type = 'pypi'
938+ default_primary_language = 'Python'
939+ description = 'Python UV pyproject.toml'
940+ documentation_url = 'https://docs.astral.sh/uv/concepts/projects/'
941+
942+ @classmethod
943+ def is_datafile (cls , location , filetypes = tuple ()):
944+ return (
945+ super ().is_datafile (location , filetypes = filetypes )
946+ and is_uv_pyproject_toml (location )
947+ )
948+
949+ @classmethod
950+ def parse (cls , location , package_only = False ):
951+ with open (location , "rb" ) as fp :
952+ toml_data = tomllib .load (fp )
953+
954+ project_data = toml_data .get ("project" )
955+ if not project_data :
956+ return
957+
958+ name = project_data .get ('name' )
959+ version = project_data .get ('version' )
960+ description = project_data .get ('description' ) or ''
961+ description = description .strip ()
962+
963+ urls , extra_data = get_urls (metainfo = project_data , name = name , version = version )
964+
965+ extracted_license_statement , license_file = get_declared_license (project_data )
966+ if license_file :
967+ extra_data ['license_file' ] = license_file
968+
969+ requires_python = project_data .get ('requires-python' )
970+ if requires_python :
971+ extra_data ['python_requires' ] = requires_python
972+
973+ dependencies = []
974+ dependencies .extend (
975+ get_requires_dependencies (requires = project_data .get ("dependencies" , []))
976+ )
977+
978+ for dep_type , deps in project_data .get ("optional-dependencies" , {}).items ():
979+ dependencies .extend (
980+ get_requires_dependencies (
981+ requires = deps ,
982+ default_scope = dep_type ,
983+ is_optional = True ,
984+ )
985+ )
986+
987+ dependencies .extend (
988+ get_dependency_group_dependencies (toml_data .get ("dependency-groups" , {}))
989+ )
990+
991+ package_data = dict (
992+ datasource_id = cls .datasource_id ,
993+ type = cls .default_package_type ,
994+ primary_language = 'Python' ,
995+ name = name ,
996+ version = version ,
997+ extracted_license_statement = extracted_license_statement ,
998+ description = description ,
999+ keywords = get_keywords (project_data ),
1000+ parties = get_pyproject_toml_parties (project_data ),
1001+ dependencies = dependencies ,
1002+ extra_data = extra_data ,
1003+ ** urls ,
1004+ )
1005+ yield models .PackageData .from_data (package_data , package_only )
1006+
1007+
1008+ class UvLockHandler (BaseUvPythonLayout ):
1009+ datasource_id = 'pypi_uv_lock'
1010+ path_patterns = ('*uv.lock' ,)
1011+ default_package_type = 'pypi'
1012+ default_primary_language = 'Python'
1013+ description = 'Python UV lockfile'
1014+ documentation_url = 'https://docs.astral.sh/uv/concepts/projects/sync/#the-uvlock-file'
1015+
1016+ @classmethod
1017+ def parse (cls , location , package_only = False ):
1018+ with open (location , "rb" ) as fp :
1019+ toml_data = tomllib .load (fp )
1020+
1021+ packages = toml_data .get ('package' )
1022+ if not packages :
1023+ return
1024+
1025+ dependencies = []
1026+ for package in packages :
1027+ source = package .get ('source' ) or {}
1028+ # skip the editable root project entry: the local pyproject.toml is
1029+ # parsed independently and the resolved transitive dependencies are
1030+ # surfaced as their own ``[[package]]`` entries.
1031+ if 'editable' in source or 'virtual' in source :
1032+ continue
1033+
1034+ name = package .get ('name' )
1035+ version = package .get ('version' )
1036+ if not name :
1037+ continue
1038+
1039+ dependencies_for_resolved = []
1040+ for dep in (package .get ('dependencies' ) or []):
1041+ dep_name = dep .get ('name' )
1042+ if not dep_name :
1043+ continue
1044+ dep_purl = PackageURL (type = cls .default_package_type , name = dep_name )
1045+ dependencies_for_resolved .append (
1046+ models .DependentPackage (
1047+ purl = dep_purl .to_string (),
1048+ extracted_requirement = dep .get ('marker' ),
1049+ scope = 'dependencies' ,
1050+ is_runtime = True ,
1051+ is_optional = False ,
1052+ is_direct = True ,
1053+ is_pinned = False ,
1054+ ).to_dict ()
1055+ )
1056+
1057+ sha256 = None
1058+ download_url = None
1059+ file_name = None
1060+ sdist = package .get ('sdist' )
1061+ if isinstance (sdist , dict ):
1062+ download_url = sdist .get ('url' )
1063+ hash_value = sdist .get ('hash' ) or ''
1064+ if hash_value .startswith ('sha256:' ):
1065+ sha256 = hash_value [len ('sha256:' ):]
1066+ if download_url :
1067+ file_name = posixpath .basename (download_url ) or None
1068+
1069+ urls = get_pypi_urls (name , version )
1070+ if download_url :
1071+ # prefer the exact sdist URL recorded in the lock file
1072+ urls ['repository_download_url' ] = download_url
1073+
1074+ qualifiers = {}
1075+ if file_name :
1076+ # per purl-spec PyPI definition the artifact ``file_name`` is
1077+ # carried as a purl qualifier so the purl identifies the
1078+ # specific sdist recorded in the lock file.
1079+ qualifiers ['file_name' ] = file_name
1080+
1081+ resolved_package_data = dict (
1082+ datasource_id = cls .datasource_id ,
1083+ type = cls .default_package_type ,
1084+ primary_language = 'Python' ,
1085+ name = name ,
1086+ version = version ,
1087+ qualifiers = qualifiers ,
1088+ sha256 = sha256 ,
1089+ is_virtual = True ,
1090+ dependencies = dependencies_for_resolved ,
1091+ ** urls ,
1092+ )
1093+ resolved_package = models .PackageData .from_data (resolved_package_data , package_only )
1094+
1095+ dependencies .append (
1096+ models .DependentPackage (
1097+ purl = resolved_package .purl ,
1098+ extracted_requirement = None ,
1099+ scope = None ,
1100+ is_runtime = True ,
1101+ is_optional = False ,
1102+ is_direct = False ,
1103+ is_pinned = True ,
1104+ resolved_package = resolved_package .to_dict (),
1105+ ).to_dict ()
1106+ )
1107+
1108+ extra_data = {}
1109+ requires_python = toml_data .get ('requires-python' )
1110+ if requires_python :
1111+ extra_data ['python_requires' ] = requires_python
1112+ lock_version = toml_data .get ('version' )
1113+ if lock_version is not None :
1114+ extra_data ['lock_version' ] = lock_version
1115+ revision = toml_data .get ('revision' )
1116+ if revision is not None :
1117+ extra_data ['revision' ] = revision
1118+
1119+ package_data = dict (
1120+ datasource_id = cls .datasource_id ,
1121+ type = cls .default_package_type ,
1122+ primary_language = 'Python' ,
1123+ extra_data = extra_data ,
1124+ dependencies = dependencies ,
1125+ )
1126+ yield models .PackageData .from_data (package_data , package_only )
1127+
1128+
8351129class PipInspectDeplockHandler (models .DatafileHandler ):
8361130 datasource_id = 'pypi_inspect_deplock'
8371131 path_patterns = ('*pip-inspect.deplock' ,)
0 commit comments