Skip to content

Commit 8b75ff5

Browse files
authored
Merge pull request #179 from sw360/177-support-uvlock
Support uv.lock
2 parents 26c4d62 + c1892f0 commit 8b75ff5

File tree

7 files changed

+1000
-22
lines changed

7 files changed

+1000
-22
lines changed

ChangeLog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
* `project prerequisites` now has a summary at the end of the output to show how many
2222
components have been scanned and how many warnings and errors there are.
2323
* Adapt `getdependencies python` to the Poetry 2.x pyproject.toml format.
24+
* `getdependencies python` now also supports uv and its `uv.lock` file.
2425

2526
## 2.9.1
2627

capycli/bom/show_bom.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
import sys
1515
from typing import Any
1616

17-
from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression
1817
from cyclonedx.model import XsUri
1918
from cyclonedx.model.bom import Bom
2019
from cyclonedx.model.component import Component
20+
from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression
2121

2222
import capycli.common.script_base
2323
from capycli.common.capycli_bom_support import CaPyCliBom, CycloneDxSupport

capycli/dependencies/python.py

Lines changed: 113 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class InputFileType(str, Enum):
4141
REQUIREMENTS = "requirements"
4242
# Poetry lock file ("poetry.lock")
4343
POETRY_LOCK = "poetry.lock"
44+
# uv lock file ("uv.lock")
45+
UV_LOCK = "uv.lock"
4446

4547

4648
@dataclass
@@ -406,11 +408,15 @@ def determine_file_type(self, full_filename: str) -> InputFileType:
406408
return InputFileType.REQUIREMENTS
407409

408410
if (filename == "poetry.lock"):
409-
data = self.read_poetry_lock_file(full_filename)
411+
data = self.read_lock_file(full_filename, filename)
410412
if data:
411413
LOG.debug("Guessing poetry.lock file")
412414
return InputFileType.POETRY_LOCK
413415

416+
if (filename == "uv.lock"):
417+
LOG.debug("Guessing uv.lock file")
418+
return InputFileType.UV_LOCK
419+
414420
# default
415421
LOG.debug("Use default type: requirements file")
416422
return InputFileType.REQUIREMENTS
@@ -435,27 +441,33 @@ def read_toml_file(self, filename: str, err_hint: str = "") -> Dict[str, Any]:
435441

436442
return {}
437443

438-
def read_poetry_lock_file(self, filename: str) -> Dict[str, Any]:
444+
def read_lock_file(self, filename: str, hint: str) -> Dict[str, Any]:
439445
"""
440446
Ready a poetry.lock file, a TOML file.
441447
"""
442-
return self.read_toml_file(filename, "poetry.lock")
448+
return self.read_toml_file(filename, hint)
443449

444450
def read_pyproject_file(self, filename: str) -> Dict[str, Any]:
445451
"""
446452
Ready a pyproject.toml file, a TOML file.
447453
"""
448454
return self.read_toml_file(filename, "pyproject.toml")
449455

450-
def get_all_lock_file_entries(self, lock_filename: str) -> List[LockFileEntry]:
456+
def get_all_poetry_lock_file_entries(self, lock_filename: str) -> List[LockFileEntry]:
451457
"""Extract information about *all* dependencies from the lock file."""
452-
poetry_lock = self.read_poetry_lock_file(lock_filename)
453-
poetry_lock_metadata = poetry_lock["metadata"]
458+
poetry_lock = self.read_lock_file(lock_filename, "poetry.lock")
459+
460+
if "metadata" in poetry_lock:
461+
poetry_lock_metadata = poetry_lock["metadata"]
462+
else:
463+
poetry_lock_metadata = {
464+
"lock-version": "2.1"
465+
}
454466
entry_list: List[LockFileEntry] = []
455467
try:
456468
poetry_lock_version = tuple(int(p) for p in str(poetry_lock_metadata["lock-version"]).split("."))
457469
except Exception:
458-
poetry_lock_version = (0,)
470+
poetry_lock_version = tuple((2, 0))
459471
LOG.debug(f"poetry_lock_version: {poetry_lock_version}")
460472

