Source code for tooz.utils

#    Copyright (C) 2014 Yahoo! Inc. All Rights Reserved.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.

from __future__ import annotations

import base64
from collections.abc import Callable, Collection, Sequence
from concurrent import futures
import datetime
import operator
import os
from typing import Any, NoReturn, ParamSpec, Self, TypeVar

import futurist
import msgpack
from oslo_serialization import msgpackutils
from oslo_utils import excutils

import tooz

P = ParamSpec('P')
R = TypeVar('R')


class Base64LockEncoder:
    def __init__(self, keyspace_url: str, prefix: str = '') -> None:
        self.keyspace_url = keyspace_url
        if prefix:
            self.keyspace_url += prefix

    def check_and_encode(self, name: str | bytes) -> str:
        if not isinstance(name, (str, bytes)):
            raise TypeError(
                "Provided lock name is expected to be a string"
                f" or binary type and not {type(name)}"
            )
        try:
            return self.encode(name)
        except (UnicodeDecodeError, UnicodeEncodeError) as e:
            raise ValueError(
                f"Invalid lock name due to encoding/decoding issue: {e}"
            )

    def encode(self, name: str | bytes) -> str:
        if isinstance(name, str):
            name = name.encode("ascii")
        enc_name = base64.urlsafe_b64encode(name)
        return self.keyspace_url + "/" + enc_name.decode("ascii")


class ProxyExecutor:
    KIND_TO_FACTORY = {
        'threaded': (lambda: futurist.ThreadPoolExecutor(max_workers=1)),
        'synchronous': lambda: futurist.SynchronousExecutor(),
    }

    # Provide a few common aliases...
    KIND_TO_FACTORY['thread'] = KIND_TO_FACTORY['threaded']
    KIND_TO_FACTORY['threading'] = KIND_TO_FACTORY['threaded']
    KIND_TO_FACTORY['sync'] = KIND_TO_FACTORY['synchronous']

    DEFAULT_KIND = 'threaded'

    def __init__(
        self,
        driver_name: str,
        default_executor_factory: Callable[[], futures.Executor],
    ) -> None:
        self.default_executor_factory = default_executor_factory
        self.driver_name = driver_name
        self.started = False
        self.executor: futures.Executor | None = None
        self.internally_owned = True

    @classmethod
    def build(cls, driver_name: str, options: dict[str, Any]) -> Self:
        default_executor_fact = cls.KIND_TO_FACTORY[cls.DEFAULT_KIND]
        if 'executor' in options:
            executor_kind = options['executor']
            try:
                default_executor_fact = cls.KIND_TO_FACTORY[executor_kind]
            except KeyError:
                executors_known = sorted(list(cls.KIND_TO_FACTORY))
                raise tooz.ToozError(
                    f"Unknown executor '{executor_kind}' provided, "
                    f"accepted values are {executors_known}"
                )
        return cls(driver_name, default_executor_fact)

    def start(self) -> None:
        if self.started:
            return
        self.executor = self.default_executor_factory()
        self.started = True

    def stop(self) -> None:
        executor = self.executor
        self.executor = None
        if executor is not None:
            executor.shutdown()
        self.started = False

    def submit(
        self, cb: Callable[P, R], *args: P.args, **kwargs: P.kwargs
    ) -> futures.Future[R]:
        if not self.started:
            raise tooz.ToozError(
                f"{self.driver_name} driver asynchronous executor "
                f"has not been started"
            )

        assert self.executor is not None  # narrow type

        try:
            return self.executor.submit(cb, *args, **kwargs)
        except RuntimeError:
            raise tooz.ToozError(
                f"{self.driver_name} driver asynchronous executor has"
                f"been shutdown"
            )


def safe_abs_path(rooted_at: str, *pieces: str) -> str:
    # Avoids the following junk...
    #
    # >>> import os
    # >>> os.path.join("/b", "..")
    # '/b/..'
    # >>> os.path.abspath(os.path.join("/b", ".."))
    # '/'
    path = os.path.abspath(os.path.join(rooted_at, *pieces))
    if not path.startswith(rooted_at):
        raise ValueError(
            "Unable to create path that is outside of"
            f" parent directory '{rooted_at}' using segments {list(pieces)}"
        )
    return path


# TODO(stephenfin): Tighten the types here and start logging a deprecation
# warning for non-boolean blocking values.
def convert_blocking(
    blocking: bool | float | str, timeout: float | None = None
) -> tuple[bool, float | None]:
    """Converts a multi-type blocking variable into its derivatives."""
    if isinstance(blocking, bool):
        return blocking, timeout

    timeout = float(blocking)
    blocking = True
    return blocking, timeout


def collapse(
    config: dict[str, Any],
    exclude: Collection[str] | None = None,
    item_selector: Callable[[Sequence[Any]], Any] = operator.itemgetter(-1),
) -> dict[str, Any]:
    """Collapses config with keys and **list/tuple** values.

    NOTE(harlowja): The last item/index from the list/tuple value is selected
    be default as the new value (values that are not lists/tuples are left
    alone). If the list/tuple value is empty (zero length), then no value
    is set.
    """
    if not isinstance(config, dict):
        raise TypeError("Unexpected config type, dict expected")
    if not config:
        return {}
    if exclude is None:
        exclude = frozenset()
    collapsed = {}
    for k, v in config.items():
        if isinstance(v, (tuple, list)):
            if k in exclude:
                collapsed[k] = v
            else:
                if len(v):
                    collapsed[k] = item_selector(v)
        else:
            collapsed[k] = v
    return collapsed


def to_binary(text: str | bytes, encoding: str = 'ascii') -> bytes:
    """Return the binary representation of string (if not already binary)."""
    if not isinstance(text, bytes):
        text = text.encode(encoding)
    return text


class SerializationError(tooz.ToozError):
    "Exception raised when serialization or deserialization breaks."


def dumps(
    data: Any, excp_cls: type[tooz.ToozError] = SerializationError
) -> bytes:
    """Serializes provided data using msgpack into a byte string."""
    try:
        return msgpackutils.dumps(data)
    except (msgpack.PackException, ValueError) as e:
        raise_with_cause(excp_cls, str(e), cause=e)


def loads(
    blob: bytes, excp_cls: type[tooz.ToozError] = SerializationError
) -> Any:
    """Deserializes provided data using msgpack (from a prior byte string)."""
    try:
        return msgpackutils.loads(blob)
    except (msgpack.UnpackException, ValueError) as e:
        raise_with_cause(excp_cls, str(e), cause=e)


def millis_to_datetime(milliseconds: str | float) -> datetime.datetime:
    """Converts number of milliseconds (from epoch) into a datetime object."""
    return datetime.datetime.fromtimestamp(float(milliseconds) / 1000)


[docs] def raise_with_cause( exc_cls: type[tooz.ToozError], message: str, *args: Any, **kwargs: Any, ) -> NoReturn: """Helper to raise + chain exceptions (when able) and associate a *cause*. **For internal usage only.** NOTE(harlowja): Since in py3.x exceptions can be chained (due to :pep:`3134`) we should try to raise the desired exception with the given *cause*. :param exc_cls: the :py:class:`~tooz.ToozError` class to raise. :param message: the text/str message that will be passed to the exceptions constructor as its first positional argument. :param args: any additional positional arguments to pass to the exceptions constructor. :param kwargs: any additional keyword arguments to pass to the exceptions constructor. """ if not issubclass(exc_cls, tooz.ToozError): raise ValueError("Subclass of tooz error is required") excutils.raise_with_cause(exc_cls, message, *args, **kwargs)