Skip to content

Commit 782e381

Browse files
saquibsaifeeclaude
andcommitted
refactor: remove packageurl-python from runtime dependencies
Remove the packageurl-python external dependency from runtime while maintaining backward compatibility in tests. The library now stores and returns PURL as strings instead of PackageURL objects, aligning with the CycloneDX specification which treats PURL as an opaque string. Changes: - Component.purl now stores and returns Optional[str] instead of Optional[PackageURL] - Purl setter converts any __str__-castable input (including PackageURL objects) to string for backward compatibility - Removed PackageUrl serialization helper class - Removed ComparablePackageURL internal utility class - Updated Bom.get_component_by_purl() signature to accept Optional[str] - Removed packageurl-python from runtime dependencies in pyproject.toml - Added packageurl-python as dev dependency for backward compatibility testing - Updated examples to use string PURL format All 6531 tests pass; tox validation successful across Python 3.9, 3.12, and 3.13. BREAKING CHANGE: Component.purl type changed from Optional[PackageURL] to Optional[str] - Code accessing .type, .namespace, .version, .qualifiers, .subpath will break with AttributeError - Bom.get_component_by_purl() now requires string argument instead of PackageURL object - Code must update to work with PURL as string, e.g., parse using purl-spec compliant parsing Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 90fad1b commit 782e381

8 files changed

Lines changed: 13 additions & 69 deletions

File tree

cyclonedx/_internal/compare.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@
2222
"""
2323

2424
from itertools import zip_longest
25-
from typing import TYPE_CHECKING, Any, Optional
26-
27-
if TYPE_CHECKING: # pragma: no cover
28-
from packageurl import PackageURL
25+
from typing import Any, Optional
2926

3027

3128
class ComparableTuple(tuple[Optional[Any], ...]):
@@ -65,18 +62,3 @@ class ComparableDict(ComparableTuple):
6562

6663
def __new__(cls, d: dict[Any, Any]) -> 'ComparableDict':
6764
return super().__new__(cls, sorted(d.items()))
68-
69-
70-
class ComparablePackageURL(ComparableTuple):
71-
"""
72-
Allows comparison of PackageURL, allowing for qualifiers.
73-
"""
74-
75-
def __new__(cls, p: 'PackageURL') -> 'ComparablePackageURL':
76-
return super().__new__(cls, (
77-
p.type,
78-
p.namespace,
79-
p.version,
80-
ComparableDict(p.qualifiers) if isinstance(p.qualifiers, dict) else p.qualifiers,
81-
p.subpath
82-
))

cyclonedx/model/bom.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from datetime import datetime
2121
from enum import Enum
2222
from itertools import chain
23-
from typing import TYPE_CHECKING, Optional, Union
23+
from typing import Optional, Union
2424
from uuid import UUID, uuid4
2525
from warnings import warn
2626

@@ -54,9 +54,6 @@
5454
from .tool import Tool, ToolRepository, _ToolRepositoryHelper
5555
from .vulnerability import Vulnerability
5656

57-
if TYPE_CHECKING: # pragma: no cover
58-
from packageurl import PackageURL
59-
6057

6158
@serializable.serializable_enum
6259
class TlpClassification(str, Enum):
@@ -694,13 +691,13 @@ def definitions(self) -> Optional[Definitions]:
694691
def definitions(self, definitions: Definitions) -> None:
695692
self._definitions = definitions
696693

697-
def get_component_by_purl(self, purl: Optional['PackageURL']) -> Optional[Component]:
694+
def get_component_by_purl(self, purl: Optional[str]) -> Optional[Component]:
698695
"""
699696
Get a Component already in the Bom by its PURL
700697
701698
Args:
702699
purl:
703-
An instance of `packageurl.PackageURL` to look and find `Component`.
700+
A PURL string to look and find `Component`.
704701
705702
Returns:
706703
`Component` or `None`

cyclonedx/model/component.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,15 @@
1919
import sys
2020
from collections.abc import Iterable
2121
from enum import Enum
22-
from typing import Any, Optional, Protocol, Union
22+
from typing import Any, Optional, Union
2323
from warnings import warn
2424

2525
if sys.version_info >= (3, 13):
2626
from warnings import deprecated
2727
else:
2828
from typing_extensions import deprecated
2929

30-
# See https://github.com/package-url/packageurl-python/issues/65
3130
import py_serializable as serializable
32-
from packageurl import PackageURL
3331
from sortedcontainers import SortedSet
3432

3533
from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str
@@ -949,13 +947,6 @@ def __str__(self) -> str:
949947
return self._id
950948

951949

