Files
Hotel-Booking/Backend/venv/lib/python3.12/site-packages/packageurl/__init__.py
Iliyan Angelov 62c1fe5951 updates
2025-12-01 06:50:10 +02:00

676 lines
21 KiB
Python

# Copyright (c) the purl authors
# SPDX-License-Identifier: MIT
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Visit https://github.com/package-url/packageurl-python for support and
# download.
from __future__ import annotations
import dataclasses
import re
import string
from collections import namedtuple
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING
from typing import Any
from typing import Optional
from typing import Union
from typing import overload
from urllib.parse import quote as _percent_quote
from urllib.parse import unquote as _percent_unquote
from urllib.parse import urlsplit as _urlsplit
from packageurl.contrib.route import NoRouteAvailable
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Iterable
from typing import ClassVar
from typing_extensions import Literal
from typing_extensions import Self
AnyStr = Union[str, bytes]
# Python 3
basestring = (bytes, str)
"""
A purl (aka. Package URL) implementation as specified at:
https://github.com/package-url/purl-spec
"""
class ValidationSeverity(str, Enum):
ERROR = "error"
WARNING = "warning"
INFO = "info"
@dataclass
class ValidationMessage:
severity: ValidationSeverity
message: str
to_dict = dataclasses.asdict
def quote(s: AnyStr) -> str:
"""
Return a percent-encoded unicode string, except for colon :, given an `s`
byte or unicode string.
"""
s_bytes = s.encode("utf-8") if isinstance(s, str) else s
quoted = _percent_quote(s_bytes)
if not isinstance(quoted, str):
quoted = quoted.decode("utf-8")
quoted = quoted.replace("%3A", ":")
return quoted
def unquote(s: AnyStr) -> str:
"""
Return a percent-decoded unicode string, given an `s` byte or unicode
string.
"""
unquoted = _percent_unquote(s)
if not isinstance(unquoted, str):
unquoted = unquoted.decode("utf-8")
return unquoted
@overload
def get_quoter(encode: bool = True) -> Callable[[AnyStr], str]: ...
@overload
def get_quoter(encode: None) -> Callable[[str], str]: ...
def get_quoter(encode: bool | None = True) -> Callable[[AnyStr], str] | Callable[[str], str]:
"""
Return quoting callable given an `encode` tri-boolean (True, False or None)
"""
if encode is True:
return quote
elif encode is False:
return unquote
elif encode is None:
return lambda x: x
def normalize_type(type: AnyStr | None, encode: bool | None = True) -> str | None:
if not type:
return None
type_str = type if isinstance(type, str) else type.decode("utf-8")
quoter = get_quoter(encode)
type_str = quoter(type_str)
return type_str.strip().lower() or None
def normalize_namespace(
namespace: AnyStr | None, ptype: str | None, encode: bool | None = True
) -> str | None:
if not namespace:
return None
namespace_str = namespace if isinstance(namespace, str) else namespace.decode("utf-8")
namespace_str = namespace_str.strip().strip("/")
if ptype in (
"bitbucket",
"github",
"pypi",
"gitlab",
"composer",
"luarocks",
"qpkg",
"alpm",
"apk",
"hex",
):
namespace_str = namespace_str.lower()
if ptype and ptype in ("cpan"):
namespace_str = namespace_str.upper()
segments = [seg for seg in namespace_str.split("/") if seg.strip()]
segments_quoted = map(get_quoter(encode), segments)
return "/".join(segments_quoted) or None
def normalize_mlflow_name(
name_str: str,
qualifiers: Union[str, bytes, dict[str, str], None],
) -> Optional[str]:
"""MLflow purl names are case-sensitive for Azure ML, it is case sensitive and must be kept as-is in the package URL
For Databricks, it is case insensitive and must be lowercased in the package URL"""
if isinstance(qualifiers, dict):
repo_url = qualifiers.get("repository_url")
if repo_url and "azureml" in repo_url.lower():
return name_str
if repo_url and "databricks" in repo_url.lower():
return name_str.lower()
if isinstance(qualifiers, str):
if "azureml" in qualifiers.lower():
return name_str
if "databricks" in qualifiers.lower():
return name_str.lower()
return name_str
def normalize_name(
name: AnyStr | None,
qualifiers: Union[Union[str, bytes], dict[str, str], None],
ptype: str | None,
encode: bool | None = True,
) -> Optional[str]:
if not name:
return None
name_str = name if isinstance(name, str) else name.decode("utf-8")
quoter = get_quoter(encode)
name_str = quoter(name_str)
name_str = name_str.strip().strip("/")
if ptype and ptype in ("mlflow"):
return normalize_mlflow_name(name_str, qualifiers)
if ptype in (
"bitbucket",
"github",
"pypi",
"gitlab",
"composer",
"luarocks",
"oci",
"npm",
"alpm",
"apk",
"bitnami",
"hex",
"pub",
):
name_str = name_str.lower()
if ptype == "pypi":
name_str = name_str.replace("_", "-").lower()
if ptype == "hackage":
name_str = name_str.replace("_", "-")
if ptype == "pub":
name_str = re.sub(r"[^a-z0-9]", "_", name_str.lower())
return name_str or None
def normalize_version(
version: AnyStr | None, ptype: Optional[Union[str, bytes]], encode: bool | None = True
) -> str | None:
if not version:
return None
version_str = version if isinstance(version, str) else version.decode("utf-8")
quoter = get_quoter(encode)
version_str = quoter(version_str.strip())
if ptype and isinstance(ptype, str) and ptype in ("huggingface", "oci"):
return version_str.lower()
return version_str or None
@overload
def normalize_qualifiers(
qualifiers: AnyStr | dict[str, str] | None, encode: Literal[True] = ...
) -> str | None: ...
@overload
def normalize_qualifiers(
qualifiers: AnyStr | dict[str, str] | None, encode: Literal[False] | None
) -> dict[str, str]: ...
@overload
def normalize_qualifiers(
qualifiers: AnyStr | dict[str, str] | None, encode: bool | None = ...
) -> str | dict[str, str] | None: ...
def normalize_qualifiers(
qualifiers: AnyStr | dict[str, str] | None, encode: bool | None = True
) -> str | dict[str, str] | None:
"""
Return normalized `qualifiers` as a mapping (or as a string if `encode` is
True). The `qualifiers` arg is either a mapping or a string.
Always return a mapping if decode is True (and never None).
Raise ValueError on errors.
"""
if not qualifiers:
return None if encode else {}
if isinstance(qualifiers, basestring):
qualifiers_str = qualifiers if isinstance(qualifiers, str) else qualifiers.decode("utf-8")
# decode string to list of tuples
qualifiers_list = qualifiers_str.split("&")
if any("=" not in kv for kv in qualifiers_list):
raise ValueError(
f"Invalid qualifier. Must be a string of key=value pairs:{qualifiers_list!r}"
)
qualifiers_parts = [kv.partition("=") for kv in qualifiers_list]
qualifiers_pairs: Iterable[tuple[str, str]] = [(k, v) for k, _, v in qualifiers_parts]
elif isinstance(qualifiers, dict):
qualifiers_pairs = qualifiers.items()
else:
raise ValueError(f"Invalid qualifier. Must be a string or dict:{qualifiers!r}")
quoter = get_quoter(encode)
qualifiers_map = {
k.strip().lower(): quoter(v)
for k, v in qualifiers_pairs
if k and k.strip() and v and v.strip()
}
valid_chars = string.ascii_letters + string.digits + ".-_"
for key in qualifiers_map:
if not key:
raise ValueError("A qualifier key cannot be empty")
if "%" in key:
raise ValueError(f"A qualifier key cannot be percent encoded: {key!r}")
if " " in key:
raise ValueError(f"A qualifier key cannot contain spaces: {key!r}")
if any(c not in valid_chars for c in key):
raise ValueError(
f"A qualifier key must be composed only of ASCII letters and numbers"
f"period, dash and underscore: {key!r}"
)
if key[0] in string.digits:
raise ValueError(f"A qualifier key cannot start with a number: {key!r}")
qualifiers_map = dict(sorted(qualifiers_map.items()))
if not encode:
return qualifiers_map
return _qualifier_map_to_string(qualifiers_map) or None
def _qualifier_map_to_string(qualifiers: dict[str, str]) -> str:
qualifiers_list = [f"{key}={value}" for key, value in qualifiers.items()]
return "&".join(qualifiers_list)
def normalize_subpath(subpath: AnyStr | None, encode: bool | None = True) -> str | None:
if not subpath:
return None
subpath_str = subpath if isinstance(subpath, str) else subpath.decode("utf-8")
quoter = get_quoter(encode)
segments = subpath_str.split("/")
segments = [quoter(s) for s in segments if s.strip() and s not in (".", "..")]
subpath_str = "/".join(segments)
return subpath_str or None
@overload
def normalize(
type: AnyStr | None,
namespace: AnyStr | None,
name: AnyStr | None,
version: AnyStr | None,
qualifiers: AnyStr | dict[str, str] | None,
subpath: AnyStr | None,
encode: Literal[True] = ...,
) -> tuple[str, str | None, str, str | None, str | None, str | None]: ...
@overload
def normalize(
type: AnyStr | None,
namespace: AnyStr | None,
name: AnyStr | None,
version: AnyStr | None,
qualifiers: AnyStr | dict[str, str] | None,
subpath: AnyStr | None,
encode: Literal[False] | None,
) -> tuple[str, str | None, str, str | None, dict[str, str], str | None]: ...
@overload
def normalize(
type: AnyStr | None,
namespace: AnyStr | None,
name: AnyStr | None,
version: AnyStr | None,
qualifiers: AnyStr | dict[str, str] | None,
subpath: AnyStr | None,
encode: bool | None = ...,
) -> tuple[str, str | None, str, str | None, str | dict[str, str] | None, str | None]: ...
def normalize(
type: AnyStr | None,
namespace: AnyStr | None,
name: AnyStr | None,
version: AnyStr | None,
qualifiers: AnyStr | dict[str, str] | None,
subpath: AnyStr | None,
encode: bool | None = True,
) -> tuple[
str | None,
str | None,
str | None,
str | None,
str | dict[str, str] | None,
str | None,
]:
"""
Return normalized purl components
"""
type_norm = normalize_type(type, encode)
namespace_norm = normalize_namespace(namespace, type_norm, encode)
name_norm = normalize_name(name, qualifiers, type_norm, encode)
version_norm = normalize_version(version, type, encode)
qualifiers_norm = normalize_qualifiers(qualifiers, encode)
subpath_norm = normalize_subpath(subpath, encode)
return type_norm, namespace_norm, name_norm, version_norm, qualifiers_norm, subpath_norm
class PackageURL(
namedtuple("PackageURL", ("type", "namespace", "name", "version", "qualifiers", "subpath"))
):
"""
A purl is a package URL as defined at
https://github.com/package-url/purl-spec
"""
SCHEME: ClassVar[str] = "pkg"
type: str
namespace: str | None
name: str
version: str | None
qualifiers: dict[str, str]
subpath: str | None
def __new__(
cls,
type: AnyStr | None = None,
namespace: AnyStr | None = None,
name: AnyStr | None = None,
version: AnyStr | None = None,
qualifiers: AnyStr | dict[str, str] | None = None,
subpath: AnyStr | None = None,
normalize_purl: bool = True,
) -> Self:
required = dict(type=type, name=name)
for key, value in required.items():
if value:
continue
raise ValueError(f"Invalid purl: {key} is a required argument.")
strings = dict(
type=type,
namespace=namespace,
name=name,
version=version,
subpath=subpath,
)
for key, value in strings.items():
if value and isinstance(value, basestring) or not value:
continue
raise ValueError(f"Invalid purl: {key} argument must be a string: {value!r}.")
if qualifiers and not isinstance(qualifiers, (basestring, dict)):
raise ValueError(
f"Invalid purl: qualifiers argument must be a dict or a string: {qualifiers!r}."
)
type_final: str
namespace_final: Optional[str]
name_final: str
version_final: Optional[str]
qualifiers_final: dict[str, str]
subpath_final: Optional[str]
if normalize_purl:
(
type_final,
namespace_final,
name_final,
version_final,
qualifiers_final,
subpath_final,
) = normalize(type, namespace, name, version, qualifiers, subpath, encode=None)
else:
from packageurl.utils import ensure_str
type_final = ensure_str(type) or ""
namespace_final = ensure_str(namespace)
name_final = ensure_str(name) or ""
version_final = ensure_str(version)
if isinstance(qualifiers, dict):
qualifiers_final = qualifiers
else:
qualifiers_final = {}
subpath_final = ensure_str(subpath)
return super().__new__(
cls,
type=type_final,
namespace=namespace_final,
name=name_final,
version=version_final,
qualifiers=qualifiers_final,
subpath=subpath_final,
)
def __str__(self, *args: Any, **kwargs: Any) -> str:
return self.to_string()
def __hash__(self) -> int:
return hash(self.to_string())
def to_dict(self, encode: bool | None = False, empty: Any = None) -> dict[str, Any]:
"""
Return an ordered dict of purl components as {key: value}.
If `encode` is True, then "qualifiers" are encoded as a normalized
string. Otherwise, qualifiers is a mapping.
You can provide a value for `empty` to be used in place of default None.
"""
data = self._asdict()
if encode:
data["qualifiers"] = normalize_qualifiers(self.qualifiers, encode=encode)
for field, value in data.items():
data[field] = value or empty
return data
def to_string(self, encode: bool | None = True) -> str:
"""
Return a purl string built from components.
"""
type, namespace, name, version, qualifiers, subpath = normalize(
self.type,
self.namespace,
self.name,
self.version,
self.qualifiers,
self.subpath,
encode=encode,
)
purl = [self.SCHEME, ":", type, "/"]
if namespace:
purl.extend((namespace, "/"))
purl.append(name)
if version:
purl.append("@")
purl.append(version)
if qualifiers:
purl.append("?")
if isinstance(qualifiers, Mapping):
qualifiers = _qualifier_map_to_string(qualifiers)
purl.append(qualifiers)
if subpath:
purl.append("#")
purl.append(subpath)
return "".join(purl)
def validate(self, strict: bool = False) -> list["ValidationMessage"]:
"""
Validate this PackageURL object and return a list of validation error messages.
"""
from packageurl.validate import DEFINITIONS_BY_TYPE
validator_class = DEFINITIONS_BY_TYPE.get(self.type)
if not validator_class:
return [
ValidationMessage(
severity=ValidationSeverity.ERROR,
message=f"Unexpected purl type: expected {self.type!r}",
)
]
return list(validator_class.validate(purl=self, strict=strict)) # type: ignore[no-untyped-call]
@classmethod
def validate_string(cls, purl: str, strict: bool = False) -> list["ValidationMessage"]:
"""
Validate a PURL string and return a list of validation error messages.
"""
try:
purl_obj = cls.from_string(purl, normalize_purl=not strict)
assert isinstance(purl_obj, PackageURL)
return purl_obj.validate(strict=strict)
except ValueError as e:
return [
ValidationMessage(
severity=ValidationSeverity.ERROR,
message=str(e),
)
]
@classmethod
def from_string(cls, purl: str, normalize_purl: bool = True) -> Self:
"""
Return a PackageURL object parsed from a string.
Raise ValueError on errors.
"""
if not purl or not isinstance(purl, str) or not purl.strip():
raise ValueError("A purl string argument is required.")
scheme, sep, remainder = purl.partition(":")
if not sep or scheme != cls.SCHEME:
raise ValueError(
f'purl is missing the required "{cls.SCHEME}" scheme component: {purl!r}.'
)
# this strip '/, // and /// as possible in :// or :///
remainder = remainder.strip().lstrip("/")
version: str | None # this line is just for type hinting
subpath: str | None # this line is just for type hinting
type_, sep, remainder = remainder.partition("/")
if not type_ or not sep:
raise ValueError(f"purl is missing the required type component: {purl!r}.")
valid_chars = string.ascii_letters + string.digits + ".-_"
if not all(c in valid_chars for c in type_):
raise ValueError(
f"purl type must be composed only of ASCII letters and numbers, period, dash and underscore: {type_!r}."
)
if type_[0] in string.digits:
raise ValueError(f"purl type cannot start with a number: {type_!r}.")
type_ = type_.lower()
original_remainder = remainder
scheme, authority, path, qualifiers_str, subpath = _urlsplit(
url=remainder, scheme="", allow_fragments=True
)
# The spec (seems) to allow colons in the name and namespace.
# urllib.urlsplit splits on : considers them parts of scheme
# and authority.
# Other libraries do not care about this.
# See https://github.com/package-url/packageurl-python/issues/152#issuecomment-2637692538
# We do + ":" + to put the colon back that urlsplit removed.
if authority:
path = authority + ":" + path
if scheme:
# This is a way to preserve the casing of the original scheme
original_scheme = original_remainder.split(":", 1)[0]
path = original_scheme + ":" + path
path = path.lstrip("/")
namespace: str | None = ""
# NPM purl have a namespace in the path
# and the namespace in an npm purl is
# different from others because it starts with `@`
# so we need to handle this case separately
if type_ == "npm" and path.startswith("@"):
namespace, sep, path = path.partition("/")
remainder, sep, version = path.rpartition("@")
if not sep:
remainder = version
version = None
ns_name = remainder.strip().strip("/")
ns_name_parts = ns_name.split("/")
ns_name_parts = [seg for seg in ns_name_parts if seg and seg.strip()]
name = ""
if not namespace and len(ns_name_parts) > 1:
name = ns_name_parts[-1]
ns = ns_name_parts[:-1]
namespace = "/".join(ns)
elif len(ns_name_parts) == 1:
name = ns_name_parts[0]
if not name:
raise ValueError(f"purl is missing the required name component: {purl!r}")
if normalize_purl:
type_, namespace, name, version, qualifiers, subpath = normalize(
type_,
namespace,
name,
version,
qualifiers_str,
subpath,
encode=False,
)
else:
qualifiers = normalize_qualifiers(qualifiers_str, encode=False) or {}
return cls(
type_, namespace, name, version, qualifiers, subpath, normalize_purl=normalize_purl
)