Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Changelog
Next release
--------------

- Add support for the Python UV package manager. Two new package data
handlers parse ``pyproject.toml`` files containing a ``[tool.uv]`` table
and ``uv.lock`` lockfiles, including PEP 735 ``[dependency-groups]``,
and the package assembly walks both files together so that the project
metadata and the resolved transitive dependencies are reported as a
single Python package.
https://github.com/aboutcode-org/scancode-toolkit/issues/4501

v3.5.0 - 2026-01-15
-------------------

Expand Down
2 changes: 2 additions & 0 deletions src/packagedcode/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@
pypi.PyprojectTomlHandler,
pypi.PoetryPyprojectTomlHandler,
pypi.PoetryLockHandler,
pypi.UvPyprojectTomlHandler,
pypi.UvLockHandler,
pypi.PythonEditableInstallationPkgInfoFile,
pypi.PythonEggPkgInfoFile,
pypi.PythonInstalledWheelMetadataFile,
Expand Down
283 changes: 283 additions & 0 deletions src/packagedcode/pypi.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ def is_datafile(cls, location, filetypes=tuple()):
return (
super().is_datafile(location, filetypes=filetypes)
and not is_poetry_pyproject_toml(location)
and not is_uv_pyproject_toml(location)
)

@classmethod
Expand Down Expand Up @@ -832,6 +833,288 @@ def parse(cls, location, package_only=False):
yield models.PackageData.from_data(package_data, package_only)


def is_uv_pyproject_toml(location):
"""
Return True if the pyproject.toml file at ``location`` is for a UV
project (it contains a ``[tool.uv]`` table).
"""
with open(location, 'r') as fp:
if "[tool.uv]" in fp.read():
return True
return False


def get_dependency_group_dependencies(groups):
"""
Return a list of DependentPackage parsed from a PEP 735 ``[dependency-groups]``
mapping as found in a pyproject.toml file. ``include-group`` references are
skipped: their resolved members are emitted by their own group entry.
"""
dependencies = []
for group_name, group_items in (groups or {}).items():
requires = []
for item in group_items:
# entries are either a requirement string or a mapping such as
# ``{include-group = "tests"}`` which we skip as a forward reference
if isinstance(item, str):
requires.append(item)
dependencies.extend(
get_requires_dependencies(
requires=requires,
default_scope=group_name,
is_optional=True,
is_runtime=False,
)
)
return dependencies


class BaseUvPythonLayout(BaseExtractedPythonLayout):
"""
Base class for UV-managed Python projects (``pyproject.toml`` paired with
a ``uv.lock`` lockfile).
"""

@classmethod
def assemble(cls, package_data, resource, codebase, package_adder):
if codebase.has_single_resource:
yield from models.DatafileHandler.assemble(package_data, resource, codebase, package_adder)
return

package_resource = None
if resource.name == 'pyproject.toml':
package_resource = resource
elif resource.name == 'uv.lock':
if resource.has_parent():
siblings = resource.siblings(codebase)
pyprojects = [r for r in siblings if r.name == 'pyproject.toml']
if pyprojects:
package_resource = pyprojects[0]

if not package_resource:
yield from yield_dependencies_from_package_resource(resource)
return

assert len(package_resource.package_data) == 1, f'Invalid pyproject.toml for {package_resource.path}'
pkg_data = package_resource.package_data[0]
pkg_data = models.PackageData.from_dict(pkg_data)

package_uid = None
if pkg_data.purl:
package = models.Package.from_package_data(
package_data=pkg_data,
datafile_path=package_resource.path,
)
package_uid = package.package_uid
package.populate_license_fields()
yield package

root = package_resource.parent(codebase)
if root:
for pypi_res in cls.walk_pypi(resource=root, codebase=codebase):
if package_uid and package_uid not in pypi_res.for_packages:
package_adder(package_uid, pypi_res, codebase)
yield pypi_res

yield package_resource

yield from yield_dependencies_from_package_data(pkg_data, package_resource.path, package_uid)

yield package_resource

for lock_file in package_resource.siblings(codebase):
if lock_file.name == 'uv.lock':
yield from yield_dependencies_from_package_resource(lock_file, package_uid)

if package_uid and package_uid not in lock_file.for_packages:
package_adder(package_uid, lock_file, codebase)
yield lock_file


class UvPyprojectTomlHandler(BaseUvPythonLayout):
datasource_id = 'pypi_uv_pyproject_toml'
path_patterns = ('*pyproject.toml',)
default_package_type = 'pypi'
default_primary_language = 'Python'
description = 'Python UV pyproject.toml'
documentation_url = 'https://docs.astral.sh/uv/concepts/projects/'

@classmethod
def is_datafile(cls, location, filetypes=tuple()):
return (
super().is_datafile(location, filetypes=filetypes)
and is_uv_pyproject_toml(location)
)

@classmethod
def parse(cls, location, package_only=False):
with open(location, "rb") as fp:
toml_data = tomllib.load(fp)

project_data = toml_data.get("project")
if not project_data:
return

name = project_data.get('name')
version = project_data.get('version')
description = project_data.get('description') or ''
description = description.strip()

