Skip to content

Commit 75cd9b3

Browse files
committed
feat: Add UV package manager support for Python projects
Add support for parsing Python projects managed with the UV package manager (https://docs.astral.sh/uv/). Two new package data handlers are added in packagedcode.pypi: - UvPyprojectTomlHandler parses pyproject.toml files containing a [tool.uv] table. It collects the standard PEP 621 [project] metadata, optional-dependencies, and PEP 735 [dependency-groups] (with include-group references skipped as forward references). - UvLockHandler parses uv.lock files. Each [[package]] entry becomes a pinned, virtual resolved package; the editable root project entry is skipped since it is parsed independently from pyproject.toml. SHA-256 hashes and the exact sdist URL recorded in the lock file are preserved, and PyPI URLs are populated via get_pypi_urls. A shared BaseUvPythonLayout assembles the package by walking from either pyproject.toml or uv.lock to its sibling, mirroring the existing Poetry layout. PyprojectTomlHandler is updated to skip pyproject.toml files that belong to a UV project so that the dedicated handler runs. Test fixtures are derived from python-attrs/attrs 26.1.0 (https://github.com/python-attrs/attrs, MIT-licensed) and trimmed to the relevant parts for parser and end-to-end package-assembly coverage. Refs: #4501 Signed-off-by: Guillem Serra Cazorla <gserracazorla@gmail.com> Signed-off-by: Guillem Serra Cazorla <guillem@meta.com>
1 parent 6570c13 commit 75cd9b3

10 files changed

Lines changed: 2244 additions & 0 deletions

File tree

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ Changelog
44
Next release
55
--------------
66

7+
- Add support for the Python UV package manager. Two new package data
8+
handlers parse ``pyproject.toml`` files containing a ``[tool.uv]`` table
9+
and ``uv.lock`` lockfiles, including PEP 735 ``[dependency-groups]``,
10+
and the package assembly walks both files together so that the project
11+
metadata and the resolved transitive dependencies are reported as a
12+
single Python package.
13+
https://github.com/aboutcode-org/scancode-toolkit/issues/4501
14+
715
v3.5.0 - 2026-01-15
816
-------------------
917

src/packagedcode/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@
174174
pypi.PyprojectTomlHandler,
175175
pypi.PoetryPyprojectTomlHandler,
176176
pypi.PoetryLockHandler,
177+
pypi.UvPyprojectTomlHandler,
178+
pypi.UvLockHandler,
177179
pypi.PythonEditableInstallationPkgInfoFile,
178180
pypi.PythonEggPkgInfoFile,
179181
pypi.PythonInstalledWheelMetadataFile,

src/packagedcode/pypi.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
8351129
class PipInspectDeplockHandler(models.DatafileHandler):
8361130
datasource_id = 'pypi_inspect_deplock'
8371131
path_patterns = ('*pip-inspect.deplock',)

tests/packagedcode/data/plugin/plugins_list_linux.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,20 @@ Package type: pypi
790790
description: Python setup.py
791791
path_patterns: '*setup.py'
792792
--------------------------------------------
793+
Package type: pypi
794+
datasource_id: pypi_uv_lock
795+
documentation URL: https://docs.astral.sh/uv/concepts/projects/sync/#the-uvlock-file
796+
primary language: Python
797+
description: Python UV lockfile
798+
path_patterns: '*uv.lock'
799+
--------------------------------------------
800+
Package type: pypi
801+
datasource_id: pypi_uv_pyproject_toml
802+
documentation URL: https://docs.astral.sh/uv/concepts/projects/
803+
primary language: Python
804+
description: Python UV pyproject.toml
805+
path_patterns: '*pyproject.toml'
806+
--------------------------------------------
793807
Package type: pypi
794808
datasource_id: pypi_wheel
795809
documentation URL: https://peps.python.org/pep-0427/

0 commit comments

Comments
 (0)