952-
953-
954-
class _StringCastable(Protocol):
955-
956-
def __str__(self) -> str: ...
957-
958-
959950
@serializable.serializable_class(ignore_unknown_during_deserialization=True)
960951
class Component(Dependable):
961952
"""
@@ -980,11 +971,10 @@ def for_file(absolute_file_path: str, path_for_bom: Optional[str]) -> 'Component
980971
component = ComponentBuilder().make_for_file(absolute_file_path, name=path_for_bom)
981972
sha1_hash = next((h.content for h in component.hashes if h.alg is HashAlgorithm.SHA_1), None)
982973
assert sha1_hash is not None
983-
component.version = f'0.0.0-{sha1_hash[0:12]}'
984-
component.purl = PackageURL( # DEPRECATED: a file has no PURL!
985-
type='generic', name=path_for_bom if path_for_bom else absolute_file_path,
986-
version=f'0.0.0-{sha1_hash[0:12]}'
987-
)
974+
version = f'0.0.0-{sha1_hash[0:12]}'
975+
name = path_for_bom if path_for_bom else absolute_file_path
976+
component.version = version
977+
component.purl = f'pkg:generic/{name}@{version}' # DEPRECATED: a file has no PURL!
988978
return component
989979

990980
def __init__(
@@ -1002,7 +992,7 @@ def __init__(
1002992
hashes: Optional[Iterable[HashType]] = None,
1003993
licenses: Optional[Iterable[License]] = None,
1004994
copyright: Optional[str] = None,
1005-
purl: Optional[_StringCastable] = None,
995+
purl: Optional[str] = None,
1006996
external_references: Optional[Iterable[ExternalReference]] = None,
1007997
properties: Optional[Iterable[Property]] = None,
1008998
release_notes: Optional[ReleaseNotes] = None,
@@ -1398,7 +1388,7 @@ def purl(self) -> Optional[str]:
13981388
return self._purl
13991389

14001390
@purl.setter
1401-
def purl(self, purl: Optional[_StringCastable]) -> None:
1391+
def purl(self, purl: Optional[str]) -> None:
14021392
self._purl = None if purl is None else str(purl)
14031393

14041394
@property

cyclonedx/model/component_evidence.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from warnings import warn
2525
from xml.etree.ElementTree import Element as XmlElement # nosec B405
2626

27-
# See https://github.com/package-url/packageurl-python/issues/65
2827
import py_serializable as serializable
2928
from sortedcontainers import SortedSet
3029

cyclonedx/serialization/__init__.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@
2424
from typing import Any, Optional
2525
from uuid import UUID
2626

27-
# See https://github.com/package-url/packageurl-python/issues/65
28-
from packageurl import PackageURL
2927
from py_serializable.helpers import BaseHelper
3028

3129
if sys.version_info >= (3, 13):
@@ -57,25 +55,6 @@ def deserialize(cls, o: Any) -> BomRef:
5755
return BomRef.deserialize(o)
5856

5957

60-
class PackageUrl(BaseHelper):
61-
62-
@classmethod
63-
def serialize(cls, o: Any, ) -> str:
64-
if isinstance(o, PackageURL):
65-
return str(o.to_string())
66-
raise SerializationOfUnexpectedValueException(
67-
f'Attempt to serialize a non-PackageURL: {o!r}')
68-
69-
@classmethod
70-
def deserialize(cls, o: Any) -> PackageURL:
71-
try:
72-
return PackageURL.from_string(purl=str(o))
73-
except ValueError as err:
74-
raise CycloneDxDeserializationException(
75-
f'PURL string supplied does not parse: {o!r}'
76-
) from err
77-
78-
7958
class UrnUuidHelper(BaseHelper):
8059

8160
@classmethod

examples/complex_serialize.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@
1818
import sys
1919
from typing import TYPE_CHECKING
2020

21-
from packageurl import PackageURL
22-
2321
from cyclonedx.contrib.license.factories import LicenseFactory
2422
from cyclonedx.contrib.this.builders import this_component as cdx_lib_component
2523
from cyclonedx.exception import MissingOptionalDependencyException
@@ -68,7 +66,7 @@
6866
urls=[XsUri('https://www.acme.org')]
6967
),
7068
bom_ref='myComponent@1.33.7-beta.1',
71-
purl=PackageURL('generic', 'acme', 'some-component', '1.33.7-beta.1')
69+
purl='pkg:generic/acme/some-component@1.33.7-beta.1'
7270
)
7371
bom.components.add(component1)
7472
bom.register_dependency(root_component, [component1])

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ keywords = [
7070

7171
[tool.poetry.dependencies]
7272
python = "^3.9"
73-
packageurl-python = ">=0.11, <2"
7473
py-serializable = "^2.1.0"
7574
sortedcontainers = "^2.4.0"
7675
license-expression = "^30"
@@ -85,6 +84,7 @@ json-validation = ["jsonschema", "referencing"]
8584
xml-validation = ["lxml"]
8685

8786
[tool.poetry.group.dev.dependencies]
87+
packageurl-python = ">=0.11, <2"
8888
ddt = "1.7.2"
8989
coverage = "7.10.7"
9090
flake8 = "7.3.0"

tests/test_component.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ def test_from_xml_file_with_path_for_bom(self) -> None:
7373
self.assertEqual(c.purl, str(purl))
7474
self.assertEqual(len(c.hashes), 1)
7575

76-
7776
def test_purl_casted_to_string(self) -> None:
7877
purl = PackageURL(type='pypi', name='example', version='1.2.3')
7978
component = Component(name='example', purl=purl)

0 commit comments

Comments
 (0)