461473
for package in poetry_lock["package"]:
@@ -469,13 +481,15 @@ def get_all_lock_file_entries(self, lock_filename: str) -> List[LockFileEntry]:
469481
continue
470482

471483
entry = LockFileEntry(name, version, description, [], [], False)
472-
package_files = package["files"] \
473-
if poetry_lock_version >= (2,) \
474-
else poetry_lock_metadata["files"][package["name"]]
475-
for file_metadata in package_files:
476-
entry.files.append(FileEntry(
477-
file_metadata["file"],
478-
file_metadata["hash"]))
484+
if "files" in package:
485+
# poetry.lock version 2.x
486+
package_files = package["files"] \
487+
if poetry_lock_version >= (2,) \
488+
else poetry_lock_metadata["files"][package["name"]]
489+
for file_metadata in package_files:
490+
entry.files.append(FileEntry(
491+
file_metadata["file"],
492+
file_metadata["hash"]))
479493

480494
for dep in package.get("dependencies", []):
481495
dep_name = GetPythonDependencies.normalize_packagename(dep)
@@ -486,6 +500,35 @@ def get_all_lock_file_entries(self, lock_filename: str) -> List[LockFileEntry]:
486500

487501
return entry_list
488502

503+
def get_all_uv_lock_file_entries(self, lock_filename: str) -> List[LockFileEntry]:
504+
"""Extract information about *all* dependencies from the lock file."""
505+
uv_lock = self.read_lock_file(lock_filename, "uv.lock")
506+
507+
entry_list: List[LockFileEntry] = []
508+
try:
509+
uv_lock_version = tuple((uv_lock.get("version", 0), uv_lock.get("revision", 0)))
510+
except Exception:
511+
uv_lock_version = tuple((1, 3))
512+
LOG.debug(f"uv_lock_version: {uv_lock_version}")
513+
514+
for package in uv_lock["package"]:
515+
name = GetPythonDependencies.normalize_packagename(package.get("name", "").strip())
516+
version = package.get("version", "").strip()
517+
# no description in uv.lock
518+
LOG.debug(f" Processing raw package: {name}, {version}")
519+
520+
entry = LockFileEntry(name, version, "", [], [], False)
521+
# no files in uv.lock
522+
523+
for dep in package.get("dependencies", []):
524+
dep_name = GetPythonDependencies.normalize_packagename(dep["name"])
525+
LOG.debug(f" Dependency: {dep_name}")
526+
entry.dependencies.append(dep_name)
527+
528+
entry_list.append(entry)
529+
530+
return entry_list
531+
489532
def find_lock_entry(self, name: str, entries: List[LockFileEntry]) -> Optional[LockFileEntry]:
490533
for entry in entries:
491534
if name == entry.name:
@@ -528,16 +571,17 @@ def get_lock_file_entries_for_sbom(self,
528571
# => return all dependencies
529572
return all_entries
530573

531-
poetry2xflag = False
574+
new_pyproject_format = False
532575
if "project" in pyproject_info:
576+
# this is for poetry >= 2.0 and uv
533577
cfg = pyproject_info["project"]
534-
poetry2xflag = True
578+
new_pyproject_format = True
535579
else:
536580
cfg = pyproject_info["tool"]["poetry"]
537581
# get only real dependencies
538582
dependencies = cfg.get("dependencies", [])
539583
for dep in dependencies:
540-
if poetry2xflag:
584+
if new_pyproject_format:
541585
dep = self.get_pure_dep_name(dep)
542586
dep_name = GetPythonDependencies.normalize_packagename(dep)
543587
if dep_name.lower() == "python":
@@ -568,7 +612,56 @@ def sbom_from_poetry_lock_file(self, filename: str, search_meta_data: bool, pack
568612
pyproject_file = os.path.join(folder, "pyproject.toml")
569613
creator = SbomCreator()
570614
sbom = creator.create([], addlicense=True, addprofile=True, addtools=True)
571-
entry_list_all = self.get_all_lock_file_entries(filename)
615+
entry_list_all = self.get_all_poetry_lock_file_entries(filename)
616+
entry_list = self.get_lock_file_entries_for_sbom(pyproject_file, entry_list_all)
617+
for package in entry_list:
618+
purl = PackageURL(type="pypi", name=package.name, version=package.version)
619+
cxcomp = Component(
620+
name=package.name,
621+
version=package.version,
622+
purl=purl,
623+
bom_ref=purl.to_string(),
624+
description=package.description)
625+
626+
prop = Property(
627+
name=CycloneDxSupport.CDX_PROP_LANGUAGE,
628+
value="Python")
629+
cxcomp.properties.add(prop)
630+
631+
if search_meta_data:
632+
self.add_meta_data_to_bomitem(cxcomp, package_source)
633+
else:
634+
LOG.debug(" Processing package_files")
635+
for file_metadata in package.files:
636+
LOG.debug(f" Processing file_metadata: {file_metadata}")
637+
try:
638+
cxcomp.external_references.add(ExternalReference(
639+
type=ExternalReferenceType.DISTRIBUTION,
640+
url=XsUri(cxcomp.get_pypi_url()),
641+
# comment=f'Distribution file: {file_metadata.file}',
642+
comment=CaPyCliBom.BINARY_URL_COMMENT,
643+
hashes=[HashType.from_composite_str(file_metadata.hash)]
644+
))
645+
except Exception as ex:
646+
# IGNORE
647+
LOG.debug(" Ignored error: " + repr(ex))
648+
pass
649+
650+
sbom.components.add(cxcomp)
651+
652+
return sbom
653+
654+
def sbom_from_uv_lock_file(self, filename: str, search_meta_data: bool, package_source: str = "") -> Bom:
655+
folder = os.path.dirname(filename)
656+
657+
if self.proj_file_override:
658+
# override for unit tests
659+
pyproject_file = self.proj_file_override
660+
else:
661+
pyproject_file = os.path.join(folder, "pyproject.toml")
662+
creator = SbomCreator()
663+
sbom = creator.create([], addlicense=True, addprofile=True, addtools=True)
664+
entry_list_all = self.get_all_uv_lock_file_entries(filename)
572665
entry_list = self.get_lock_file_entries_for_sbom(pyproject_file, entry_list_all)
573666
for package in entry_list:
574667
purl = PackageURL(type="pypi", name=package.name, version=package.version)
@@ -700,6 +793,8 @@ def run(self, args: Any) -> None:
700793
datatype = self.determine_file_type(args.inputfile)
701794
if datatype == InputFileType.POETRY_LOCK:
702795
sbom = self.sbom_from_poetry_lock_file(args.inputfile, args.search_meta_data, args.package_source)
796+
elif datatype == InputFileType.UV_LOCK:
797+
sbom = self.sbom_from_uv_lock_file(args.inputfile, args.search_meta_data, args.package_source)
703798
else:
704799
print_text("Reading input file " + args.inputfile)
705800
package_list = self.requirements_to_package_list(args.inputfile)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# SPDX-FileCopyrightText: (c) 2018-2025 Siemens
2+
# SPDX-License-Identifier: MIT
3+
4+
[project]
5+
name = "capycli"
6+
version = "2.9.1"
7+
description = "CaPyCli - Clearing Automation Python Command Line Interface for SW360"
8+
readme="Readme.md"
9+
requires-python = ">=3.11,<3.15"
10+
license = "MIT"
11+
authors = [
12+
{name = "Thomas Graf", email="thomas.graf@siemens.com"},
13+
{name = "Gernot Hillier", email="gernot.hillier@siemens.com"}
14+
]
15+
keywords = ["sw360", "cli, automation", "license", "compliance", "clearing"]
16+
license-files = [
17+
"License.md"
18+
]
19+
classifiers = [
20+
"Development Status :: 5 - Production/Stable",
21+
"Intended Audience :: Developers",
22+
"Natural Language :: English",
23+
"Operating System :: OS Independent",
24+
"Programming Language :: Python :: 3 :: Only",
25+
]
26+
27+
dependencies = [
28+
"colorama (>=0.4.3,<0.5.0)",
29+
"requests (>=2.31.0,<3.0.0)",
30+
"semver (==3.0.2)",
31+
"packageurl-python (>=0.15.6,<0.16.0)",
32+
"pyjwt (>=2.4.0,<3.0.0)",
33+
"openpyxl (>=3.0.3,<4.0.0)",
34+
"requirements-parser (==0.11.0)",
35+
"sw360 (>=1.8.1,<2.0.0)",
36+
"wheel (>=0.38.4,<0.39.0)",
37+
"cli-support (==2.0.1)",
38+
"chardet (==5.2.0)",
39+
"cyclonedx-python-lib (>=11.4.0,<12.0.0)",
40+
"dateparser (>=1.1.8,<2.0.0)",
41+
"urllib3 (>=2.5.0,<3.0.0)",
42+
"importlib-resources (>=5.12.0,<6.0.0)",
43+
"beautifulsoup4 (>=4.11.1,<5.0.0)",
44+
"jsonschema (>=4.23.0,<5.0.0)",
45+
"validation (>=0.8.3,<0.9.0)"]
46+
47+
[project.urls]
48+
repository = "https://github.com/sw360/capycli"
49+
homepage = "https://github.com/sw360/capycli"
50+
issues = "https://github.com/sw360/capycli/issues"
51+
52+
[project.scripts]
53+
capycli = "capycli.main.cli:main"
54+
55+
[tool.poetry]
56+
include = [
57+
"License.md",
58+
{ path = "capycli/data/granularity_list.csv", format = "wheel" },
59+
{ path = "capycli/data/__init__.py", format = "wheel" },
60+
{ path = "capycli/data/granularity_list.csv", format = "sdist" },
61+
{ path = "capycli/data/__init__.py", format = "sdist" },
62+
]
63+
64+
[tool.poetry.group.dev.dependencies]
65+
coverage = "^5.4"
66+
responses = "0.24.1"
67+
pytest = "7.4.3"
68+
cli-test-helpers = "^3.1.0"
69+
isort = "^5.12.0"
70+
mypy = "^1.8.0"
71+
types-colorama = "^0.4.15.12"
72+
types-urllib3 = "^1.26.25.14"
73+
types-openpyxl = "^3.1.0.32"
74+
types-python-dateutil = "^2.8.19.14"
75+
types-requests = "2.31.0.6" # this is the last version that uses urllib3 < 2
76+
types-beautifulsoup4 = "^4.12.0.20240106"
77+
codespell = "^2.2.6"
78+
pyinstaller = "^6.17.0"
79+
flake8 = "^7.3.0"
80+
81+
[tool.pytest.ini_options]
82+
filterwarnings = [
83+
# note the use of single quote below to denote "raw" strings in TOML
84+
"ignore:pkg_resources is deprecated as an API:DeprecationWarning",
85+
"ignore:Both `id` and `name` have been supplied - `name` will be ignored!",
86+
# cyclonedx-python-lib - UserWarning: The Component this BOM is describing None...
87+
"ignore::UserWarning",
88+
# cyclonedx-python-lib - DeprecationWarning: `@.tools` is deprecated from CycloneDX v1.5 onwards
89+
"ignore::DeprecationWarning"
90+
]
91+
92+
[tool.mypy]
93+
exclude = [
94+
"/tests",
95+
]
96+
97+
show_error_codes = true
98+
pretty = true
99+
100+
warn_unreachable = true
101+
allow_redefinition = true
102+
103+
### Strict mode ###
104+
warn_unused_configs = true
105+
disallow_subclassing_any = true
106+
107+
disallow_any_generics = true
108+
disallow_untyped_calls = true
109+
disallow_untyped_defs = true
110+
disallow_incomplete_defs = true
111+
check_untyped_defs = true
112+
disallow_untyped_decorators = true
113+
no_implicit_optional = true
114+
warn_redundant_casts = true
115+
warn_unused_ignores = true
116+
no_implicit_reexport = true
117+
118+
[tool.codespell]
119+
skip = "./htmlcov/*,./_internal_tests_/*,./__internal__/*,./tests/fixtures/*,*.svg,./capycli/data/granularity_list.csv,./ComponentCache.*,./build/*,./dist/*"
120+
ignore-words-list = "manuel, assertIn,datas"

0 commit comments

Comments
 (0)