diff --git a/.gitignore b/.gitignore index 95df8c9d855..f4d65cb93e1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ /pip-selfcheck.json /tmp /venv +/myenv .Python /include /Include diff --git a/src/packagedcode/pypi.py b/src/packagedcode/pypi.py index b5588ed7ca9..59ceea6e719 100644 --- a/src/packagedcode/pypi.py +++ b/src/packagedcode/pypi.py @@ -529,6 +529,16 @@ def is_poetry_pyproject_toml(location): return False +def is_uv_pyproject_toml(location): + with open(location, 'r') as file: + data = file.read() + + if "tool.uv" in data: + return True + else: + return False + + class BasePoetryPythonLayout(BaseExtractedPythonLayout): """ Base class for poetry python projects. @@ -832,6 +842,310 @@ def parse(cls, location, package_only=False): yield models.PackageData.from_data(package_data, package_only) +def parse_dependency_requirement(requirement, scope='dependencies', is_runtime=True): + """ + Parse a dependency requirement string and return a DependentPackage or None. + + Args: + requirement: A requirement string (e.g., "requests>=2.0.0") + scope: The dependency scope (e.g., 'dependencies', 'dev-dependencies') + is_runtime: Whether this is a runtime dependency + + Returns: + models.DependentPackage or None + """ + if not requirement: + return None + + try: + req = Requirement(requirement) + name = canonicalize_name(req.name) + is_pinned = False + purl = PackageURL(type='pypi', name=name) + + specifiers_set = req.specifier + specifiers = specifiers_set._specs + extracted_requirement = None + + if specifiers: + extracted_requirement = str(specifiers_set) + if len(specifiers) == 1: + specifier = list(specifiers)[0] + if specifier.operator in ('==', '==='): + is_pinned = True + purl = purl._replace(version=specifier.version) + + extra_data = {} + if req.marker: + platform = get_python_version_os(req.marker) + if platform: + extra_data = platform + + is_optional = bool(get_extra(req.marker) if req.marker else False) + + return models.DependentPackage( + purl=purl.to_string(), + scope=scope, + is_runtime=is_runtime, + is_optional=is_optional, + is_pinned=is_pinned, + is_direct=True, + extracted_requirement=extracted_requirement, + extra_data=extra_data if extra_data else None, + ) + except Exception: + return None + + +class BaseUvPythonLayout(BaseExtractedPythonLayout): + + @classmethod + def assemble(cls, package_data, resource, codebase, package_adder): + package_resource = None + if resource.name == 'pyproject.toml': + package_resource = resource + elif resource.name == 'uv.lock': + if resource.has_parent(): + siblings = resource.siblings(codebase) + package_resource = [r for r in siblings if r.name == 'pyproject.toml'] + if package_resource: + package_resource = package_resource[0] + + if not package_resource: + # we do not have a pyproject.toml + yield from yield_dependencies_from_package_resource(resource) + return + + if codebase.has_single_resource: + yield from models.DatafileHandler.assemble(package_data, resource, codebase, package_adder) + 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) + + 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 + + else: + # we have no package, so deps are not for a specific package uid + package_uid = None + + # in all cases yield possible dependencies + yield from yield_dependencies_from_package_data(pkg_data, package_resource.path, package_uid) + + # we yield this as we do not want this further processed + 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/' + + @classmethod + def is_datafile(cls, location, filetypes=tuple()): + """ + Return True if the file at location is likely a UV pyproject.toml file. + """ + if super().is_datafile(location, filetypes=filetypes) is False: + return False + return is_uv_pyproject_toml(location) + + @classmethod + def parse(cls, location, package_only=False): + """ + Parse a UV pyproject.toml file and yield a PackageData. + """ + with open(location, "rb") as fp: + pyproject_data = tomllib.load(fp) + + project = pyproject_data.get('project', {}) + tool_uv = pyproject_data.get('tool', {}).get('uv', {}) + + name = project.get('name') + version = project.get('version') + description = project.get('description') + + # Standard dependencies + dependencies = [] + for dep_requirement in project.get('dependencies', []): + dependency = parse_dependency_requirement( + requirement=dep_requirement, + scope='dependencies', + is_runtime=True, + ) + if dependency: + dependencies.append(dependency.to_dict()) + + # UV dev dependencies + dev_dependencies = tool_uv.get('dev-dependencies', []) + for dep_requirement in dev_dependencies: + dependency = parse_dependency_requirement( + requirement=dep_requirement, + scope='dev-dependencies', + is_runtime=False, + ) + if dependency: + dependencies.append(dependency.to_dict()) + + # Extra dependencies (optional dependency groups) + optional_dependencies = project.get('optional-dependencies', {}) + for group_name, group_deps in optional_dependencies.items(): + for dep_requirement in group_deps: + dependency = parse_dependency_requirement( + requirement=dep_requirement, + scope=group_name, + is_runtime=False, + ) + if dependency: + dependencies.append(dependency.to_dict()) + + extra_data = {} + if tool_uv: + extra_data['uv_config'] = tool_uv + + requires_python = project.get('requires-python') + if requires_python: + extra_data['python_version'] = requires_python + + package_data = dict( + datasource_id=cls.datasource_id, + type=cls.default_package_type, + primary_language='Python', + name=name, + version=version, + description=description, + extra_data=extra_data if extra_data else None, + dependencies=dependencies, + ) + + 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/' + + @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 + + version = toml_data.get('version') + requires_python = toml_data.get('requires-python') + + dependencies = [] + for package in packages: + dependencies_for_resolved = [] + + # Handle dependencies - UV uses a different format than Poetry + deps = package.get("dependencies") or [] + for dep in deps: + if isinstance(dep, dict): + # UV format: {name: "package-name", marker: "condition"} + dep_name = dep.get('name') + marker = dep.get('marker') + purl = PackageURL( + type=cls.default_package_type, + name=dep_name, + ) + dependency = models.DependentPackage( + purl=purl.to_string(), + extracted_requirement=marker, + scope="dependencies", + is_runtime=True, + is_optional=False, + is_direct=True, + is_pinned=False, + ) + dependencies_for_resolved.append(dependency.to_dict()) + elif isinstance(dep, str): + # Simple string dependency + dependency = parse_dependency_requirement( + requirement=dep, + scope='dependencies', + is_runtime=True, + ) + if dependency: + dependencies_for_resolved.append(dependency.to_dict()) + + name = package.get('name') + version = package.get('version') + urls = get_pypi_urls(name, version) + + package_data = dict( + datasource_id=cls.datasource_id, + type=cls.default_package_type, + primary_language='Python', + name=name, + version=version, + is_virtual=True, + dependencies=dependencies_for_resolved, + **urls, + ) + resolved_package = models.PackageData.from_data(package_data, package_only) + + dependency = 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() + ) + dependencies.append(dependency.to_dict()) + + extra_data = {} + extra_data['python_version'] = requires_python + extra_data['lock_version'] = version + + 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',) diff --git a/tests/packagedcode/data/pypi/uv/attrs-uv.lock b/tests/packagedcode/data/pypi/uv/attrs-uv.lock new file mode 100644 index 00000000000..2a14a8d405c --- /dev/null +++ b/tests/packagedcode/data/pypi/uv/attrs-uv.lock @@ -0,0 +1,57 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/df/3a895fae24940a47989cf1b39c41fd127b0f21ae82d36c0a61c14c46a04e/attrs-25.4.0.tar.gz", hash = "sha256:8ad3aa2f5e9afc0f5b0fd29327f2f4ae1c8dcc21d4ddc1ae75e0c2ac3d81e8cb", size = 814869, upload-time = "2025-09-03T12:55:53.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/08/b71f8dc5f4f98c72e96c28c21e1bbccf57ad5e2de0be03dd8d0ff5c6c70d/attrs-25.4.0-py3-none-any.whl", hash = "sha256:bc3d66eda87c61dfcdda5d35ab9f84ffdf05d4e1c59a0ad8e54c5e1bd8d9e27c", size = 67413, upload-time = "2025-09-03T12:55:51.917Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2021-10-13T16:47:00.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2021-10-13T16:47:01.308Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:a3b6a6bd1e06f8c2ba797a47ebe7ecba1ba1aa5ca0ff2b48b0ac85e5d1c6b95d", size = 55502, upload-time = "2025-10-11T21:23:08.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/e5/d2f59ecf66b4db97913df13e8e2c91e27f1a3e7dc26e4802f5c74c80bfb1/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:c6099ecf5cbbbb09cd81c38f47a5ac38cf6a90cf0f0d2b8efe55b04fe450c25f", size = 26942, upload-time = "2025-10-11T21:23:06.757Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:2a9a1ba1fdbd2d7a60e7be01da6e9a4a9c55e1d2c34b916ed3695b0a57c73ef6", size = 25122, upload-time = "2025-06-03T15:33:51.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/b3/8c9e75fc9ba3004cfa3805a45ba15073912f98c4099cd66d11f43dfab56c/zipp-3.23.0-py3-none-any.whl", hash = "sha256:caf9e9c4b12f47e2b5b7c91c98b6fd96b8b4fdfd96fd3c1caa4c59e3cb9e9e1b", size = 9880, upload-time = "2025-06-03T15:33:49.298Z" }, +] diff --git a/tests/packagedcode/data/pypi/uv/attrs-uv.lock-expected.json b/tests/packagedcode/data/pypi/uv/attrs-uv.lock-expected.json new file mode 100644 index 00000000000..4946e036ced --- /dev/null +++ b/tests/packagedcode/data/pypi/uv/attrs-uv.lock-expected.json @@ -0,0 +1,356 @@ +[ + { + "type": "pypi", + "namespace": null, + "name": null, + "version": null, + "qualifiers": {}, + "subpath": null, + "primary_language": "Python", + "description": null, + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": false, + "extra_data": { + "python_version": ">=3.9", + "lock_version": "3.23.0" + }, + "dependencies": [ + { + "purl": "pkg:pypi/attrs@25.4.0", + "extracted_requirement": null, + "scope": null, + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": false, + "resolved_package": { + "type": "pypi", + "namespace": null, + "name": "attrs", + "version": "25.4.0", + "qualifiers": {}, + "subpath": null, + "primary_language": "Python", + "description": null, + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": true, + "extra_data": {}, + "dependencies": [ + { + "purl": "pkg:pypi/importlib-metadata", + "extracted_requirement": "python_full_version < '3.10'", + "scope": "dependencies", + "is_runtime": true, + "is_optional": false, + "is_pinned": false, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + } + ], + "repository_homepage_url": "https://pypi.org/project/attrs", + "repository_download_url": "https://pypi.org/packages/source/a/attrs/attrs-25.4.0.tar.gz", + "api_data_url": "https://pypi.org/pypi/attrs/25.4.0/json", + "datasource_id": "pypi_uv_lock", + "purl": "pkg:pypi/attrs@25.4.0" + }, + "extra_data": {} + }, + { + "purl": "pkg:pypi/click@8.3.0", + "extracted_requirement": null, + "scope": null, + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": false, + "resolved_package": { + "type": "pypi", + "namespace": null, + "name": "click", + "version": "8.3.0", + "qualifiers": {}, + "subpath": null, + "primary_language": "Python", + "description": null, + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": true, + "extra_data": {}, + "dependencies": [ + { + "purl": "pkg:pypi/colorama", + "extracted_requirement": "python_full_version >= '3.10' and sys_platform == 'win32'", + "scope": "dependencies", + "is_runtime": true, + "is_optional": false, + "is_pinned": false, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + } + ], + "repository_homepage_url": "https://pypi.org/project/click", + "repository_download_url": "https://pypi.org/packages/source/c/click/click-8.3.0.tar.gz", + "api_data_url": "https://pypi.org/pypi/click/8.3.0/json", + "datasource_id": "pypi_uv_lock", + "purl": "pkg:pypi/click@8.3.0" + }, + "extra_data": {} + }, + { + "purl": "pkg:pypi/colorama@0.4.6", + "extracted_requirement": null, + "scope": null, + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": false, + "resolved_package": { + "type": "pypi", + "namespace": null, + "name": "colorama", + "version": "0.4.6", + "qualifiers": {}, + "subpath": null, + "primary_language": "Python", + "description": null, + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": true, + "extra_data": {}, + "dependencies": [], + "repository_homepage_url": "https://pypi.org/project/colorama", + "repository_download_url": "https://pypi.org/packages/source/c/colorama/colorama-0.4.6.tar.gz", + "api_data_url": "https://pypi.org/pypi/colorama/0.4.6/json", + "datasource_id": "pypi_uv_lock", + "purl": "pkg:pypi/colorama@0.4.6" + }, + "extra_data": {} + }, + { + "purl": "pkg:pypi/importlib-metadata@8.7.0", + "extracted_requirement": null, + "scope": null, + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": false, + "resolved_package": { + "type": "pypi", + "namespace": null, + "name": "importlib-metadata", + "version": "8.7.0", + "qualifiers": {}, + "subpath": null, + "primary_language": "Python", + "description": null, + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": true, + "extra_data": {}, + "dependencies": [ + { + "purl": "pkg:pypi/zipp", + "extracted_requirement": "python_full_version < '3.10'", + "scope": "dependencies", + "is_runtime": true, + "is_optional": false, + "is_pinned": false, + "is_direct": true, + "resolved_package": {}, + "extra_data": {} + } + ], + "repository_homepage_url": "https://pypi.org/project/importlib-metadata", + "repository_download_url": "https://pypi.org/packages/source/i/importlib-metadata/importlib-metadata-8.7.0.tar.gz", + "api_data_url": "https://pypi.org/pypi/importlib-metadata/8.7.0/json", + "datasource_id": "pypi_uv_lock", + "purl": "pkg:pypi/importlib-metadata@8.7.0" + }, + "extra_data": {} + }, + { + "purl": "pkg:pypi/zipp@3.23.0", + "extracted_requirement": null, + "scope": null, + "is_runtime": true, + "is_optional": false, + "is_pinned": true, + "is_direct": false, + "resolved_package": { + "type": "pypi", + "namespace": null, + "name": "zipp", + "version": "3.23.0", + "qualifiers": {}, + "subpath": null, + "primary_language": "Python", + "description": null, + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": true, + "extra_data": {}, + "dependencies": [], + "repository_homepage_url": "https://pypi.org/project/zipp", + "repository_download_url": "https://pypi.org/packages/source/z/zipp/zipp-3.23.0.tar.gz", + "api_data_url": "https://pypi.org/pypi/zipp/3.23.0/json", + "datasource_id": "pypi_uv_lock", + "purl": "pkg:pypi/zipp@3.23.0" + }, + "extra_data": {} + } + ], + "repository_homepage_url": null, + "repository_download_url": null, + "api_data_url": null, + "datasource_id": "pypi_uv_lock", + "purl": null + } +] \ No newline at end of file diff --git a/tests/packagedcode/data/pypi/uv/pyproject-with-uv.toml b/tests/packagedcode/data/pypi/uv/pyproject-with-uv.toml new file mode 100644 index 00000000000..d05605d8140 --- /dev/null +++ b/tests/packagedcode/data/pypi/uv/pyproject-with-uv.toml @@ -0,0 +1,23 @@ +[project] +name = "attrs-example" +version = "1.0.0" +description = "Example project using UV" +requires-python = ">=3.9" + +dependencies = [ + "attrs>=25.4.0", + "click>=8.3.0", +] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.0.0", + "black>=24.0.0", +] + +[tool.uv.sources] +attrs = { url = "https://files.pythonhosted.org/packages/97/08/b71f8dc5f4f98c72e96c28c21e1bbccf57ad5e2de0be03dd8d0ff5c6c70d/attrs-25.4.0-py3-none-any.whl" } + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/tests/packagedcode/data/pypi/uv/pyproject-with-uv.toml-expected.json b/tests/packagedcode/data/pypi/uv/pyproject-with-uv.toml-expected.json new file mode 100644 index 00000000000..506a389d6a8 --- /dev/null +++ b/tests/packagedcode/data/pypi/uv/pyproject-with-uv.toml-expected.json @@ -0,0 +1,104 @@ +[ + { + "type": "pypi", + "namespace": null, + "name": "attrs-example", + "version": "1.0.0", + "qualifiers": {}, + "subpath": null, + "primary_language": "Python", + "description": "Example project using UV", + "release_date": null, + "parties": [], + "keywords": [], + "homepage_url": null, + "download_url": null, + "size": null, + "sha1": null, + "md5": null, + "sha256": null, + "sha512": null, + "bug_tracking_url": null, + "code_view_url": null, + "vcs_url": null, + "copyright": null, + "holder": null, + "declared_license_expression": null, + "declared_license_expression_spdx": null, + "license_detections": [], + "other_license_expression": null, + "other_license_expression_spdx": null, + "other_license_detections": [], + "extracted_license_statement": null, + "notice_text": null, + "source_packages": [], + "file_references": [], + "is_private": false, + "is_virtual": false, + "extra_data": { + "uv_config": { + "dev-dependencies": [ + "pytest>=8.0.0", + "black>=24.0.0" + ], + "sources": { + "attrs": { + "url": "https://files.pythonhosted.org/packages/97/08/b71f8dc5f4f98c72e96c28c21e1bbccf57ad5e2de0be03dd8d0ff5c6c70d/attrs-25.4.0-py3-none-any.whl" + } + } + }, + "python_version": ">=3.9" + }, + "dependencies": [ + { + "purl": "pkg:pypi/attrs", + "extracted_requirement": ">=25.4.0", + "scope": "dependencies", + "is_runtime": true, + "is_optional": false, + "is_pinned": false, + "is_direct": true, + "resolved_package": {}, + "extra_data": null + }, + { + "purl": "pkg:pypi/click", + "extracted_requirement": ">=8.3.0", + "scope": "dependencies", + "is_runtime": true, + "is_optional": false, + "is_pinned": false, + "is_direct": true, + "resolved_package": {}, + "extra_data": null + }, + { + "purl": "pkg:pypi/pytest", + "extracted_requirement": ">=8.0.0", + "scope": "dev-dependencies", + "is_runtime": false, + "is_optional": false, + "is_pinned": false, + "is_direct": true, + "resolved_package": {}, + "extra_data": null + }, + { + "purl": "pkg:pypi/black", + "extracted_requirement": ">=24.0.0", + "scope": "dev-dependencies", + "is_runtime": false, + "is_optional": false, + "is_pinned": false, + "is_direct": true, + "resolved_package": {}, + "extra_data": null + } + ], + "repository_homepage_url": null, + "repository_download_url": null, + "api_data_url": null, + "datasource_id": "pypi_uv_pyproject_toml", + "purl": "pkg:pypi/attrs-example@1.0.0" + } +] \ No newline at end of file diff --git a/tests/packagedcode/test_pypi.py b/tests/packagedcode/test_pypi.py index 3dcfa7d4268..a6e9d1768b9 100644 --- a/tests/packagedcode/test_pypi.py +++ b/tests/packagedcode/test_pypi.py @@ -405,6 +405,30 @@ def test_parse_pyproject_toml_poetry_univers(self): self.check_packages_data(package, expected_loc, regen=REGEN_TEST_FIXTURES) +class TestUvHandler(PackageTester): + test_data_dir = os.path.join(os.path.dirname(__file__), 'data') + + def test_is_uv_pyproject_toml(self): + test_file = self.get_test_loc('pypi/uv/pyproject-with-uv.toml') + assert pypi.UvPyprojectTomlHandler.is_datafile(test_file) + + def test_parse_uv_pyproject_toml(self): + test_file = self.get_test_loc('pypi/uv/pyproject-with-uv.toml') + package = pypi.UvPyprojectTomlHandler.parse(test_file) + expected_loc = self.get_test_loc('pypi/uv/pyproject-with-uv.toml-expected.json') + self.check_packages_data(package, expected_loc, regen=REGEN_TEST_FIXTURES) + + def test_is_uv_lock(self): + test_file = self.get_test_loc('pypi/uv/attrs-uv.lock') + assert pypi.UvLockHandler.is_datafile(test_file) + + def test_parse_uv_lock_attrs(self): + test_file = self.get_test_loc('pypi/uv/attrs-uv.lock') + package = pypi.UvLockHandler.parse(test_file) + expected_loc = self.get_test_loc('pypi/uv/attrs-uv.lock-expected.json') + self.check_packages_data(package, expected_loc, regen=REGEN_TEST_FIXTURES) + + class TestPipInspectDeplockHandler(PackageTester): test_data_dir = os.path.join(os.path.dirname(__file__), 'data')