Skip to content

Commit 64d01f0

Browse files
committed
feat: Add UV package manager support for Python projects
- Implement UvPyprojectTomlHandler to parse pyproject.toml with [tool.uv] sections - Implement UvLockHandler to parse uv.lock lockfiles - Add BaseUvPythonLayout for UV project assembly logic - Add is_uv_pyproject_toml() helper for UV project detection - Add parse_dependency_requirement() helper for dependency parsing - Support standard dependencies, dev-dependencies, and optional groups - Handle UV lockfile format with [[package]] entries and markers - Add comprehensive test fixtures and test cases Signed-off-by: Shekhar Suman [email protected]
1 parent 930e30e commit 64d01f0

File tree

7 files changed

+879
-0
lines changed

7 files changed

+879
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
/pip-selfcheck.json
2323
/tmp
2424
/venv
25+
/myenv
2526
.Python
2627
/include
2728
/Include

src/packagedcode/pypi.py

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

tests/packagedcode/data/pypi/uv/attrs-uv.lock

Lines changed: 57 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)