urls, extra_data = get_urls(metainfo=project_data, name=name, version=version)

extracted_license_statement, license_file = get_declared_license(project_data)
if license_file:
extra_data['license_file'] = license_file

requires_python = project_data.get('requires-python')
if requires_python:
extra_data['python_requires'] = requires_python

dependencies = []
dependencies.extend(
get_requires_dependencies(requires=project_data.get("dependencies", []))
)

for dep_type, deps in project_data.get("optional-dependencies", {}).items():
dependencies.extend(
get_requires_dependencies(
requires=deps,
default_scope=dep_type,
is_optional=True,
)
)

dependencies.extend(
get_dependency_group_dependencies(toml_data.get("dependency-groups", {}))
)

package_data = dict(
datasource_id=cls.datasource_id,
type=cls.default_package_type,
primary_language='Python',
name=name,
version=version,
extracted_license_statement=extracted_license_statement,
description=description,
keywords=get_keywords(project_data),
parties=get_pyproject_toml_parties(project_data),
dependencies=dependencies,
extra_data=extra_data,
**urls,
)
yield models.PackageData.from_data(package_data, package_only)


class UvLockHandler(BaseUvPythonLayout):
datasource_id = 'pypi_uv_lock'
path_patterns = ('*uv.lock',)
default_package_type = 'pypi'
default_primary_language = 'Python'
description = 'Python UV lockfile'
documentation_url = 'https://docs.astral.sh/uv/concepts/projects/sync/#the-uvlock-file'

@classmethod
def parse(cls, location, package_only=False):
with open(location, "rb") as fp:
toml_data = tomllib.load(fp)

packages = toml_data.get('package')
if not packages:
return

dependencies = []
for package in packages:
source = package.get('source') or {}
# skip the editable root project entry: the local pyproject.toml is
# parsed independently and the resolved transitive dependencies are
# surfaced as their own ``[[package]]`` entries.
if 'editable' in source or 'virtual' in source:
continue

name = package.get('name')
version = package.get('version')
if not name:
continue

dependencies_for_resolved = []
for dep in (package.get('dependencies') or []):
dep_name = dep.get('name')
if not dep_name:
continue
dep_purl = PackageURL(type=cls.default_package_type, name=dep_name)
dependencies_for_resolved.append(
models.DependentPackage(
purl=dep_purl.to_string(),
extracted_requirement=dep.get('marker'),
scope='dependencies',
is_runtime=True,
is_optional=False,
is_direct=True,
is_pinned=False,
).to_dict()
)

sha256 = None
download_url = None
sdist = package.get('sdist')
if isinstance(sdist, dict):
download_url = sdist.get('url')
hash_value = sdist.get('hash') or ''
if hash_value.startswith('sha256:'):
sha256 = hash_value[len('sha256:'):]

urls = get_pypi_urls(name, version)
if download_url:
# prefer the exact sdist URL recorded in the lock file
urls['repository_download_url'] = download_url

resolved_package_data = dict(
datasource_id=cls.datasource_id,
type=cls.default_package_type,
primary_language='Python',
name=name,
version=version,
sha256=sha256,
is_virtual=True,
dependencies=dependencies_for_resolved,
**urls,
)
resolved_package = models.PackageData.from_data(resolved_package_data, package_only)

dependencies.append(
models.DependentPackage(
purl=resolved_package.purl,
extracted_requirement=None,
scope=None,
is_runtime=True,
is_optional=False,
is_direct=False,
is_pinned=True,
resolved_package=resolved_package.to_dict(),
).to_dict()
)

extra_data = {}
requires_python = toml_data.get('requires-python')
if requires_python:
extra_data['python_requires'] = requires_python
lock_version = toml_data.get('version')
if lock_version is not None:
extra_data['lock_version'] = lock_version
revision = toml_data.get('revision')
if revision is not None:
extra_data['revision'] = revision

package_data = dict(
datasource_id=cls.datasource_id,
type=cls.default_package_type,
primary_language='Python',
extra_data=extra_data,
dependencies=dependencies,
)
yield models.PackageData.from_data(package_data, package_only)


class PipInspectDeplockHandler(models.DatafileHandler):
datasource_id = 'pypi_inspect_deplock'
path_patterns = ('*pip-inspect.deplock',)
Expand Down
14 changes: 14 additions & 0 deletions tests/packagedcode/data/plugin/plugins_list_linux.txt
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,20 @@ Package type: pypi
description: Python setup.py
path_patterns: '*setup.py'
--------------------------------------------
Package type: pypi
datasource_id: pypi_uv_lock
documentation URL: https://docs.astral.sh/uv/concepts/projects/sync/#the-uvlock-file
primary language: Python
description: Python UV lockfile
path_patterns: '*uv.lock'
--------------------------------------------
Package type: pypi
datasource_id: pypi_uv_pyproject_toml
documentation URL: https://docs.astral.sh/uv/concepts/projects/
primary language: Python
description: Python UV pyproject.toml
path_patterns: '*pyproject.toml'
--------------------------------------------
Package type: pypi
datasource_id: pypi_wheel
documentation URL: https://peps.python.org/pep-0427/
Expand Down
Loading
Loading