diff --git a/kazoo/client.py b/kazoo/client.py index 4877f3580..dc775ba87 100644 --- a/kazoo/client.py +++ b/kazoo/client.py @@ -99,10 +99,7 @@ _RETRY_COMPAT_DEFAULTS = dict( - max_retries=None, - retry_delay=0.1, - retry_backoff=2, - retry_max_delay=3600, + max_retries=None, retry_delay=0.1, retry_backoff=2, retry_max_delay=3600 ) _RETRY_COMPAT_MAPPING = dict( @@ -177,7 +174,6 @@ def __init__( check_hostname: bool = False, ) -> None: ... - # FIXME This should be deprecated then killed @overload @deprecated( "Passing retry configuration parameters directly to the client" @@ -332,11 +328,7 @@ def __init__( self.logger = logger or log # Record the handler strategy used - # FIXME we need to work on making SequentialGeventHandler being an - # IHandler to make this cleaner but for now this will do. - self.handler = cast( - "IHandler", handler if handler else SequentialThreadingHandler() - ) + self.handler = handler if handler else SequentialThreadingHandler() if inspect.isclass(self.handler): raise ConfigurationError( "Handler must be an instance of a class, " @@ -346,16 +338,8 @@ def __init__( self.auth_data = set(auth_data if auth_data else []) self.default_acl = default_acl self.randomize_hosts = randomize_hosts - # FIXME Note: hosts and chroot are set by set_hosts, which also checks - # for chroot changes at runtime, so we initialize them to None here to - # avoid confusion with the empty string that set_hosts would set them - # to. This is massively hacky as set_hosts is only called from here - # anyway, but I want to make this change minimally invasive. - # we should really do self.hosts, self.chroot = self.set_hosts(hosts) - # and have set_hosts return the hosts and chroot - self.hosts: list[tuple[str, int]] = None # type: ignore[assignment] - self.chroot: str = None # type: ignore[assignment] - self.set_hosts(hosts) + + self.hosts, self.chroot = self._collect_hosts(hosts) self.use_ssl = use_ssl self.verify_certs = verify_certs @@ -395,74 +379,54 @@ def __init__( self._stopped.set() self._writer_stopped.set() - # FIXME This is kind of gross but we need to set these to something so - # that the type checker will understand that they are set by the time - # they are used and that they have the right type. - # We would do better to use a few variables/functions instead of - # overloading self.retry but this is a bit less invasive to the code - # and the type checker can understand it with a few hacks - self.retry: KazooRetry = None # type: ignore[assignment] - self._conn_retry: KazooRetry = None # type: ignore[assignment] - - if type(connection_retry) is dict: - self._conn_retry = KazooRetry(**connection_retry) - elif type(connection_retry) is KazooRetry: - self._conn_retry = connection_retry - - if type(command_retry) is dict: - self.retry = KazooRetry(**command_retry) - elif type(command_retry) is KazooRetry: - self.retry = command_retry - - if type(self._conn_retry) is KazooRetry: + old_retry_keys = dict(_RETRY_COMPAT_DEFAULTS) + for key in old_retry_keys: + try: + old_retry_keys[key] = cast( + "dict[str, float | None]", kwargs + ).pop(key) + warnings.warn( + "Passing retry configuration param %s to the " + "client directly is deprecated, please pass a " + "configured retry object (using param %s)" + % (key, _RETRY_COMPAT_MAPPING[key]), + DeprecationWarning, + stacklevel=2, + ) + except KeyError: + pass + + retry_keys: dict[str, Any] = {} + for oldname, value in old_retry_keys.items(): + retry_keys[_RETRY_COMPAT_MAPPING[oldname]] = value + retry_keys["sleep_func"] = self.handler.sleep_func + + def make_retry( + retry: KazooRetry | KazooRetryParams | None, + ) -> KazooRetry: + if isinstance(retry, dict): + return KazooRetry(**retry) + if isinstance(retry, KazooRetry): + return retry + return KazooRetry(**retry_keys) + + self._conn_retry = make_retry(connection_retry) + self._retry = make_retry(command_retry) + + if self._conn_retry is not None: if self.handler.sleep_func != self._conn_retry.sleep_func: raise ConfigurationError( "Retry handler and event handler " " must use the same sleep func" ) - if type(self.retry) is KazooRetry: - if self.handler.sleep_func != self.retry.sleep_func: + if self._retry is not None: + if self.handler.sleep_func != self._retry.sleep_func: raise ConfigurationError( "Command retry handler and event handler " "must use the same sleep func" ) - if self.retry is None or self._conn_retry is None: - # Note: because of the hacks at line 280, mypy thinks this is - # unreachable - old_retry_keys = dict( # type: ignore[unreachable] - _RETRY_COMPAT_DEFAULTS - ) - for key in old_retry_keys: - try: - old_retry_keys[key] = kwargs.pop(key) - warnings.warn( - "Passing retry configuration param %s to the " - "client directly is deprecated, please pass a " - "configured retry object (using param %s)" - % (key, _RETRY_COMPAT_MAPPING[key]), - DeprecationWarning, - stacklevel=2, - ) - except KeyError: - pass - - retry_keys = {} - for oldname, value in old_retry_keys.items(): - retry_keys[_RETRY_COMPAT_MAPPING[oldname]] = value - - if self._conn_retry is None: - self._conn_retry = KazooRetry( - sleep_func=self.handler.sleep_func, - **retry_keys, - ) - if self.retry is None: - self.retry = KazooRetry( - sleep_func=self.handler.sleep_func, - **retry_keys, - ) - # Managing legacy SASL options for scheme, auth in self.auth_data: if scheme != "sasl": @@ -506,7 +470,6 @@ def __init__( # Every retry call should have its own copy of the retry helper # to avoid shared retry counts - self._retry = self.retry def _retry( func: Callable[GenericArgs, KazooRetry.RETRY_RETURN], @@ -515,14 +478,7 @@ def _retry( ) -> KazooRetry.RETRY_RETURN: return self._retry.copy()(func, *args, **kwargs) - # FIXME - # (expression has type "Callable[[VarArg(Any), KwArg(Any)], Any]", - # variable has type "KazooRetry") so basically self.retry needs to be - # set to that and then the type checker will understand that - # self.retry.copy() is a valid call. This is just a mess and needs the - # code rearranging to be more mypy friendly but this is the least - # invasive way to do it for now - self.retry = _retry # type: ignore[assignment] + self.retry = _retry self.Barrier = partial(Barrier, self) self.Counter = partial(Counter, self) @@ -607,10 +563,14 @@ def connected(self) -> bool: established.""" return self._live.is_set() + def _collect_hosts( + self, hosts: str | list[str] + ) -> tuple[list[tuple[str, int]], str]: + new_hosts, chroot = collect_hosts(hosts) + return new_hosts, normpath(chroot) + def set_hosts( - self, - hosts: str | list[str], - randomize_hosts: bool | None = None, + self, hosts: str | list[str], randomize_hosts: bool | None = None ) -> None: """sets the list of hosts used by this client. @@ -635,25 +595,18 @@ def set_hosts( zookeeper server cluster has undefined behavior. """ - # Change the client setting for randomization if specified + # Randomizing the list will be done at connect time if randomize_hosts is not None: self.randomize_hosts = randomize_hosts - # Randomizing the list will be done at connect time - self.hosts, chroot = collect_hosts(hosts) + self.hosts, chroot = self._collect_hosts(hosts) - if chroot: - new_chroot = normpath(chroot) - else: - new_chroot = "" - - if self.chroot is not None and new_chroot != self.chroot: + if chroot != self.chroot: raise ConfigurationError( "Changing chroot at runtime is not currently supported" ) - - self.chroot = new_chroot + self.chroot = chroot def add_listener(self, listener: ListenerFunc) -> None: """Add a function to be called for connection state changes. @@ -981,29 +934,12 @@ def _try_fetch() -> tuple[int, ...] | None: except ValueError: return None - def _is_valid(version: tuple[int, ...] | None) -> bool: - # All zookeeper versions should have at least major.minor - # version numbers; if we get one that doesn't it is likely not - # correct and was truncated... - if version and len(version) > 1: - return True - return False - - # FIXME A better way of doing this would be to put the initial - # _try_fetch in the loop and inline _is_valid but I want to minimise - # code changes - # Try 1 + retries amount of times to get a version that we know # will likely be acceptable... - version = _try_fetch() - if _is_valid(version): - # mypy doesn't recognise that _is_valid guarantees this - return version # type: ignore[return-value] - for _i in range(0, retries): + for _ in range(0, retries + 1): version = _try_fetch() - if _is_valid(version): - # mypy doesn't recognise that _is_valid guarantees this - return version # type: ignore[return-value] + if version is not None and len(version) > 1: + return version raise KazooException( "Unable to fetch useable server" " version after trying %s times" % (1 + max(0, retries)) @@ -1578,12 +1514,8 @@ def get_children_async( raise TypeError("Invalid type for 'include_data' (bool expected)") async_result = self.handler.async_result() - # FIXME? Do this as req = getc2 if include_data else getc - req: GetChildren | GetChildren2 - if include_data: - req = GetChildren2(_prefix_root(self.chroot, path), watch) - else: - req = GetChildren(_prefix_root(self.chroot, path), watch) + func = GetChildren2 if include_data else GetChildren + req = func(_prefix_root(self.chroot, path), watch) self._call(req, async_result) return async_result @@ -2099,9 +2031,7 @@ def _check_tx_state(self) -> None: raise ValueError("Transaction already committed") def _add( - self, - request: Any, - post_processor: Callable[[Any], Any] | None = None, + self, request: Any, post_processor: Callable[[Any], Any] | None = None ) -> None: self._check_tx_state() self.client.logger.log(BLATHER, "Added %r to %r", request, self) diff --git a/kazoo/handlers/eventlet.py b/kazoo/handlers/eventlet.py index c6f01d11f..40e3923f0 100644 --- a/kazoo/handlers/eventlet.py +++ b/kazoo/handlers/eventlet.py @@ -17,16 +17,10 @@ from kazoo.handlers import utils from kazoo.handlers.utils import selector_select +from kazoo.interfaces import IHandler, SpawnedFunc if TYPE_CHECKING: - from kazoo.interfaces import ( - Event, - FdLike, - IHandler, - Lockable, - ReentrantLock, - Socket, - ) + from kazoo.interfaces import Event, FdLike, Lockable, ReentrantLock, Socket from kazoo.protocol.states import Callback @@ -65,8 +59,7 @@ def __init__(self, handler: IHandler): ) -# FIXME This should inherit from IHandler -class SequentialEventletHandler: +class SequentialEventletHandler(IHandler): """Eventlet handler for sequentially executing callbacks. This handler executes callbacks in a sequential manner. A queue is @@ -96,29 +89,23 @@ class SequentialEventletHandler: """ name = "sequential_eventlet_handler" - queue_impl = green_queue.LightQueue + queue_impl = green_queue.LightQueue # type: ignore[assignment] queue_empty = green_queue.Empty + sleep_func = staticmethod(green_time.sleep) # type: ignore[assignment, attr-defined] def __init__(self) -> None: """Create a :class:`SequentialEventletHandler` instance""" - self.callback_queue = ( + self._callback_queue = ( self.queue_impl() # type: ignore[no-untyped-call] ) - self.completion_queue = ( + self._completion_queue = ( self.queue_impl() # type: ignore[no-untyped-call] ) self._workers: list[ # type: ignore[name-defined] - tuple[ - eventlet.GreenThread, - green_queue.LightQueue, - ] + tuple[eventlet.GreenThread, green_queue.LightQueue] ] = [] self._started = False - @staticmethod - def sleep_func(wait: float) -> None: - green_time.sleep(wait) # type: ignore[attr-defined, no-untyped-call] - @property def running(self) -> bool: return self._started @@ -127,7 +114,7 @@ def running(self) -> bool: def _process_completion_queue(self) -> None: while True: - cb = self.completion_queue.get() # type: ignore[no-untyped-call] + cb = self._completion_queue.get() # type: ignore[no-untyped-call] if cb is _STOP: break try: @@ -143,7 +130,7 @@ def _process_completion_queue(self) -> None: def _process_callback_queue(self) -> None: while True: - cb = self.callback_queue.get() # type: ignore[no-untyped-call] + cb = self._callback_queue.get() # type: ignore[no-untyped-call] if cb is _STOP: break try: @@ -165,11 +152,11 @@ def start(self) -> None: w = eventlet.spawn( self._process_completion_queue # type: ignore[no-untyped-call] ) - self._workers.append((w, self.completion_queue)) + self._workers.append((w, self._completion_queue)) w = eventlet.spawn( self._process_callback_queue # type: ignore[no-untyped-call] ) - self._workers.append((w, self.callback_queue)) + self._workers.append((w, self._callback_queue)) self._started = True atexit.register(self.stop) @@ -236,6 +223,18 @@ def spawn( return t def dispatch_callback(self, callback: Callback) -> None: - self.callback_queue.put( # type: ignore[no-untyped-call] + self._callback_queue.put( # type: ignore[no-untyped-call] lambda: callback.func(*callback.args) ) + + def add_to_completion_queue(self, callback: SpawnedFunc) -> None: + """Add a method to callback on completion. + + :param callback: A :class:`~kazoo.protocol.states.Callback` + object to be called. + + The supplied method will be called whn an event completes. + + Note: This seems an odd place to store these. + """ + self._completion_queue.put(callback) # type: ignore[no-untyped-call] diff --git a/kazoo/handlers/gevent.py b/kazoo/handlers/gevent.py index 2f782fa28..3a390fb24 100644 --- a/kazoo/handlers/gevent.py +++ b/kazoo/handlers/gevent.py @@ -3,33 +3,40 @@ from __future__ import annotations import atexit +import importlib.util import logging from typing import Any, Callable, Iterable, TYPE_CHECKING, cast +from typing_extensions import TypeAlias from kazoo.handlers.utils import selector_select from kazoo.handlers import utils - -# FIXME This is messy. -# We don't want to force the user to install gevent, so we need to handle the -# case where it's not available, but there should be a cleaner we of doing this -# than by importing all of gevent and ignoring all the import errors. -import gevent # type: ignore[import] -from gevent import socket # type: ignore[import] -import gevent.event # type: ignore[import] -import gevent.queue # type: ignore[import] - -import gevent.thread # type: ignore[import] -import gevent.selectors # type: ignore[import] -from gevent.lock import Semaphore, RLock # type: ignore[import] +from kazoo.interfaces import IAsyncResult, IHandler, SpawnedFunc if TYPE_CHECKING: - from kazoo.interfaces import FdLike, Lockable, Socket + from kazoo.interfaces import ( + Event, + FdLike, + Lockable, + ReentrantLock, + Socket, + Threadlike, + ) from kazoo.protocol.states import Callback - from gevent import Greenlet -_using_libevent = gevent.__version__.startswith("0.") + GEVENT_AVAILABLE: bool = True +else: + GEVENT_AVAILABLE = importlib.util.find_spec("gevent") is not None + +if GEVENT_AVAILABLE: + import gevent + from gevent import socket + import gevent.event + import gevent.queue + import gevent.thread + import gevent.selectors + from gevent.lock import Semaphore, RLock log = logging.getLogger(__name__) @@ -38,14 +45,11 @@ AsyncResult = gevent.event.AsyncResult -# The following would be great typenames, but python3.8 complains about type -# objects not being indexable. -# GCallback = Callable[..., None] -# Worker = Greenlet[..., Any] -# CallbackQueue = gevent.queue.Queue[Callable[..., None]] +Worker: TypeAlias = "gevent.Greenlet[..., Any]" +CallbackQueue: TypeAlias = "gevent.queue.Queue[Callable[..., None]]" -class SequentialGeventHandler: +class SequentialGeventHandler(IHandler): """Gevent handler for sequentially executing callbacks. This handler executes callbacks in a sequential manner. A queue is @@ -67,31 +71,26 @@ class SequentialGeventHandler: """ name = "sequential_gevent_handler" - queue_impl = gevent.queue.Queue + queue_impl = gevent.queue.Queue # type: ignore[assignment] queue_empty = gevent.queue.Empty - sleep_func = staticmethod(gevent.sleep) + sleep_func = staticmethod(gevent.sleep) # type: ignore[assignment] def __init__(self) -> None: """Create a :class:`SequentialGeventHandler` instance""" - self.callback_queue: gevent.queue.Queue[Callable[..., None]] = ( - self.queue_impl() - ) + self._callback_queue: CallbackQueue = self.queue_impl() + self._running = False self._async = None self._state_change = Semaphore() - self._workers: list[Greenlet[..., Any]] = [] + self._workers: list[Worker] = [] @property def running(self) -> bool: return self._running - class timeout_exception(gevent.Timeout): - def __init__(self, msg: Any): - gevent.Timeout.__init__(self, exception=msg) + timeout_exception = gevent.timeout.Timeout - def _create_greenlet_worker( - self, queue: gevent.queue.Queue[Callable[..., None]] - ) -> Greenlet[..., Any]: + def _create_greenlet_worker(self, queue: CallbackQueue) -> Worker: def greenlet_worker() -> None: while True: try: @@ -121,7 +120,7 @@ def start(self) -> None: # Spawn our worker greenlets, we have # - A callback worker for watch events to be called # FIXME Why the loop? - for queue in (self.callback_queue,): + for queue in (self._callback_queue,): w = self._create_greenlet_worker(queue) self._workers.append(w) atexit.register(self.stop) @@ -134,7 +133,7 @@ def stop(self) -> None: self._running = False - for queue in (self.callback_queue,): + for queue in (self._callback_queue,): queue.put(cast("Callable[..., None]", _STOP)) while self._workers: @@ -142,7 +141,7 @@ def stop(self) -> None: worker.join() # Clear the queues - self.callback_queue = self.queue_impl() # pragma: nocover + self._callback_queue = self.queue_impl() # pragma: nocover atexit.unregister(self.stop) @@ -167,7 +166,7 @@ def create_connection(self, *args: Any, **kwargs: Any) -> Socket: def create_socket_pair(self) -> tuple[Socket, Socket]: return utils.create_socket_pair(socket) - def event_object(self) -> gevent.event.Event: + def event_object(self) -> Event: """Create an appropriate Event object""" return gevent.event.Event() @@ -178,11 +177,11 @@ def lock_object(self) -> Lockable: gevent.thread.allocate_lock(), # type: ignore[no-untyped-call] ) - def rlock_object(self) -> RLock: + def rlock_object(self) -> ReentrantLock: """Create an appropriate RLock object""" return RLock() - def async_result(self) -> AsyncResult[Any]: + def async_result(self) -> IAsyncResult: """Create a :class:`AsyncResult` instance The :class:`AsyncResult` instance will have its completion @@ -193,9 +192,7 @@ def async_result(self) -> AsyncResult[Any]: """ return AsyncResult() - def spawn( - self, func: Any, *args: Any, **kwargs: Any - ) -> gevent.Greenlet[..., Any]: + def spawn(self, func: Any, *args: Any, **kwargs: Any) -> Threadlike: """Spawn a function to run asynchronously""" return gevent.spawn(func, *args, **kwargs) @@ -206,4 +203,16 @@ def dispatch_callback(self, callback: Callback) -> None: type as documented for the :class:`SequentialGeventHandler`. """ - self.callback_queue.put(lambda: callback.func(*callback.args)) + self._callback_queue.put(lambda: callback.func(*callback.args)) + + def add_to_completion_queue(self, callback: SpawnedFunc) -> None: + """Add a method to callback on completion. + + :param callback: A :class:`~kazoo.protocol.states.Callback` + object to be called. + + The supplied method will be called whn an event completes. + + Note: This seems an odd place to store these. + """ + raise NotImplementedError() diff --git a/kazoo/handlers/threading.py b/kazoo/handlers/threading.py index f816652fb..20758c24d 100644 --- a/kazoo/handlers/threading.py +++ b/kazoo/handlers/threading.py @@ -113,7 +113,7 @@ def __init__(self) -> None: self.callback_queue: queue.Queue[Callable[..., None]] = ( self.queue_impl() ) - self.completion_queue: queue.Queue[Callable[..., None]] = ( + self._completion_queue: queue.Queue[Callable[..., None]] = ( self.queue_impl() ) self._running = False @@ -155,7 +155,7 @@ def start(self) -> None: # Spawn our worker threads, we have # - A callback worker for watch events to be called # - A completion worker for completion events to be called - for work_queue in (self.completion_queue, self.callback_queue): + for work_queue in (self._completion_queue, self.callback_queue): w = self._create_thread_worker(work_queue) self._workers.append(w) self._running = True @@ -169,7 +169,7 @@ def stop(self) -> None: self._running = False - for work_queue in (self.completion_queue, self.callback_queue): + for work_queue in (self._completion_queue, self.callback_queue): work_queue.put(cast("Callable[..., None]", _STOP)) self._workers.reverse() @@ -179,7 +179,7 @@ def stop(self) -> None: # Clear the queues self.callback_queue = self.queue_impl() - self.completion_queue = self.queue_impl() + self._completion_queue = self.queue_impl() atexit.unregister(self.stop) def select( @@ -202,9 +202,7 @@ def event_object(self) -> Event: def lock_object(self) -> Lockable: """Create a lock object""" - # Note: This is not ideal, but the ContextManager Protocol seems to - # think you should return an object of the same type. - return cast("Lockable", threading.Lock()) + return threading.Lock() def rlock_object(self) -> ReentrantLock: """Create an appropriate RLock object""" @@ -230,3 +228,15 @@ def dispatch_callback(self, callback: Callback) -> None: """ self.callback_queue.put(lambda: callback.func(*callback.args)) + + def add_to_completion_queue(self, callback: SpawnedFunc) -> None: + """Add a method to callback on completion. + + :param callback: A :class:`~kazoo.protocol.states.Callback` + object to be called. + + The supplied method will be called when an event completes. + + Note: This seems an odd place to store these. + """ + self._completion_queue.put(callback) diff --git a/kazoo/handlers/utils.py b/kazoo/handlers/utils.py index c7741087b..0950911cb 100644 --- a/kazoo/handlers/utils.py +++ b/kazoo/handlers/utils.py @@ -13,7 +13,7 @@ from typing import Any, Callable, Iterable, TypeVar, TYPE_CHECKING from typing_extensions import ParamSpec -from kazoo.interfaces import IAsyncResult, FdLike +from kazoo.interfaces import IAsyncResult, IHandler, FdLike if TYPE_CHECKING: from types import ModuleType @@ -26,14 +26,7 @@ HAS_FNCTL = False -# sentinel objects -# Note: This needs to be a unique object that is not None, as None is used to -# indicate a successful result in AsyncResult. -# This should probably be an Enum, it would certainly be cleaner, but don't -# want to change the code too much. -_NONE = object() - -CallbackFunc = Callable[..., None] +CallbackFunc = Callable[["AsyncResult"], None] class AsyncResult(IAsyncResult): @@ -41,12 +34,13 @@ class AsyncResult(IAsyncResult): def __init__( self, - handler: Any, + handler: IHandler, condition_factory: Callable[[], Any], timeout_factory: Callable[[], Any], ) -> None: self._handler = handler - self._exception: object | Exception | None = _NONE + self._exception: Exception | None = None + self._ready = False self._condition = condition_factory() self._callbacks: list[CallbackFunc] = [] self._timeout_factory = timeout_factory @@ -55,32 +49,33 @@ def __init__( def ready(self) -> bool: """Return true if and only if it holds a value or an exception""" - return self._exception is not _NONE + return self._ready def successful(self) -> bool: """Return true if and only if it is ready and holds a value""" - return self._exception is None + return self._ready and self._exception is None @property def exception(self) -> Exception | None: - if self._exception is not _NONE: - return self._exception # type: ignore[return-value] - return None + return self._exception def set(self, value: Any = None) -> None: """Store the value. Wake up the waiters.""" with self._condition: self.value = value - self._exception = None - self._do_callbacks() - self._condition.notify_all() + self._wakeup() + + def _wakeup(self) -> None: + """All done, set ready and wake up any waiters""" + self._ready = True + self._do_callbacks() + self._condition.notify_all() def set_exception(self, exception: Exception) -> None: """Store the exception. Wake up the waiters.""" with self._condition: self._exception = exception - self._do_callbacks() - self._condition.notify_all() + self._wakeup() def get(self, block: bool = True, timeout: float | None = None) -> Any: """Return the stored value or raise the exception. @@ -89,16 +84,16 @@ def get(self, block: bool = True, timeout: float | None = None) -> Any: """ with self._condition: - if self._exception is not _NONE: + if self._ready: if self._exception is None: return self.value - raise self._exception # type: ignore[misc] - elif block: + raise self._exception + if block: self._condition.wait(timeout) - if self._exception is not _NONE: + if self.ready(): if self._exception is None: return self.value - raise self._exception # type: ignore[misc] + raise self._exception # if we get to this point we timeout raise self._timeout_factory() @@ -116,7 +111,7 @@ def wait(self, timeout: float | None = None) -> bool: with self._condition: if not self.ready(): self._condition.wait(timeout) - return self._exception is not _NONE + return self._ready def rawlink(self, callback: CallbackFunc) -> None: """Register a callback to call when a value or an exception is @@ -147,7 +142,7 @@ def _do_callbacks(self) -> None: for callback in self._callbacks: if self._handler.running: - self._handler.completion_queue.put( + self._handler.add_to_completion_queue( functools.partial(callback, self) ) else: @@ -382,33 +377,6 @@ def captured_function( return capture -def fileobj_to_fd(fileobj: FdLike) -> int: - """Return a file descriptor from a file object. - - Parameters: - fileobj -- file object or file descriptor - - Returns: - corresponding file descriptor - - Raises: - TypeError if the object is invalid - """ - if isinstance(fileobj, int): - fd = fileobj - else: - # FIXME given the protocol I don't think the try/catch/int are - # required. - try: - fd = int(fileobj.fileno()) - except (AttributeError, TypeError, ValueError): - raise TypeError("Invalid file object: {!r}".format(fileobj)) - # FIXME Questionable, just let select deal with it. - if fd < 0: - raise TypeError("Invalid file descriptor: {}".format(fd)) - return fd - - def selector_select( rlist: Iterable[FdLike], wlist: Iterable[FdLike], @@ -434,7 +402,7 @@ def selector_select( for event, fileobjs in events_mapping.items(): for fileobj in fileobjs: - fd = fileobj_to_fd(fileobj) + fd = fileobj if isinstance(fileobj, int) else fileobj.fileno() fd_events[fd] |= event fd_fileobjs[fd].append(fileobj) diff --git a/kazoo/hosts.py b/kazoo/hosts.py index cda746a3a..675f734f4 100644 --- a/kazoo/hosts.py +++ b/kazoo/hosts.py @@ -3,9 +3,7 @@ import urllib.parse -def collect_hosts( - hosts: str | list[str], -) -> tuple[list[tuple[str, int]], str | None]: +def collect_hosts(hosts: str | list[str]) -> tuple[list[tuple[str, int]], str]: """ Collect a set of hosts and an optional chroot from a string or a list of strings. @@ -14,11 +12,12 @@ def collect_hosts( if hosts[-1].strip().startswith("/"): host_ports, chroot = hosts[:-1], hosts[-1] else: - host_ports, chroot = hosts, None + host_ports, chroot = hosts, "" else: host_ports_1, chroot = hosts.partition("/")[::2] host_ports = host_ports_1.split(",") - chroot = "/" + chroot if chroot else None + if chroot != "": + chroot = "/" + chroot result = [] for host_port in host_ports: @@ -28,7 +27,7 @@ def collect_hosts( host = res.hostname if host is None: raise ValueError("bad hostname") - port = int(res.port) if res.port else 2181 + port = 2181 if res.port is None else res.port result.append((host.strip(), port)) return result, chroot diff --git a/kazoo/interfaces.py b/kazoo/interfaces.py index b7b685bb0..d0d732f7d 100644 --- a/kazoo/interfaces.py +++ b/kazoo/interfaces.py @@ -10,13 +10,14 @@ from __future__ import annotations - from typing import ( Any, Callable, Iterable, + Literal, Protocol, Union, + overload, TYPE_CHECKING, ) @@ -70,11 +71,11 @@ def shutdown(self, flag: int) -> None: ... class Lockable(Protocol): """This is what threading.Lock implements. - In python 3.9+ it's available natively. Though given it has some - very odd typing, I wouldn't put money on it. + In python 3.9+ Lockable is meant to be available natively. Though I haven't + seen it yet. """ - def __enter__(self) -> None: ... + def __enter__(self) -> bool: ... def __exit__( self, @@ -98,7 +99,10 @@ class ReentrantLock(Protocol): In python 3.14+, it's the same as Lock, which adds to the fun. """ - def __enter__(self) -> None: ... + # Note: GreenLet Rlock returns a bool, but the threading.RLock returns + # None, unlike threading.Lock. So we have to use a union type here to + # satisfy both. This is a hack + def __enter__(self) -> bool | None: ... def __exit__( self, @@ -121,14 +125,25 @@ def set(self) -> None: ... def clear(self) -> None: ... + # This overload is for the case where timeout is None, which means wait + # indefinitely. gevent's Event.wait explicitly returns True in this case, + # so we reflect that in the type. + # The threading.Event.wait actually conforms to this overload as well, + # since it returns True if the event is set, and if timeout is None, it + # will always return True, but it doesn't explicitly say that in the docs, + # so we have to be a bit more conservative with the typing. + @overload + def wait(self, timeout: None = None) -> bool | Literal[True]: ... + + @overload + def wait(self, timeout: float) -> bool: ... + def wait(self, timeout: float | None = None) -> bool: ... class Threadlike(Protocol): """Protocol for something like a thread.""" - def is_alive(self) -> bool: ... - def join(self, timeout: float | None = None) -> None: ... @@ -170,7 +185,7 @@ class IHandler(Protocol): # FIXME These should be methods but I want to avoid changing the API for # now. - timeout_exception: type[Exception] = None # type: ignore[assignment] + timeout_exception: type[BaseException] = None # type: ignore[assignment] sleep_func: staticmethod[[float], None] = None # type: ignore[assignment] queue_impl: type[queue.Queue[Any]] = None # type: ignore[assignment] @@ -181,6 +196,10 @@ def stop(self) -> None: """Stop the handler. Should block until the handler is safely stopped.""" + @property + def running(self) -> bool: + """Return True if the handler is running, False otherwise.""" + def select( self, rlist: Iterable[FdLike], @@ -240,6 +259,18 @@ def dispatch_callback(self, callback: Callback) -> None: """ + def add_to_completion_queue(self, callback: SpawnedFunc) -> None: + """Add a method to callback on completion. + + :param callback: A :class:`~kazoo.protocol.states.Callback` + object to be called. + + The supplied method will be called when an event completes. + + This is called from utils.ASyncResult, the GeventHandler doesn't + support this. + """ + class IAsyncResult(Protocol): """An Async Result object that can be queried for a value that has @@ -344,6 +375,10 @@ def unlink(self, callback: Callable[[IAsyncResult], None]) -> None: """ @property - def exception(self) -> Exception | None: + def exception(self) -> BaseException | None: """The exception set by :meth:`set_exception` or `None` if no - exception has been set""" + exception has been set + + gevent specifies BaseException here so we have to be compatible with + that, though it seems a little OTT. + """ diff --git a/kazoo/protocol/connection.py b/kazoo/protocol/connection.py index d35952be5..8b1214a48 100644 --- a/kazoo/protocol/connection.py +++ b/kazoo/protocol/connection.py @@ -5,6 +5,7 @@ from binascii import hexlify from contextlib import contextmanager import copy +import importlib.util import logging import random import select @@ -54,25 +55,19 @@ WatchedEvent, EVENT_TYPE_MAP, ) -from kazoo.retry import ( - ForceRetryError, - KazooRetry, - RetryFailedError, -) +from kazoo.retry import ForceRetryError, KazooRetry, RetryFailedError if TYPE_CHECKING: from kazoo.client import KazooClient, WatchFunc from kazoo.interfaces import Socket, Threadlike -# FIXME This is NOT pretty, but we don't want to force users to have to -# install puresasl. Can we avoid some of the type: ignore stuff? -try: - import puresasl # type: ignore[import-untyped,import-not-found] - import puresasl.client # type: ignore[import-untyped,import-not-found] + PURESASL_AVAILABLE: bool = True +else: + PURESASL_AVAILABLE = importlib.util.find_spec("puresasl") is not None - PURESASL_AVAILABLE = True -except ImportError: - PURESASL_AVAILABLE = False +if PURESASL_AVAILABLE: + import puresasl + import puresasl.client log = logging.getLogger(__name__) @@ -215,7 +210,7 @@ def __init__( self._connection_routine: Threadlike | None = None - self.sasl_options = sasl_options + self._sasl_options = sasl_options self.sasl_cli = None # This is instance specific to avoid odd thread bug issues in Python @@ -462,7 +457,7 @@ def _read_watch_event(self, buffer: bytes, offset: int) -> None: elif watch.type == CHILD_EVENT: watchers.extend(client._child_watchers.pop(path, [])) else: - self.logger.warn("Received unknown event %r", watch.type) + self.logger.warning("Received unknown event %r", watch.type) return # Strip the chroot if needed @@ -478,10 +473,7 @@ def _read_watch_event(self, buffer: bytes, offset: int) -> None: client.handler.dispatch_callback(Callback("watch", watch1, (ev,))) def _read_response( - self, - header: ReplyHeader, - buffer: bytes, - offset: int, + self, header: ReplyHeader, buffer: bytes, offset: int ) -> object | None: client = self.client request, async_object, xid = client._pending.popleft() @@ -489,9 +481,7 @@ def _read_response( client.last_zxid = header.zxid if header.xid != xid: exc = RuntimeError( - "xids do not match, expected %r received %r", - xid, - header.xid, + "xids do not match, expected %r received %r", xid, header.xid ) async_object.set_exception(exc) raise exc @@ -578,9 +568,7 @@ def _read_socket(self, read_timeout: float) -> object | None: return None def _send_request( - self, - read_timeout: float, - connect_timeout: float, + self, read_timeout: float, connect_timeout: float ) -> None: """Called when we have something to send out on the socket""" client = self.client @@ -711,11 +699,7 @@ def _connect_loop(self, retry: KazooRetry) -> object: raise ForceRetryError("Reconnecting") def _connect_attempt( - self, - host: str, - hostip: str, - port: int, - retry: KazooRetry, + self, host: str, hostip: str, port: int, retry: KazooRetry ) -> object: client = self.client KazooTimeoutError = self.handler.timeout_exception @@ -828,10 +812,7 @@ def _connect_attempt( return None def _connect( - self, - host: str, - hostip: str, - port: int, + self, host: str, hostip: str, port: int ) -> tuple[float, float]: client = self.client self.logger.info( @@ -912,8 +893,10 @@ def _connect( client._session_callback(KeeperState.CONNECTED) self._ro_mode = None - if self.sasl_options is not None: - self._authenticate_with_sasl(host, connect_timeout / 1000.0) + if self._sasl_options is not None: + self._authenticate_with_sasl( + self._sasl_options, host, connect_timeout / 1000.0 + ) # Get a copy of the auth data before iterating, in case it is # changed. @@ -927,36 +910,26 @@ def _connect( return read_timeout, connect_timeout - def _authenticate_with_sasl(self, host: str, timeout: float) -> None: + def _authenticate_with_sasl( + self, sasl_options: dict[str, str], host: str, timeout: float + ) -> None: """Establish a SASL authenticated connection to the server.""" if not PURESASL_AVAILABLE: raise SASLException("Missing SASL support") - # Although this can only be called if sasl_options is not None, we - # really should just have make self.sasl_options into an empty dict - # in the constructor. However, I want to avoid code changes in as - # much as possible. - if "service" not in self.sasl_options: # type: ignore[operator] - self.sasl_options["service"] = "zookeeper" # type: ignore[index] + if "service" not in sasl_options: + sasl_options["service"] = "zookeeper" # NOTE: Zookeeper hardcoded the domain for Digest authentication # instead of using the hostname. See # zookeeper/util/SecurityUtils.java#L74 and Server/Client # initializations. - if ( - self.sasl_options["mechanism"] # type: ignore[index] - == "DIGEST-MD5" - ): + if sasl_options["mechanism"] == "DIGEST-MD5": host = "zk-sasl-md5" - # I don't think the client.sasl_cli attribute is actually used - # anywhere else, so not sure why we need to set it on the client, - # but again, I want to avoid code changes as much as possible. - sasl_cli = self.client.sasl_cli = ( # type: ignore[attr-defined] - puresasl.client.SASLClient( # type: ignore[no-untyped-call] - host=host, - **self.sasl_options, # type: ignore[arg-type] - ) + # FIXME puresasl isn't properly type hinted. + sasl_cli = puresasl.client.SASLClient( # type: ignore + host=host, **sasl_options ) # Initialize the process with an empty challenge token diff --git a/kazoo/protocol/serialization.py b/kazoo/protocol/serialization.py index 4653c1e59..b989cb6aa 100644 --- a/kazoo/protocol/serialization.py +++ b/kazoo/protocol/serialization.py @@ -392,8 +392,36 @@ def serialize(self) -> bytearray: return b -# FIXME Transaction class should move after Create2 -Transaction_Types = Union[Create, "Create2", Delete, SetData, CheckVersion] +class Create2(namedtuple("Create2", "path data acl flags")): + path: str + data: bytes | None + acl: Sequence[ACL] + flags: int + + type: ClassVar[int] = 15 + + def serialize(self) -> bytearray: + b = bytearray() + b.extend(write_string(self.path)) + b.extend(write_buffer(self.data)) + b.extend(int_struct.pack(len(self.acl))) + for acl in self.acl: + b.extend( + int_struct.pack(acl.perms) + + write_string(acl.id.scheme) + + write_string(acl.id.id) + ) + b.extend(int_struct.pack(self.flags)) + return b + + @classmethod + def deserialize(cls, bytes: bytes, offset: int) -> tuple[str, ZnodeStat]: + path, offset = read_string(bytes, offset) + stat = ZnodeStat(*stat_struct.unpack_from(bytes, offset)) + return path, stat + + +Transaction_Types = Union[Create, Create2, Delete, SetData, CheckVersion] Transaction_Response = Union[str, bool, ZnodeStat, ZookeeperError, None] @@ -449,35 +477,6 @@ def unchroot( return resp -class Create2(namedtuple("Create2", "path data acl flags")): - path: str - data: bytes | None - acl: Sequence[ACL] - flags: int - - type: ClassVar[int] = 15 - - def serialize(self) -> bytearray: - b = bytearray() - b.extend(write_string(self.path)) - b.extend(write_buffer(self.data)) - b.extend(int_struct.pack(len(self.acl))) - for acl in self.acl: - b.extend( - int_struct.pack(acl.perms) - + write_string(acl.id.scheme) - + write_string(acl.id.id) - ) - b.extend(int_struct.pack(self.flags)) - return b - - @classmethod - def deserialize(cls, bytes: bytes, offset: int) -> tuple[str, ZnodeStat]: - path, offset = read_string(bytes, offset) - stat = ZnodeStat(*stat_struct.unpack_from(bytes, offset)) - return path, stat - - class Reconfig( namedtuple("Reconfig", "joining leaving new_members config_id") ): diff --git a/kazoo/recipe/cache.py b/kazoo/recipe/cache.py index 107d6849a..bfe259d45 100644 --- a/kazoo/recipe/cache.py +++ b/kazoo/recipe/cache.py @@ -187,9 +187,6 @@ def get_children( does not exist. :raises ValueError: If the path is outside of this subtree. :returns: The :class:`frozenset` which including children names. - - # FIXME the default return value should be an empty frozenset, - # returning None is confusing. """ node = self._find_node(path) return default if node is None else frozenset(node._children) diff --git a/kazoo/recipe/counter.py b/kazoo/recipe/counter.py index 77d68cb55..bde47e57b 100644 --- a/kazoo/recipe/counter.py +++ b/kazoo/recipe/counter.py @@ -107,25 +107,15 @@ def _ensure_node(self) -> None: def _value(self) -> tuple[Number, int]: self._ensure_node() - # FIXME: This is astonishingly hard to follow... - # Should probably be refactored to be more clear. - # val, state = ... - # if val == b"": - # old = self.default - # elif self.support_curator: - # old = struct.unpack(">i", val)[0] - # else: - # old = val.decode("ascii") - # maybe (not sure it does anything for the messy type though) - old: Union[bytes, str, Number] - old, stat = self.client.get(self.path) - if self.support_curator: - old = struct.unpack(">i", old)[0] if old != b"" else self.default + old: Union[str, Number] + val, stat = self.client.get(self.path) + if val == b"": + old = self.default + elif self.support_curator: + old = int(struct.unpack(">i", val)[0]) else: - old = old.decode("ascii") if old != b"" else self.default - version = stat.version - data = self.default_type(old) - return data, version + old = val.decode("ascii") + return self.default_type(old), stat.version @property def value(self) -> Number: diff --git a/kazoo/recipe/election.py b/kazoo/recipe/election.py index 4c4400032..eba33e1d4 100644 --- a/kazoo/recipe/election.py +++ b/kazoo/recipe/election.py @@ -34,10 +34,7 @@ class Election: """ def __init__( - self, - client: KazooClient, - path: str, - identifier: str | None = None, + self, client: KazooClient, path: str, identifier: str | None = None ): """Create a Kazoo Leader Election diff --git a/kazoo/recipe/lock.py b/kazoo/recipe/lock.py index 1737802b7..8a44ff9d0 100644 --- a/kazoo/recipe/lock.py +++ b/kazoo/recipe/lock.py @@ -20,12 +20,7 @@ import re import time import uuid -from typing import ( - Iterable, - Literal, - Pattern, - TYPE_CHECKING, -) +from typing import Iterable, Literal, Pattern, TYPE_CHECKING from kazoo.exceptions import ( CancelledError, @@ -34,11 +29,7 @@ NoNodeError, ) from kazoo.protocol.states import KazooState, WatchedEvent -from kazoo.retry import ( - ForceRetryError, - KazooRetry, - RetryFailedError, -) +from kazoo.retry import ForceRetryError, KazooRetry, RetryFailedError if TYPE_CHECKING: from types import TracebackType @@ -48,26 +39,13 @@ class _Watch: def __init__(self, duration: float | None = None): self.duration = duration - self.started_at: float | None = None - - def start(self) -> None: self.started_at = time.monotonic() def leftover(self) -> float | None: if self.duration is None: return None - else: - # We should probably set started_at to either 0 or - # time.monotonic() in __init__ to avoid the type ignore - # here, but this is a private class and it's pretty clear - # that start() should be called before leftover() so I'm - # not sure it's worth it. - # FIXME raise an exception if start() hasn't been called yet - # i.e. self.started_at is None - elapsed = ( - time.monotonic() - self.started_at # type: ignore[operator] - ) - return max(0, self.duration - elapsed) + elapsed = time.monotonic() - self.started_at + return max(0, self.duration - elapsed) class Lock: @@ -242,10 +220,7 @@ def _watch_session(self, state: KazooState) -> bool: return True def _inner_acquire( - self, - blocking: bool, - timeout: float | None, - ephemeral: bool = True, + self, blocking: bool, timeout: float | None, ephemeral: bool = True ) -> bool: # wait until it's our chance to get it.. if self.is_acquired: @@ -607,9 +582,7 @@ def cancel(self) -> None: self.wake_event.set() def acquire( - self, - blocking: bool = True, - timeout: float | None = None, + self, blocking: bool = True, timeout: float | None = None ) -> bool: """Acquire the semaphore. By defaults blocks and waits forever. @@ -649,9 +622,7 @@ def acquire( return self.is_acquired def _inner_acquire( - self, - blocking: bool, - timeout: float | None = None, + self, blocking: bool, timeout: float | None = None ) -> bool: """Inner loop that runs from the top anytime a command hits a retryable Zookeeper exception.""" @@ -666,7 +637,6 @@ def _inner_acquire( return True w = _Watch(duration=timeout) - w.start() # FIXME This is passing bytes data, but self.client.Lock expects a str, # which I think is a bug in this code. However, I don't want to # change any code at this point, so we just ignore the type error here. diff --git a/kazoo/recipe/party.py b/kazoo/recipe/party.py index a51fc2a88..990fe7ec7 100644 --- a/kazoo/recipe/party.py +++ b/kazoo/recipe/party.py @@ -23,10 +23,7 @@ class BaseParty: """Base implementation of a party.""" def __init__( - self, - client: KazooClient, - path: str, - identifier: str | None = None, + self, client: KazooClient, path: str, identifier: str | None = None ): """ :param client: A :class:`~kazoo.client.KazooClient` instance. @@ -132,10 +129,7 @@ class ShallowParty(BaseParty): """ def __init__( - self, - client: KazooClient, - path: str, - identifier: str | None = None, + self, client: KazooClient, path: str, identifier: str | None = None ): BaseParty.__init__(self, client, path, identifier=identifier) self.node = "-".join([uuid.uuid4().hex, self.data.decode("utf-8")]) diff --git a/kazoo/recipe/watchers.py b/kazoo/recipe/watchers.py index 2a504ee2b..086081fbb 100644 --- a/kazoo/recipe/watchers.py +++ b/kazoo/recipe/watchers.py @@ -119,10 +119,7 @@ def my_func(data, stat, event): @overload def __init__( - self, - client: KazooClient, - path: str, - func: DataWatchFunc | None = None, + self, client: KazooClient, path: str, func: DataWatchFunc | None = None ): ... @overload diff --git a/kazoo/retry.py b/kazoo/retry.py index f5a544288..0f7c47650 100644 --- a/kazoo/retry.py +++ b/kazoo/retry.py @@ -116,10 +116,7 @@ def copy(self) -> KazooRetry: return obj def __call__( - self, - func: Callable[..., RETRY_RETURN], - *args: Any, - **kwargs: Any, + self, func: Callable[..., RETRY_RETURN], *args: Any, **kwargs: Any ) -> RETRY_RETURN: """Call a function with arguments until it completes without throwing a Kazoo exception diff --git a/kazoo/testing/__init__.py b/kazoo/testing/__init__.py index 0d4a57519..4c806ada7 100644 --- a/kazoo/testing/__init__.py +++ b/kazoo/testing/__init__.py @@ -1,6 +1,3 @@ from kazoo.testing.harness import KazooTestCase, KazooTestHarness -__all__ = ( - "KazooTestHarness", - "KazooTestCase", -) +__all__ = ("KazooTestHarness", "KazooTestCase") diff --git a/kazoo/testing/async_result_common.py b/kazoo/testing/async_result_common.py new file mode 100644 index 000000000..638f80d21 --- /dev/null +++ b/kazoo/testing/async_result_common.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import contextlib +from abc import ABC, abstractmethod +from types import TracebackType +from typing import Any, Callable, Generator +from unittest.mock import Mock + +import pytest + +from kazoo.interfaces import IAsyncResult, IHandler +from kazoo.handlers.utils import capture_exceptions, wrap + + +class ThreadlikeContext(ABC): + def __init__(self, target: Callable[[], None]): + self._target = target + + @abstractmethod + def __enter__(self) -> None: + raise NotImplementedError + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + pass + + +# FIXME It's not clear whether this needs all the tests to be run with and +# without a running handler to test all the paths. The original mocks always +# returned a truth value for `running``. +class AsyncResultCommon: + def __init__( + self, + handler_factory: Callable[[], IHandler], + async_result_factory: Callable[[IHandler], IAsyncResult], + *args: Any, + thread_context_factory: Callable[ + [Callable[[], None]], ThreadlikeContext + ], + **kwargs: Any, + ) -> None: + self._handler_factory = handler_factory + self._async_result_factory = async_result_factory + self._thread_context_factory = thread_context_factory + super().__init__(*args, **kwargs) + + @contextlib.contextmanager + def _start_and_stop_handler(self) -> Generator[IHandler, None, None]: + self._handler.start() + try: + yield self._handler + finally: + self._handler.stop() + + def setUp(self) -> None: + self._handler = self._handler_factory() + self._async_result = self._async_result_factory(self._handler) + + def test_ready(self) -> None: + assert self._async_result.ready() is False + self._async_result.set(None) + assert self._async_result.ready() is True + assert self._async_result.successful() is True + assert self._async_result.exception is None + assert self._async_result.get() is None + + def test_ready_value(self) -> None: + assert self._async_result.ready() is False + self._async_result.set("value") + assert self._async_result.ready() is True + assert self._async_result.successful() is True + assert self._async_result.exception is None + assert self._async_result.get() == "value" + + def test_callback_queued(self) -> None: + callback_called = self._handler.event_object() + + def add_on(async_result: IAsyncResult) -> None: + callback_called.set() + + self._async_result.rawlink(add_on) + self._async_result.set("val") + + assert callback_called.wait(10) + + def test_set_exception(self) -> None: + callback_called = self._handler.event_object() + + def add_on(async_result: IAsyncResult) -> None: + callback_called.set() + + self._async_result.rawlink(add_on) + self._async_result.set_exception(ImportError("Error occurred")) + + assert self._async_result.ready() is True + assert self._async_result.successful() is False + assert isinstance(self._async_result.exception, ImportError) + + assert callback_called.wait(10) + + def test_get_wait_while_setting(self) -> None: + lst = [] + bv = self._handler.event_object() + cv = self._handler.event_object() + + def wait_for_val() -> None: + bv.set() + val = self._async_result.get() + lst.append(val) + cv.set() + + with self._thread_context_factory(wait_for_val): + bv.wait() + self._async_result.set("fred") + cv.wait() + assert lst == ["fred"] + + def test_get_with_nowait(self) -> None: + timeout = self._handler.timeout_exception + + with pytest.raises(timeout): + self._async_result.get(block=False) + + with pytest.raises(timeout): + self._async_result.get_nowait() + + def test_get_with_exception(self) -> None: + lst = [] + bv = self._handler.event_object() + cv = self._handler.event_object() + + def wait_for_val() -> None: + bv.set() + try: + val = self._async_result.get() + except ImportError: + lst.append("oops") + else: + lst.append(val) + cv.set() + + with self._thread_context_factory(wait_for_val): + bv.wait() + self._async_result.set_exception(ImportError()) + cv.wait() + assert lst == ["oops"] + + def test_wait(self, *, is_green: bool = False) -> None: + lst: list[bool | str] = [] + bv = self._handler.event_object() + cv = self._handler.event_object() + + expected_list = ["fred" if is_green else True] + + def wait_for_val() -> None: + bv.set() + try: + val = self._async_result.wait(15) + except self._handler.timeout_exception: + lst.append("oops") + else: + lst.append(val) + cv.set() + + with self._thread_context_factory(wait_for_val): + bv.wait(10) + self._async_result.set("fred") + cv.wait(15) + + assert lst == expected_list + + def test_wait_race(self) -> None: + """Test that there is no race condition in `IAsyncResult.wait()`. + + Guards against the reappearance of: + https://github.com/python-zk/kazoo/issues/485 + """ + self._async_result.set("immediate") + + cv = self._handler.event_object() + + def wait_for_val() -> None: + # NB: should not sleep + self._async_result.wait(20) + cv.set() + + with self._thread_context_factory(wait_for_val): + self._async_result.set("immediate") + cv.wait(10) + + # if the wait() didn't sleep (correctly), cv will be set quickly + # if it did sleep, the cv will not be set yet and this will timeout + cv.wait(10) + assert cv.is_set() + + def test_set_before_wait(self) -> None: + lst = [] + cv = self._handler.event_object() + self._async_result.set("fred") + + def wait_for_val() -> None: + val = self._async_result.get() + lst.append(val) + cv.set() + + with self._thread_context_factory(wait_for_val): + self._async_result.set("fred") + cv.wait() + assert lst == ["fred"] + + def test_set_exc_before_wait(self) -> None: + lst = [] + cv = self._handler.event_object() + self._async_result.set_exception(ImportError()) + + def wait_for_val() -> None: + try: + val = self._async_result.get() + except ImportError: + lst.append("ooops") + else: + lst.append(val) + cv.set() + + with self._thread_context_factory(wait_for_val): + self._async_result.set_exception(ImportError()) + cv.wait() + + assert lst == ["ooops"] + + def test_linkage(self) -> None: + cv = self._handler.event_object() + callback_called = self._handler.event_object() + + def add_on(async_result: IAsyncResult) -> None: + callback_called.set() + + def wait_for_val() -> None: + self._async_result.get() + cv.set() + + with self._thread_context_factory(wait_for_val): + self._async_result.rawlink(add_on) + self._async_result.set(b"fred") + cv.wait() + self._async_result.unlink(add_on) + + assert callback_called.wait(10) + assert self._async_result.get() == b"fred" + + def test_linkage_not_ready(self) -> None: + callback_called = self._handler.event_object() + lst = [] + + def add_on(async_result: IAsyncResult) -> None: + lst.append(True) + callback_called.set() + + self._async_result.set("fred") + self._async_result.rawlink(add_on) + assert callback_called.wait(10) + assert lst == [True] + + def test_link_and_unlink(self) -> None: + lst = [] + + def add_on(async_result: IAsyncResult) -> None: + lst.append(True) + + self._async_result.rawlink(add_on) + self._async_result.unlink(add_on) + self._async_result.set("fred") + assert lst == [] + + def test_captured_exception(self) -> None: + @capture_exceptions(self._async_result) + def exceptional_function() -> float: + return 1 / 0 + + exceptional_function() + + with pytest.raises(ZeroDivisionError): + self._async_result.get() + + def test_no_capture_exceptions(self) -> None: + lst = [] + + def add_on(async_result: IAsyncResult) -> None: + lst.append(True) + + self._async_result.rawlink(add_on) + + @capture_exceptions(self._async_result) + def regular_function() -> bool: + return True + + regular_function() + + assert lst == [] + + def test_wraps(self) -> None: + callback_called = self._handler.event_object() + lst = [] + + def add_on(result: IAsyncResult) -> None: + lst.append(result.get()) + callback_called.set() + + self._async_result.rawlink(add_on) + + @wrap(self._async_result) + def regular_function() -> str: + return "hello" + + assert regular_function() == "hello" + assert callback_called.wait(10) + assert self._async_result.get() == "hello" + + def test_multiple_callbacks(self) -> None: + mockback1 = Mock(name="mockback1") + mockback2 = Mock(name="mockback2") + + self._async_result.rawlink(mockback1) + self._async_result.rawlink(mockback2) + self._async_result.set("howdy") + self._async_result.wait() + + mockback2.assert_called_once_with(self._async_result) + mockback1.assert_called_once_with(self._async_result) diff --git a/kazoo/testing/common.py b/kazoo/testing/common.py index 2c2d8e276..70f1c2269 100644 --- a/kazoo/testing/common.py +++ b/kazoo/testing/common.py @@ -1,604 +1,604 @@ -# -# Copyright (C) 2010-2011, 2011 Canonical Ltd. All Rights Reserved -# -# This file was originally taken from txzookeeper and modified later. -# -# Authors: -# Kapil Thangavelu and the Kazoo team -# -# txzookeeper is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# txzookeeper is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with txzookeeper. If not, see . -from __future__ import annotations - -import code -import datetime -import logging -import os -import os.path -import pathlib -import shutil -import signal -import subprocess -import tempfile -import traceback -from collections import namedtuple -from glob import glob -from itertools import chain -from typing import TYPE_CHECKING, Any, TypedDict - -import jks -from cryptography import x509 -from cryptography.hazmat.primitives import asymmetric, hashes, serialization -from cryptography.x509.oid import NameOID - -if TYPE_CHECKING: - from collections.abc import Iterator - from types import FrameType - -log = logging.getLogger(__name__) - - -def debug(sig: int, frame: FrameType | None) -> None: - """Interrupt running process, and provide a python prompt for - interactive debugging.""" - d = {"_frame": frame} # Allow access to frame object. - if frame is not None: - d.update(frame.f_globals) # Unless shadowed by global - d.update(frame.f_locals) - - i = code.InteractiveConsole(d) - message = "Signal received : entering python shell.\nTraceback:\n" - message += "".join(traceback.format_stack(frame)) - i.interact(message) - - -def listen() -> None: - if os.name != "nt": # SIGUSR1 is not supported on Windows - signal.signal(signal.SIGUSR1, debug) # Register handler - - -listen() - - -def to_java_compatible_path(path: str) -> str: - if os.name == "nt": - path = path.replace("\\", "/") - return path - - -def _x509_common_name(value: str) -> x509.Name: - return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, value)]) - - -ServerInfo = namedtuple( - "ServerInfo", - "server_id client_port secure_client_port " - "election_port leader_port admin_port peer_type", -) - - -class SSLCerts(TypedDict): - """TypedDict for SSL certificate information.""" - - ca_cert: x509.Certificate - ca_key: asymmetric.rsa.RSAPrivateKey - ca_cert_pem: bytes - server_cert: x509.Certificate - server_key: asymmetric.rsa.RSAPrivateKey - client_cert: x509.Certificate - client_key: asymmetric.rsa.RSAPrivateKey - client_cert_pem: bytes - client_key_pem: bytes - truststore: jks.KeyStore - keystore: jks.KeyStore - - -class SSLClientConfiguration(TypedDict): - """TypedDict for SSL Client configuration.""" - - client_key: bytes - client_cert: bytes - ca_cert: bytes - - -class ManagedZooKeeper: - """Class to manage the running of a ZooKeeper instance for testing. - - Note: no attempt is made to probe the ZooKeeper instance is - actually available, or that the selected port is free. In the - future, we may want to do that, especially when run in a - Hudson/Buildbot context, to ensure more test robustness.""" - - def __init__( - self, - software_path: str, - server_info: ServerInfo, - peers: list[ServerInfo], - *, - classpath: str, - configuration_entries: list[str], - java_system_properties: list[str], - ssl_configuration: SSLCerts, - jaas_config: str | None = None, - ): - """Define the ZooKeeper test instance. - - @param install_path: The path to the install for ZK - @param port: The port to run the managed ZK instance - """ - self.install_path = software_path - self._classpath = classpath - self.server_info = server_info - self.host = "127.0.0.1" - self.peers = peers - self.working_path: str = tempfile.mkdtemp() - self._running: bool = False - self.configuration_entries = configuration_entries - self.java_system_properties = java_system_properties - self.jaas_config = jaas_config - self.ssl_configuration = ssl_configuration - - def run(self) -> None: - """Run the ZooKeeper instance under a temporary directory. - - Writes ZK log messages to zookeeper.log in the current directory. - """ - if self.running: - return - config_path = os.path.join(self.working_path, "zoo.cfg") - jaas_config_path = os.path.join(self.working_path, "jaas.conf") - log_path = os.path.join(self.working_path, "log") - log4j_path = os.path.join(self.working_path, "log4j.properties") - data_path = os.path.join(self.working_path, "data") - truststore_path = os.path.join(self.working_path, "truststore.jks") - keystore_path = os.path.join(self.working_path, "keystore.jks") - - # various setup steps - if not os.path.exists(self.working_path): - os.mkdir(self.working_path) - if not os.path.exists(log_path): - os.mkdir(log_path) - if not os.path.exists(data_path): - os.mkdir(data_path) - - try: - self.ssl_configuration["truststore"].save( - truststore_path, "apassword" - ) - self.ssl_configuration["keystore"].save(keystore_path, "apassword") - except Exception: - log.exception("Unable to perform SSL configuration: ") - raise - - with open(config_path, "w") as config: - config.write( - """ -tickTime=2000 -dataDir=%s -clientPort=%s -secureClientPort=%s -maxClientCnxns=0 -admin.serverPort=%s -serverCnxnFactory=org.apache.zookeeper.server.NettyServerCnxnFactory -authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider -ssl.keyStore.location=%s -ssl.keyStore.password=apassword -ssl.trustStore.location=%s -ssl.trustStore.password=apassword -%s -""" - % ( - to_java_compatible_path(data_path), - self.server_info.client_port, - self.server_info.secure_client_port, - self.server_info.admin_port, - to_java_compatible_path(keystore_path), - to_java_compatible_path(truststore_path), - "\n".join(self.configuration_entries), - ) - ) - - # setup a replicated setup if peers are specified - if self.peers: - servers_cfg = [] - for p in chain((self.server_info,), self.peers): - servers_cfg.append( - "server.%s=localhost:%s:%s:%s" - % ( - p.server_id, - p.leader_port, - p.election_port, - p.peer_type, - ) - ) - - with open(config_path, "a") as config: - config.write( - """ -initLimit=4 -syncLimit=2 -%s -peerType=%s -""" - % ("\n".join(servers_cfg), self.server_info.peer_type) - ) - - # Write server ids into datadir - with open(os.path.join(data_path, "myid"), "w") as myid_file: - myid_file.write(str(self.server_info.server_id)) - # Write JAAS configuration - with open(jaas_config_path, "w") as jaas_file: - jaas_file.write(self.jaas_config or "") - with open(log4j_path, "w") as log4j: - log4j.write( - """ -# DEFAULT: console appender only -log4j.rootLogger=INFO, ROLLINGFILE -log4j.appender.ROLLINGFILE.layout=org.apache.log4j.PatternLayout -log4j.appender.ROLLINGFILE.layout.ConversionPattern=%d{ISO8601} \ -[myid:%X{myid}] - %-5p [%t:%C{1}@%L] - %m%n -log4j.appender.ROLLINGFILE=org.apache.log4j.RollingFileAppender -log4j.appender.ROLLINGFILE.Threshold=DEBUG -log4j.appender.ROLLINGFILE.File=""" - + to_java_compatible_path( - self.working_path + os.sep + "zookeeper.log\n" - ) - ) - - args = ( - [ - "java", - "-cp", - self.classpath, - # make_digest_acl_credential assumes UTF-8, but ZK decodes - # digest auth packets using the JVM's default "charset"--which - # depends on the environment. Force it to use UTF-8 to avoid - # test failures. - "-Dfile.encoding=UTF-8", - # "-Dlog4j.debug", - "-Dreadonlymode.enabled=true", - "-Dzookeeper.log.dir=%s" % log_path, - "-Dzookeeper.root.logger=INFO,CONSOLE", - "-Dlog4j.configuration=file:%s" % log4j_path, - # OS X: Prevent java from appearing in menu bar, process dock - # and from activation of the main workspace on run. - "-Djava.awt.headless=true", - # JAAS configuration for SASL authentication - "-Djava.security.auth.login.config=%s" % jaas_config_path, - ] - + list(self.java_system_properties) - + [ - "org.apache.zookeeper.server.quorum.QuorumPeerMain", - config_path, - ] - ) - self.process = subprocess.Popen(args=args) - log.info( - "Started zookeeper process %s on port %s using args %s", - self.process.pid, - self.server_info.client_port, - args, - ) - self._running = True - - @property - def classpath(self) -> str: - """Get the classpath necessary to run ZooKeeper.""" - - if self._classpath: - return self._classpath - - # Two possibilities, as seen in zkEnv.sh: - # Check for a release - top-level zookeeper-*.jar? - jars = glob(os.path.join(self.install_path, "zookeeper-*.jar")) - if jars: - # Release build (`ant package`) - jars.extend(glob(os.path.join(self.install_path, "lib", "*.jar"))) - jars.extend(glob(os.path.join(self.install_path, "*.jar"))) - # support for different file locations on Debian/Ubuntu - jars.extend(glob(os.path.join(self.install_path, "log4j-*.jar"))) - jars.extend( - glob(os.path.join(self.install_path, "slf4j-api-*.jar")) - ) - jars.extend( - glob(os.path.join(self.install_path, "slf4j-log4j*.jar")) - ) - else: - # Development build (plain `ant`) - jars = glob( - os.path.join(self.install_path, "build", "zookeeper-*.jar") - ) - jars.extend( - glob(os.path.join(self.install_path, "build", "lib", "*.jar")) - ) - - return os.pathsep.join(jars) - - @property - def address(self) -> str: - """Get the address of the ZooKeeper instance.""" - return "%s:%s" % (self.host, self.client_port) - - @property - def secure_address(self) -> str: - """Get the address of the SSL ZooKeeper instance.""" - return "%s:%s" % (self.host, self.secure_client_port) - - @property - def running(self) -> bool: - return self._running - - @property - def client_port(self) -> Any: - return self.server_info.client_port - - @property - def secure_client_port(self) -> Any: - return self.server_info.secure_client_port - - def reset(self) -> None: - """Stop the zookeeper instance, cleaning out its on disk-data.""" - self.stop() - shutil.rmtree(os.path.join(self.working_path, "data"), True) - os.mkdir(os.path.join(self.working_path, "data")) - with open(os.path.join(self.working_path, "data", "myid"), "w") as fh: - fh.write(str(self.server_info.server_id)) - - def stop(self) -> None: - """Stop the Zookeeper instance, retaining on disk state.""" - if not self.running: - return - self.process.terminate() - self.process.wait() - if self.process.returncode != 0: - log.warning( - "Zookeeper process %s failed to terminate with" - " non-zero return code (it terminated with %s return" - " code instead)", - self.process.pid, - self.process.returncode, - ) - self._running = False - - def destroy(self) -> None: - """Stop the ZooKeeper instance and destroy its on disk-state""" - # called by at exit handler, reimport to avoid cleanup race. - self.stop() - - shutil.rmtree(self.working_path, True) - - def get_logs(self, num_lines: int = 100) -> list[str]: - log_path = pathlib.Path(self.working_path, "zookeeper.log") - if log_path.exists(): - with log_path.open("r") as log_file: - lines = log_file.readlines() - return lines[-num_lines:] - return [] - - -class ZookeeperCluster: - def __init__( - self, - install_path: str, - classpath: str, - size: int, - port_offset: int, - observer_start_id: int, - configuration_entries: list[str], - java_system_properties: list[str], - jaas_config: str | None, - ): - self._install_path = install_path - self._classpath = classpath - self._servers = [] - self._ssl_configuration = self._perform_ssl_certs_generation() - - # Calculate ports and peer group - port = port_offset - peers: list[ServerInfo] = [] - - for i in range(size): - server_id = i + 1 - if observer_start_id != -1 and server_id >= observer_start_id: - peer_type = "observer" - else: - peer_type = "participant" - info = ServerInfo( - server_id, - port, - port + 4, - port + 1, - port + 2, - port + 3, - peer_type, - ) - peers.append(info) - port += 10 - - # Instantiate Managed ZK Servers - for i in range(size): - server_peers = list(peers) - server_info = server_peers.pop(i) - self._servers.append( - ManagedZooKeeper( - self._install_path, - server_info, - server_peers, - classpath=self._classpath, - configuration_entries=configuration_entries, - java_system_properties=java_system_properties, - jaas_config=jaas_config, - ssl_configuration=self._ssl_configuration, - ) - ) - - def __getitem__(self, k: int) -> ManagedZooKeeper: - return self._servers[k] - - def __iter__(self) -> Iterator[ManagedZooKeeper]: - return iter(self._servers) - - def start(self) -> None: - # Zookeeper client expresses a preference for either lower ports or - # lexicographical ordering of hosts, to ensure that all servers have a - # chance to startup, start them in reverse order. - for server in reversed(list(self)): - server.run() - # Giving the servers a moment to start, decreases the overall time - # required for a client to successfully connect (2s vs. 4s without - # the sleep). - import time - - time.sleep(2) - - def stop(self) -> None: - for server in self: - server.stop() - self._servers = [] - - def terminate(self) -> None: - for server in self: - server.destroy() - - def reset(self) -> None: - for server in self: - server.reset() - - def get_logs(self) -> list[str]: - logs = [] - for server in self: - logs += server.get_logs() - return logs - - def _perform_ssl_certs_generation(self) -> SSLCerts: - ca_private_key = asymmetric.rsa.generate_private_key( - public_exponent=65537, key_size=2048 - ) - ca_name = _x509_common_name("ca.kazoo.org") - ca_public_key = ca_private_key.public_key() - not_valid_before = datetime.datetime.now(tz=datetime.timezone.utc) - not_valid_after = not_valid_before + datetime.timedelta(days=1) - ca_cert = x509.CertificateBuilder( - issuer_name=ca_name, - subject_name=ca_name, - public_key=ca_public_key, - serial_number=1, - not_valid_before=not_valid_before, - not_valid_after=not_valid_after, - extensions=[ - x509.Extension( - x509.BasicConstraints.oid, - critical=True, - value=x509.BasicConstraints(ca=True, path_length=0), - ), - x509.Extension( - x509.KeyUsage.oid, - critical=True, - value=x509.KeyUsage( - digital_signature=False, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=True, - crl_sign=True, - encipher_only=False, - decipher_only=False, - ), - ), - x509.Extension( - x509.SubjectKeyIdentifier.oid, - critical=False, - value=x509.SubjectKeyIdentifier.from_public_key( - ca_public_key - ), - ), - ], - ).sign(ca_private_key, hashes.SHA256()) - - # generate server cert - server_private_key = asymmetric.rsa.generate_private_key( - public_exponent=65537, key_size=2048 - ) - server_public_key = server_private_key.public_key() - - server_cert = x509.CertificateBuilder( - issuer_name=ca_name, - subject_name=_x509_common_name("localhost"), - public_key=server_public_key, - serial_number=2, - not_valid_before=not_valid_before, - not_valid_after=not_valid_after, - ).sign(ca_private_key, hashes.SHA256()) - - # generate client cert - client_private_key = asymmetric.rsa.generate_private_key( - public_exponent=65537, key_size=2048 - ) - client_public_key = client_private_key.public_key() - - client_cert = x509.CertificateBuilder( - issuer_name=ca_name, - subject_name=_x509_common_name("client"), - public_key=client_public_key, - serial_number=3, - not_valid_before=not_valid_before, - not_valid_after=not_valid_after, - ).sign(ca_private_key, hashes.SHA256()) - - dumped_ca_cert = ca_cert.public_bytes(serialization.Encoding.DER) - - tce = jks.TrustedCertEntry.new("kazoo ca", dumped_ca_cert) - truststore = jks.KeyStore.new("jks", [tce]) - - dumped_server_cert = server_cert.public_bytes( - serialization.Encoding.DER - ) - dumped_server_key = server_private_key.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - - server_pke = jks.PrivateKeyEntry.new( - "server cert", [dumped_server_cert], dumped_server_key, "rsa_raw" - ) - - keystore = jks.KeyStore.new("jks", [server_pke]) - - return { - "ca_cert": ca_cert, - "ca_key": ca_private_key, - "ca_cert_pem": ca_cert.public_bytes(serialization.Encoding.PEM), - "server_cert": server_cert, - "server_key": server_private_key, - "client_cert": client_cert, - "client_key": client_private_key, - "client_cert_pem": client_cert.public_bytes( - serialization.Encoding.PEM - ), - "client_key_pem": client_private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ), - "truststore": truststore, - "keystore": keystore, - } - - def get_ssl_client_configuration(self) -> SSLClientConfiguration: - return { - "client_key": self._ssl_configuration["client_key_pem"], - "client_cert": self._ssl_configuration["client_cert_pem"], - "ca_cert": self._ssl_configuration["ca_cert_pem"], - } +# +# Copyright (C) 2010-2011, 2011 Canonical Ltd. All Rights Reserved +# +# This file was originally taken from txzookeeper and modified later. +# +# Authors: +# Kapil Thangavelu and the Kazoo team +# +# txzookeeper is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# txzookeeper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with txzookeeper. If not, see . +from __future__ import annotations + +import code +import datetime +import logging +import os +import os.path +import pathlib +import shutil +import signal +import subprocess +import tempfile +import traceback +from collections import namedtuple +from glob import glob +from itertools import chain +from typing import TYPE_CHECKING, Any, TypedDict + +import jks +from cryptography import x509 +from cryptography.hazmat.primitives import asymmetric, hashes, serialization +from cryptography.x509.oid import NameOID + +if TYPE_CHECKING: + from collections.abc import Iterator + from types import FrameType + +log = logging.getLogger(__name__) + + +def debug(sig: int, frame: FrameType | None) -> None: + """Interrupt running process, and provide a python prompt for + interactive debugging.""" + d = {"_frame": frame} # Allow access to frame object. + if frame is not None: + d.update(frame.f_globals) # Unless shadowed by global + d.update(frame.f_locals) + + i = code.InteractiveConsole(d) + message = "Signal received : entering python shell.\nTraceback:\n" + message += "".join(traceback.format_stack(frame)) + i.interact(message) + + +def listen() -> None: + if os.name != "nt": # SIGUSR1 is not supported on Windows + signal.signal(signal.SIGUSR1, debug) # Register handler + + +listen() + + +def to_java_compatible_path(path: str) -> str: + if os.name == "nt": + path = path.replace("\\", "/") + return path + + +def _x509_common_name(value: str) -> x509.Name: + return x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, value)]) + + +ServerInfo = namedtuple( + "ServerInfo", + "server_id client_port secure_client_port " + "election_port leader_port admin_port peer_type", +) + + +class SSLCerts(TypedDict): + """TypedDict for SSL certificate information.""" + + ca_cert: x509.Certificate + ca_key: asymmetric.rsa.RSAPrivateKey + ca_cert_pem: bytes + server_cert: x509.Certificate + server_key: asymmetric.rsa.RSAPrivateKey + client_cert: x509.Certificate + client_key: asymmetric.rsa.RSAPrivateKey + client_cert_pem: bytes + client_key_pem: bytes + truststore: jks.KeyStore + keystore: jks.KeyStore + + +class SSLClientConfiguration(TypedDict): + """TypedDict for SSL Client configuration.""" + + client_key: bytes + client_cert: bytes + ca_cert: bytes + + +class ManagedZooKeeper: + """Class to manage the running of a ZooKeeper instance for testing. + + Note: no attempt is made to probe the ZooKeeper instance is + actually available, or that the selected port is free. In the + future, we may want to do that, especially when run in a + Hudson/Buildbot context, to ensure more test robustness.""" + + def __init__( + self, + software_path: str, + server_info: ServerInfo, + peers: list[ServerInfo], + *, + classpath: str, + configuration_entries: list[str], + java_system_properties: list[str], + ssl_configuration: SSLCerts, + jaas_config: str | None = None, + ): + """Define the ZooKeeper test instance. + + @param install_path: The path to the install for ZK + @param port: The port to run the managed ZK instance + """ + self.install_path = software_path + self._classpath = classpath + self.server_info = server_info + self.host = "127.0.0.1" + self.peers = peers + self.working_path: str = tempfile.mkdtemp() + self._running: bool = False + self.configuration_entries = configuration_entries + self.java_system_properties = java_system_properties + self.jaas_config = jaas_config + self.ssl_configuration = ssl_configuration + + def run(self) -> None: + """Run the ZooKeeper instance under a temporary directory. + + Writes ZK log messages to zookeeper.log in the current directory. + """ + if self.running: + return + config_path = os.path.join(self.working_path, "zoo.cfg") + jaas_config_path = os.path.join(self.working_path, "jaas.conf") + log_path = os.path.join(self.working_path, "log") + log4j_path = os.path.join(self.working_path, "log4j.properties") + data_path = os.path.join(self.working_path, "data") + truststore_path = os.path.join(self.working_path, "truststore.jks") + keystore_path = os.path.join(self.working_path, "keystore.jks") + + # various setup steps + if not os.path.exists(self.working_path): + os.mkdir(self.working_path) + if not os.path.exists(log_path): + os.mkdir(log_path) + if not os.path.exists(data_path): + os.mkdir(data_path) + + try: + self.ssl_configuration["truststore"].save( + truststore_path, "apassword" + ) + self.ssl_configuration["keystore"].save(keystore_path, "apassword") + except Exception: + log.exception("Unable to perform SSL configuration: ") + raise + + with open(config_path, "w") as config: + config.write( + """ +tickTime=2000 +dataDir=%s +clientPort=%s +secureClientPort=%s +maxClientCnxns=0 +admin.serverPort=%s +serverCnxnFactory=org.apache.zookeeper.server.NettyServerCnxnFactory +authProvider.1=org.apache.zookeeper.server.auth.SASLAuthenticationProvider +ssl.keyStore.location=%s +ssl.keyStore.password=apassword +ssl.trustStore.location=%s +ssl.trustStore.password=apassword +%s +""" + % ( + to_java_compatible_path(data_path), + self.server_info.client_port, + self.server_info.secure_client_port, + self.server_info.admin_port, + to_java_compatible_path(keystore_path), + to_java_compatible_path(truststore_path), + "\n".join(self.configuration_entries), + ) + ) + + # setup a replicated setup if peers are specified + if self.peers: + servers_cfg = [] + for p in chain((self.server_info,), self.peers): + servers_cfg.append( + "server.%s=localhost:%s:%s:%s" + % ( + p.server_id, + p.leader_port, + p.election_port, + p.peer_type, + ) + ) + + with open(config_path, "a") as config: + config.write( + """ +initLimit=4 +syncLimit=2 +%s +peerType=%s +""" + % ("\n".join(servers_cfg), self.server_info.peer_type) + ) + + # Write server ids into datadir + with open(os.path.join(data_path, "myid"), "w") as myid_file: + myid_file.write(str(self.server_info.server_id)) + # Write JAAS configuration + with open(jaas_config_path, "w") as jaas_file: + jaas_file.write(self.jaas_config or "") + with open(log4j_path, "w") as log4j: + log4j.write( + """ +# DEFAULT: console appender only +log4j.rootLogger=INFO, ROLLINGFILE +log4j.appender.ROLLINGFILE.layout=org.apache.log4j.PatternLayout +log4j.appender.ROLLINGFILE.layout.ConversionPattern=%d{ISO8601} \ +[myid:%X{myid}] - %-5p [%t:%C{1}@%L] - %m%n +log4j.appender.ROLLINGFILE=org.apache.log4j.RollingFileAppender +log4j.appender.ROLLINGFILE.Threshold=DEBUG +log4j.appender.ROLLINGFILE.File=""" + + to_java_compatible_path( + self.working_path + os.sep + "zookeeper.log\n" + ) + ) + + args = ( + [ + "java", + "-cp", + self.classpath, + # make_digest_acl_credential assumes UTF-8, but ZK decodes + # digest auth packets using the JVM's default "charset"--which + # depends on the environment. Force it to use UTF-8 to avoid + # test failures. + "-Dfile.encoding=UTF-8", + # "-Dlog4j.debug", + "-Dreadonlymode.enabled=true", + "-Dzookeeper.log.dir=%s" % log_path, + "-Dzookeeper.root.logger=INFO,CONSOLE", + "-Dlog4j.configuration=file:%s" % log4j_path, + # OS X: Prevent java from appearing in menu bar, process dock + # and from activation of the main workspace on run. + "-Djava.awt.headless=true", + # JAAS configuration for SASL authentication + "-Djava.security.auth.login.config=%s" % jaas_config_path, + ] + + list(self.java_system_properties) + + [ + "org.apache.zookeeper.server.quorum.QuorumPeerMain", + config_path, + ] + ) + self.process = subprocess.Popen(args=args) + log.info( + "Started zookeeper process %s on port %s using args %s", + self.process.pid, + self.server_info.client_port, + args, + ) + self._running = True + + @property + def classpath(self) -> str: + """Get the classpath necessary to run ZooKeeper.""" + + if self._classpath: + return self._classpath + + # Two possibilities, as seen in zkEnv.sh: + # Check for a release - top-level zookeeper-*.jar? + jars = glob(os.path.join(self.install_path, "zookeeper-*.jar")) + if jars: + # Release build (`ant package`) + jars.extend(glob(os.path.join(self.install_path, "lib", "*.jar"))) + jars.extend(glob(os.path.join(self.install_path, "*.jar"))) + # support for different file locations on Debian/Ubuntu + jars.extend(glob(os.path.join(self.install_path, "log4j-*.jar"))) + jars.extend( + glob(os.path.join(self.install_path, "slf4j-api-*.jar")) + ) + jars.extend( + glob(os.path.join(self.install_path, "slf4j-log4j*.jar")) + ) + else: + # Development build (plain `ant`) + jars = glob( + os.path.join(self.install_path, "build", "zookeeper-*.jar") + ) + jars.extend( + glob(os.path.join(self.install_path, "build", "lib", "*.jar")) + ) + + return os.pathsep.join(jars) + + @property + def address(self) -> str: + """Get the address of the ZooKeeper instance.""" + return "%s:%s" % (self.host, self.client_port) + + @property + def secure_address(self) -> str: + """Get the address of the SSL ZooKeeper instance.""" + return "%s:%s" % (self.host, self.secure_client_port) + + @property + def running(self) -> bool: + return self._running + + @property + def client_port(self) -> Any: + return self.server_info.client_port + + @property + def secure_client_port(self) -> Any: + return self.server_info.secure_client_port + + def reset(self) -> None: + """Stop the zookeeper instance, cleaning out its on disk-data.""" + self.stop() + shutil.rmtree(os.path.join(self.working_path, "data"), True) + os.mkdir(os.path.join(self.working_path, "data")) + with open(os.path.join(self.working_path, "data", "myid"), "w") as fh: + fh.write(str(self.server_info.server_id)) + + def stop(self) -> None: + """Stop the Zookeeper instance, retaining on disk state.""" + if not self.running: + return + self.process.terminate() + self.process.wait() + if self.process.returncode != 0: + log.warning( + "Zookeeper process %s failed to terminate with" + " non-zero return code (it terminated with %s return" + " code instead)", + self.process.pid, + self.process.returncode, + ) + self._running = False + + def destroy(self) -> None: + """Stop the ZooKeeper instance and destroy its on disk-state""" + # called by at exit handler, reimport to avoid cleanup race. + self.stop() + + shutil.rmtree(self.working_path, True) + + def get_logs(self, num_lines: int = 100) -> list[str]: + log_path = pathlib.Path(self.working_path, "zookeeper.log") + if log_path.exists(): + with log_path.open("r") as log_file: + lines = log_file.readlines() + return lines[-num_lines:] + return [] + + +class ZookeeperCluster: + def __init__( + self, + install_path: str, + classpath: str, + size: int, + port_offset: int, + observer_start_id: int, + configuration_entries: list[str], + java_system_properties: list[str], + jaas_config: str | None, + ): + self._install_path = install_path + self._classpath = classpath + self._servers = [] + self._ssl_configuration = self._perform_ssl_certs_generation() + + # Calculate ports and peer group + port = port_offset + peers: list[ServerInfo] = [] + + for i in range(size): + server_id = i + 1 + if observer_start_id != -1 and server_id >= observer_start_id: + peer_type = "observer" + else: + peer_type = "participant" + info = ServerInfo( + server_id, + port, + port + 4, + port + 1, + port + 2, + port + 3, + peer_type, + ) + peers.append(info) + port += 10 + + # Instantiate Managed ZK Servers + for i in range(size): + server_peers = list(peers) + server_info = server_peers.pop(i) + self._servers.append( + ManagedZooKeeper( + self._install_path, + server_info, + server_peers, + classpath=self._classpath, + configuration_entries=configuration_entries, + java_system_properties=java_system_properties, + jaas_config=jaas_config, + ssl_configuration=self._ssl_configuration, + ) + ) + + def __getitem__(self, k: int) -> ManagedZooKeeper: + return self._servers[k] + + def __iter__(self) -> Iterator[ManagedZooKeeper]: + return iter(self._servers) + + def start(self) -> None: + # Zookeeper client expresses a preference for either lower ports or + # lexicographical ordering of hosts, to ensure that all servers have a + # chance to startup, start them in reverse order. + for server in reversed(list(self)): + server.run() + # Giving the servers a moment to start, decreases the overall time + # required for a client to successfully connect (2s vs. 4s without + # the sleep). + import time + + time.sleep(2) + + def stop(self) -> None: + for server in self: + server.stop() + self._servers = [] + + def terminate(self) -> None: + for server in self: + server.destroy() + + def reset(self) -> None: + for server in self: + server.reset() + + def get_logs(self) -> list[str]: + logs = [] + for server in self: + logs += server.get_logs() + return logs + + def _perform_ssl_certs_generation(self) -> SSLCerts: + ca_private_key = asymmetric.rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + ca_name = _x509_common_name("ca.kazoo.org") + ca_public_key = ca_private_key.public_key() + not_valid_before = datetime.datetime.now(tz=datetime.timezone.utc) + not_valid_after = not_valid_before + datetime.timedelta(days=1) + ca_cert = x509.CertificateBuilder( + issuer_name=ca_name, + subject_name=ca_name, + public_key=ca_public_key, + serial_number=1, + not_valid_before=not_valid_before, + not_valid_after=not_valid_after, + extensions=[ + x509.Extension( + x509.BasicConstraints.oid, + critical=True, + value=x509.BasicConstraints(ca=True, path_length=0), + ), + x509.Extension( + x509.KeyUsage.oid, + critical=True, + value=x509.KeyUsage( + digital_signature=False, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + ), + x509.Extension( + x509.SubjectKeyIdentifier.oid, + critical=False, + value=x509.SubjectKeyIdentifier.from_public_key( + ca_public_key + ), + ), + ], + ).sign(ca_private_key, hashes.SHA256()) + + # generate server cert + server_private_key = asymmetric.rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + server_public_key = server_private_key.public_key() + + server_cert = x509.CertificateBuilder( + issuer_name=ca_name, + subject_name=_x509_common_name("localhost"), + public_key=server_public_key, + serial_number=2, + not_valid_before=not_valid_before, + not_valid_after=not_valid_after, + ).sign(ca_private_key, hashes.SHA256()) + + # generate client cert + client_private_key = asymmetric.rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + client_public_key = client_private_key.public_key() + + client_cert = x509.CertificateBuilder( + issuer_name=ca_name, + subject_name=_x509_common_name("client"), + public_key=client_public_key, + serial_number=3, + not_valid_before=not_valid_before, + not_valid_after=not_valid_after, + ).sign(ca_private_key, hashes.SHA256()) + + dumped_ca_cert = ca_cert.public_bytes(serialization.Encoding.DER) + + tce = jks.TrustedCertEntry.new("kazoo ca", dumped_ca_cert) + truststore = jks.KeyStore.new("jks", [tce]) + + dumped_server_cert = server_cert.public_bytes( + serialization.Encoding.DER + ) + dumped_server_key = server_private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + server_pke = jks.PrivateKeyEntry.new( + "server cert", [dumped_server_cert], dumped_server_key, "rsa_raw" + ) + + keystore = jks.KeyStore.new("jks", [server_pke]) + + return { + "ca_cert": ca_cert, + "ca_key": ca_private_key, + "ca_cert_pem": ca_cert.public_bytes(serialization.Encoding.PEM), + "server_cert": server_cert, + "server_key": server_private_key, + "client_cert": client_cert, + "client_key": client_private_key, + "client_cert_pem": client_cert.public_bytes( + serialization.Encoding.PEM + ), + "client_key_pem": client_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ), + "truststore": truststore, + "keystore": keystore, + } + + def get_ssl_client_configuration(self) -> SSLClientConfiguration: + return { + "client_key": self._ssl_configuration["client_key_pem"], + "client_cert": self._ssl_configuration["client_cert_pem"], + "ca_cert": self._ssl_configuration["ca_cert_pem"], + } diff --git a/kazoo/tests/test_async_result.py b/kazoo/tests/test_async_result.py new file mode 100644 index 000000000..1a0706ff8 --- /dev/null +++ b/kazoo/tests/test_async_result.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import threading +import unittest + +from typing import Any, Callable +from unittest.mock import Mock + +# Using AsyncResult from kazoo.handlers.utils isn't ideal, but it allows us to +# smoke test the AsyncResult interface without having to depend on a specific +# handler implementation or writing a mock. +# Actually this whole thing is probably now useless, since well call this from +# the other handler tests, but leaving it here for now. +from kazoo.handlers.utils import AsyncResult +from kazoo.interfaces import IHandler, SpawnedFunc +from kazoo.testing.async_result_common import ( + AsyncResultCommon, + ThreadlikeContext, +) + + +class Mock_Condition: + def __init__(self) -> None: + self._event = threading.Event() + + def __enter__(self) -> Mock_Condition: + return self + + def __exit__(self, *exc_info: object) -> None: + pass + + def wait(self, timeout: float | None = None) -> bool: + return self._event.wait(timeout) + + def notify_all(self) -> None: + self._event.set() + + +class Mock_Timeout(BaseException): + pass + + +class Mock_Handler(Mock): + _running: bool + + @property + def running(self) -> bool: + return self._running + + +class ThreadContext(ThreadlikeContext): + def __init__(self, target: Callable[[], None]): + super().__init__(target) + self._thread = threading.Thread(target=self._target) + + def __enter__(self) -> None: + self._thread.start() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> None: + self._thread.join() + + +class TestAsyncResult(AsyncResultCommon, unittest.TestCase): + def __init__(self, *args: Any, **kwargs: Any) -> None: + def condition_factory() -> Mock_Condition: + return Mock_Condition() + + def timeout_factory() -> Mock_Timeout: + return Mock_Timeout() + + def handler_factory() -> Mock_Handler: + handler = Mock_Handler(spec=IHandler) + handler._running = False + + def start() -> None: + handler._running = True + + def stop() -> None: + handler._running = False + + def add_to_completion_queue(callback: SpawnedFunc) -> None: + callback() + + handler.start.side_effect = start + handler.stop.side_effect = stop + handler.add_to_completion_queue.side_effect = ( + add_to_completion_queue + ) + handler.timeout_exception = Mock_Timeout + handler.event_object = lambda: threading.Event() + return handler + + super().__init__( + handler_factory, + lambda handler: AsyncResult( + handler, condition_factory, timeout_factory + ), + thread_context_factory=ThreadContext, + *args, + **kwargs, + ) diff --git a/kazoo/tests/test_cache.py b/kazoo/tests/test_cache.py index 15a672018..1606e061a 100644 --- a/kazoo/tests/test_cache.py +++ b/kazoo/tests/test_cache.py @@ -4,7 +4,8 @@ import importlib import sys import uuid -from typing import Any, TYPE_CHECKING +from contextlib import contextmanager +from typing import Any, Iterator, TYPE_CHECKING from unittest.mock import patch, call, Mock import pytest @@ -53,7 +54,7 @@ def choose_an_installed_handler( try: mod = importlib.import_module(handler_module) cls = getattr(mod, handler_class) - except ImportError: + except (ImportError, NameError): continue else: return cls() # type: ignore[no-any-return] @@ -66,7 +67,6 @@ def setUp(self) -> None: self._event_queue: Queue[TreeEvent] = self.client.handler.queue_impl() self._error_queue = self.client.handler.queue_impl() self._path: str | None = None - self._cache: TreeCache | None = None def tearDown(self) -> None: if not self._error_queue.empty(): @@ -74,29 +74,22 @@ def tearDown(self) -> None: raise self._error_queue.get() except FakeException: pass - if self._cache is not None: - self._cache.close() - self._cache = None super().tearDown() - def make_cache(self) -> TreeCache: - if self._cache is None: - self._path = "/" + uuid.uuid4().hex - self._cache = TreeCache(self.client, self.path) - self._cache.listen(lambda event: self._event_queue.put(event)) - self._cache.listen_fault( - lambda error: self._error_queue.put(error) - ) - self._cache.start() - return self._cache + @contextmanager + def make_cache(self) -> Iterator[TreeCache]: + self._path = "/" + uuid.uuid4().hex + assert self.count_tree_node() == 0 + cache = TreeCache(self.client, self.path) + assert self.count_tree_node() == 1 + cache.listen(lambda event: self._event_queue.put(event)) + cache.listen_fault(lambda error: self._error_queue.put(error)) + cache.start() - # FIXME This is entirely for the purpose of minimising code changes. - # Calling make_cache twice should be an error and the return value - # should be used, not stored. - @property - def cache(self) -> TreeCache: - assert self._cache is not None - return self._cache + try: + yield cache + finally: + cache.close() @property def path(self) -> str: @@ -129,8 +122,9 @@ def _wait_gc(self) -> None: # trigger switching on some coroutine handlers self.client.handler.sleep_func(0.1) - completion_queue = getattr(self.handler, "completion_queue", None) + completion_queue = getattr(self.handler, "_completion_queue", None) if completion_queue is not None: + print(completion_queue, file=sys.stderr) while not completion_queue.empty(): self.client.handler.sleep_func(0.1) @@ -149,317 +143,337 @@ def count_tree_node(self) -> int: raise RuntimeError("could not count refs exactly") def test_start(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) - stat = self.client.exists(self.path) - assert stat is not None - assert stat.version == 0 + stat = self.client.exists(self.path) + assert stat is not None + assert stat.version == 0 - assert self.cache._state == TreeCache.STATE_STARTED - assert self.cache._root._state == TreeNode.STATE_LIVE + assert cache._state == TreeCache.STATE_STARTED + assert cache._root._state == TreeNode.STATE_LIVE def test_start_started(self) -> None: - self.make_cache() - with pytest.raises(KazooException): - self.cache.start() + with self.make_cache() as cache: + with pytest.raises(KazooException): + cache.start() def test_start_closed(self) -> None: - self.make_cache() - self.cache.close() - with pytest.raises(KazooException): - self.cache.start() + with self.make_cache() as cache: + cache.close() + with pytest.raises(KazooException): + cache.start() def test_close(self) -> None: - assert self.count_tree_node() == 0 + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) + self.client.create(self.path + "/foo/bar/baz", makepath=True) + for _ in range(3): + self.wait_cache(TreeEvent.NODE_ADDED) + + # setup stub watchers which are outside of tree cache + stub_data_watcher = Mock(spec=lambda event: None) + stub_child_watcher = Mock(spec=lambda event: None) + self.client.get(self.path + "/foo", stub_data_watcher) + self.client.get_children(self.path + "/foo", stub_child_watcher) + + # watchers inside tree cache should be here + root_path = self.client.chroot + self.path + assert len(self.client._data_watchers[root_path + "/foo"]) == 2 + assert len(self.client._data_watchers[root_path + "/foo/bar"]) == 1 + assert ( + len(self.client._data_watchers[root_path + "/foo/bar/baz"]) + == 1 + ) + assert len(self.client._child_watchers[root_path + "/foo"]) == 2 + assert ( + len(self.client._child_watchers[root_path + "/foo/bar"]) == 1 + ) + assert ( + len(self.client._child_watchers[root_path + "/foo/bar/baz"]) + == 1 + ) - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - self.client.create(self.path + "/foo/bar/baz", makepath=True) - for _ in range(3): - self.wait_cache(TreeEvent.NODE_ADDED) + cache.close() + + # nothing should be published since tree closed + assert self._event_queue.empty() + + # tree should be empty + assert cache._root._children == {} + assert cache._root._data is None + assert cache._state == TreeCache.STATE_CLOSED + + # node state should not be changed + assert cache._root._state != TreeNode.STATE_DEAD + + # watchers should be reset + assert len(self.client._data_watchers[root_path + "/foo"]) == 1 + assert len(self.client._data_watchers[root_path + "/foo/bar"]) == 0 + assert ( + len(self.client._data_watchers[root_path + "/foo/bar/baz"]) + == 0 + ) + assert len(self.client._child_watchers[root_path + "/foo"]) == 1 + assert ( + len(self.client._child_watchers[root_path + "/foo/bar"]) == 0 + ) + assert ( + len(self.client._child_watchers[root_path + "/foo/bar/baz"]) + == 0 + ) - # setup stub watchers which are outside of tree cache - stub_data_watcher = Mock(spec=lambda event: None) - stub_child_watcher = Mock(spec=lambda event: None) - self.client.get(self.path + "/foo", stub_data_watcher) - self.client.get_children(self.path + "/foo", stub_child_watcher) - - # watchers inside tree cache should be here - root_path = self.client.chroot + self.path - assert len(self.client._data_watchers[root_path + "/foo"]) == 2 - assert len(self.client._data_watchers[root_path + "/foo/bar"]) == 1 - assert len(self.client._data_watchers[root_path + "/foo/bar/baz"]) == 1 - assert len(self.client._child_watchers[root_path + "/foo"]) == 2 - assert len(self.client._child_watchers[root_path + "/foo/bar"]) == 1 - assert ( - len(self.client._child_watchers[root_path + "/foo/bar/baz"]) == 1 - ) - - self.cache.close() - - # nothing should be published since tree closed - assert self._event_queue.empty() - - # tree should be empty - assert self.cache._root._children == {} - assert self.cache._root._data is None - assert self.cache._state == TreeCache.STATE_CLOSED - - # node state should not be changed - assert self.cache._root._state != TreeNode.STATE_DEAD - - # watchers should be reset - assert len(self.client._data_watchers[root_path + "/foo"]) == 1 - assert len(self.client._data_watchers[root_path + "/foo/bar"]) == 0 - assert len(self.client._data_watchers[root_path + "/foo/bar/baz"]) == 0 - assert len(self.client._child_watchers[root_path + "/foo"]) == 1 - assert len(self.client._child_watchers[root_path + "/foo/bar"]) == 0 - assert ( - len(self.client._child_watchers[root_path + "/foo/bar/baz"]) == 0 - ) - - # outside watchers should not be deleted - assert ( - list(self.client._data_watchers[root_path + "/foo"])[0] - == stub_data_watcher - ) - assert ( - list(self.client._child_watchers[root_path + "/foo"])[0] - == stub_child_watcher - ) - - # FIXME This looks pointless at best. - self._cache = None + # outside watchers should not be deleted + assert ( + list(self.client._data_watchers[root_path + "/foo"])[0] + == stub_data_watcher + ) + assert ( + list(self.client._child_watchers[root_path + "/foo"])[0] + == stub_child_watcher + ) # should not be any leaked memory (tree node) here + cache = None # type: ignore assert self.count_tree_node() == 0 def test_delete_operation(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - - assert self.count_tree_node() == 1 + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) - self.client.create(self.path + "/foo/bar/baz", makepath=True) - for _ in range(3): - self.wait_cache(TreeEvent.NODE_ADDED) + self.client.create(self.path + "/foo/bar/baz", makepath=True) + for _ in range(3): + self.wait_cache(TreeEvent.NODE_ADDED) - self.client.delete(self.path + "/foo", recursive=True) - for _ in range(3): - self.wait_cache(TreeEvent.NODE_REMOVED) + self.client.delete(self.path + "/foo", recursive=True) + for _ in range(3): + self.wait_cache(TreeEvent.NODE_REMOVED) - # tree should be empty - assert self.cache._root._children == {} + # tree should be empty + assert cache._root._children == {} - # watchers should be reset - root_path = self.client.chroot + self.path - assert self.client._data_watchers[root_path + "/foo"] == set() - assert self.client._data_watchers[root_path + "/foo/bar"] == set() - assert self.client._data_watchers[root_path + "/foo/bar/baz"] == set() - assert self.client._child_watchers[root_path + "/foo"] == set() - assert self.client._child_watchers[root_path + "/foo/bar"] == set() - assert self.client._child_watchers[root_path + "/foo/bar/baz"] == set() + # watchers should be reset + root_path = self.client.chroot + self.path + assert self.client._data_watchers[root_path + "/foo"] == set() + assert self.client._data_watchers[root_path + "/foo/bar"] == set() + assert ( + self.client._data_watchers[root_path + "/foo/bar/baz"] == set() + ) + assert self.client._child_watchers[root_path + "/foo"] == set() + assert self.client._child_watchers[root_path + "/foo/bar"] == set() + assert ( + self.client._child_watchers[root_path + "/foo/bar/baz"] + == set() + ) - # should not be any leaked memory (tree node) here - assert self.count_tree_node() == 1 + # This should be the only tree left + assert self.count_tree_node() == 1 def test_children_operation(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - - self.client.create(self.path + "/test_children", b"test_children_1") - event = self.wait_cache(TreeEvent.NODE_ADDED) - assert event is not None - assert event.event_type == TreeEvent.NODE_ADDED - assert event.event_data.path == self.path + "/test_children" - assert event.event_data.data == b"test_children_1" - assert event.event_data.stat.version == 0 - - self.client.set(self.path + "/test_children", b"test_children_2") - event = self.wait_cache(TreeEvent.NODE_UPDATED) - assert event is not None - assert event.event_type == TreeEvent.NODE_UPDATED - assert event.event_data.path == self.path + "/test_children" - assert event.event_data.data == b"test_children_2" - assert event.event_data.stat.version == 1 - - self.client.delete(self.path + "/test_children") - event = self.wait_cache(TreeEvent.NODE_REMOVED) - assert event is not None - assert event.event_type == TreeEvent.NODE_REMOVED - assert event.event_data.path == self.path + "/test_children" - assert event.event_data.data == b"test_children_2" - assert event.event_data.stat.version == 1 - - def test_subtree_operation(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) + with self.make_cache(): + self.wait_cache(since=TreeEvent.INITIALIZED) - self.client.create(self.path + "/foo/bar/baz", makepath=True) - for relative_path in ("/foo", "/foo/bar", "/foo/bar/baz"): + self.client.create( + self.path + "/test_children", b"test_children_1" + ) event = self.wait_cache(TreeEvent.NODE_ADDED) assert event is not None assert event.event_type == TreeEvent.NODE_ADDED - assert event.event_data.path == self.path + relative_path - assert event.event_data.data == b"" + assert event.event_data.path == self.path + "/test_children" + assert event.event_data.data == b"test_children_1" assert event.event_data.stat.version == 0 - self.client.delete(self.path + "/foo", recursive=True) - for relative_path in ("/foo/bar/baz", "/foo/bar", "/foo"): + self.client.set(self.path + "/test_children", b"test_children_2") + event = self.wait_cache(TreeEvent.NODE_UPDATED) + assert event is not None + assert event.event_type == TreeEvent.NODE_UPDATED + assert event.event_data.path == self.path + "/test_children" + assert event.event_data.data == b"test_children_2" + assert event.event_data.stat.version == 1 + + self.client.delete(self.path + "/test_children") event = self.wait_cache(TreeEvent.NODE_REMOVED) assert event is not None assert event.event_type == TreeEvent.NODE_REMOVED - assert event.event_data.path == self.path + relative_path + assert event.event_data.path == self.path + "/test_children" + assert event.event_data.data == b"test_children_2" + assert event.event_data.stat.version == 1 + + def test_subtree_operation(self) -> None: + with self.make_cache(): + self.wait_cache(since=TreeEvent.INITIALIZED) + + self.client.create(self.path + "/foo/bar/baz", makepath=True) + for relative_path in ("/foo", "/foo/bar", "/foo/bar/baz"): + event = self.wait_cache(TreeEvent.NODE_ADDED) + assert event is not None + assert event.event_type == TreeEvent.NODE_ADDED + assert event.event_data.path == self.path + relative_path + assert event.event_data.data == b"" + assert event.event_data.stat.version == 0 + + self.client.delete(self.path + "/foo", recursive=True) + for relative_path in ("/foo/bar/baz", "/foo/bar", "/foo"): + event = self.wait_cache(TreeEvent.NODE_REMOVED) + assert event is not None + assert event.event_type == TreeEvent.NODE_REMOVED + assert event.event_data.path == self.path + relative_path def test_get_data(self) -> None: - cache = self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - self.client.create(self.path + "/foo/bar/baz", b"@", makepath=True) - self.wait_cache(TreeEvent.NODE_ADDED) - self.wait_cache(TreeEvent.NODE_ADDED) - self.wait_cache(TreeEvent.NODE_ADDED) - - with patch.object(cache, "_client"): # disable any remote operation - node = cache.get_data(self.path) - assert node is not None - assert node.data == b"" - assert node.stat.version == 0 - - node = cache.get_data(self.path + "foo") - assert node is not None - assert node.data == b"" - assert node.stat.version == 0 - - node = cache.get_data(self.path + "foo/bar") - assert node is not None - assert node.data == b"" - assert node.stat.version == 0 - - node = cache.get_data(self.path + "foo/bar/baz") - assert node is not None - assert node.data == b"@" - assert node.stat.version == 0 + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) + self.client.create(self.path + "/foo/bar/baz", b"@", makepath=True) + self.wait_cache(TreeEvent.NODE_ADDED) + self.wait_cache(TreeEvent.NODE_ADDED) + self.wait_cache(TreeEvent.NODE_ADDED) + + # disable any remote operations + with patch.object(cache, "_client"): + node = cache.get_data(self.path) + assert node is not None + assert node.data == b"" + assert node.stat.version == 0 + + node = cache.get_data(self.path + "foo") + assert node is not None + assert node.data == b"" + assert node.stat.version == 0 + + node = cache.get_data(self.path + "foo/bar") + assert node is not None + assert node.data == b"" + assert node.stat.version == 0 + + node = cache.get_data(self.path + "foo/bar/baz") + assert node is not None + assert node.data == b"@" + assert node.stat.version == 0 def test_get_children(self) -> None: - cache = self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - self.client.create(self.path + "/foo/bar/baz", b"@", makepath=True) - self.wait_cache(TreeEvent.NODE_ADDED) - self.wait_cache(TreeEvent.NODE_ADDED) - self.wait_cache(TreeEvent.NODE_ADDED) - - with patch.object(cache, "_client"): # disable any remote operation - assert ( - cache.get_children(self.path + "/foo/bar/baz") == frozenset() - ) - assert cache.get_children(self.path + "/foo/bar") == frozenset( - ["baz"] - ) - assert cache.get_children(self.path + "/foo") == frozenset(["bar"]) - assert cache.get_children(self.path) == frozenset(["foo"]) + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) + self.client.create(self.path + "/foo/bar/baz", b"@", makepath=True) + self.wait_cache(TreeEvent.NODE_ADDED) + self.wait_cache(TreeEvent.NODE_ADDED) + self.wait_cache(TreeEvent.NODE_ADDED) + + # Disable any remote operations + with patch.object(cache, "_client"): + assert ( + cache.get_children(self.path + "/foo/bar/baz") + == frozenset() + ) + assert cache.get_children(self.path + "/foo/bar") == frozenset( + ["baz"] + ) + assert cache.get_children(self.path + "/foo") == frozenset( + ["bar"] + ) + assert cache.get_children(self.path) == frozenset(["foo"]) def test_get_data_out_of_tree(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - with pytest.raises(ValueError): - self.cache.get_data("/out_of_tree") + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) + with pytest.raises(ValueError): + cache.get_data("/out_of_tree") def test_get_children_out_of_tree(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - with pytest.raises(ValueError): - self.cache.get_children("/out_of_tree") + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) + with pytest.raises(ValueError): + cache.get_children("/out_of_tree") def test_get_data_no_node(self) -> None: - cache = self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) - with patch.object(cache, "_client"): # disable any remote operation - assert cache.get_data(self.path + "/non_exists") is None + with patch.object(cache, "_client"): + assert cache.get_data(self.path + "/non_exists") is None def test_get_children_no_node(self) -> None: - cache = self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) - with patch.object(cache, "_client"): # disable any remote operation - assert cache.get_children(self.path + "/non_exists") is None + with patch.object(cache, "_client"): + assert cache.get_children(self.path + "/non_exists") is None def test_session_reconnected(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - - self.client.create(self.path + "/foo") - event = self.wait_cache(TreeEvent.NODE_ADDED) - assert event is not None - assert event.event_data.path == self.path + "/foo" - - with self.spy_client("get_async") as get_data: - with self.spy_client("get_children_async") as get_children: - # session suspended - self.lose_connection(self.client.handler.event_object) - self.wait_cache(TreeEvent.CONNECTION_SUSPENDED) - - # There are a serial refreshing operation here. But NODE_ADDED - # events will not be raised because the zxid of nodes are the - # same during reconnecting. - - # connection restore - self.wait_cache(TreeEvent.CONNECTION_RECONNECTED) - - # wait for outstanding operations - while self.cache._outstanding_ops > 0: - self.client.handler.sleep_func(0.1) - - # inspect in-memory nodes - _node_root = self.cache._root - _node_foo = self.cache._root._children["foo"] - - # make sure that all nodes are refreshed - get_data.assert_has_calls( - [ - call(self.path, watch=_node_root._process_watch), - call( - self.path + "/foo", watch=_node_foo._process_watch - ), - ], - any_order=True, - ) - get_children.assert_has_calls( - [ - call(self.path, watch=_node_root._process_watch), - call( - self.path + "/foo", watch=_node_foo._process_watch - ), - ], - any_order=True, - ) + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) + + self.client.create(self.path + "/foo") + event = self.wait_cache(TreeEvent.NODE_ADDED) + assert event is not None + assert event.event_data.path == self.path + "/foo" + + with self.spy_client("get_async") as get_data: + with self.spy_client("get_children_async") as get_children: + # session suspended + self.lose_connection(self.client.handler.event_object) + self.wait_cache(TreeEvent.CONNECTION_SUSPENDED) + + # There are a serial refreshing operation here. But + # NODE_ADDED events will not be raised because the zxid of + # nodes are the same during reconnecting. + + # connection restore + self.wait_cache(TreeEvent.CONNECTION_RECONNECTED) + + # wait for outstanding operations + while cache._outstanding_ops > 0: + self.client.handler.sleep_func(0.1) + + # inspect in-memory nodes + _node_root = cache._root + _node_foo = cache._root._children["foo"] + + # make sure that all nodes are refreshed + get_data.assert_has_calls( + [ + call(self.path, watch=_node_root._process_watch), + call( + self.path + "/foo", + watch=_node_foo._process_watch, + ), + ], + any_order=True, + ) + get_children.assert_has_calls( + [ + call(self.path, watch=_node_root._process_watch), + call( + self.path + "/foo", + watch=_node_foo._process_watch, + ), + ], + any_order=True, + ) def test_root_recreated(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) - - # remove root node - self.client.delete(self.path) - event = self.wait_cache(TreeEvent.NODE_REMOVED) - assert event is not None - assert event.event_type == TreeEvent.NODE_REMOVED - assert event.event_data.data == b"" - assert event.event_data.path == self.path - assert event.event_data.stat.version == 0 - - # re-create root node - self.client.ensure_path(self.path) - event = self.wait_cache(TreeEvent.NODE_ADDED) - assert event is not None - assert event.event_type == TreeEvent.NODE_ADDED - assert event.event_data.data == b"" - assert event.event_data.path == self.path - assert event.event_data.stat.version == 0 - - assert self.cache._outstanding_ops >= 0, ( - "unexpected outstanding ops %r" % self.cache._outstanding_ops - ) + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) + + # remove root node + self.client.delete(self.path) + event = self.wait_cache(TreeEvent.NODE_REMOVED) + assert event is not None + assert event.event_type == TreeEvent.NODE_REMOVED + assert event.event_data.data == b"" + assert event.event_data.path == self.path + assert event.event_data.stat.version == 0 + + # re-create root node + self.client.ensure_path(self.path) + event = self.wait_cache(TreeEvent.NODE_ADDED) + assert event is not None + assert event.event_type == TreeEvent.NODE_ADDED + assert event.event_data.data == b"" + assert event.event_data.path == self.path + assert event.event_data.stat.version == 0 + + assert cache._outstanding_ops >= 0, ( + "unexpected outstanding ops %r" % cache._outstanding_ops + ) def test_exception_handler(self) -> None: error_value = FakeException() @@ -468,30 +482,30 @@ def test_exception_handler(self) -> None: with patch.object(TreeNode, "on_deleted") as on_deleted: on_deleted.side_effect = [error_value] - self.make_cache() - self.cache.listen_fault(error_handler) + with self.make_cache() as cache: + cache.listen_fault(error_handler) - self.cache.close() - error_handler.assert_called_once_with(error_value) + cache.close() + error_handler.assert_called_once_with(error_value) def test_exception_suppressed(self) -> None: - self.make_cache() - self.wait_cache(since=TreeEvent.INITIALIZED) + with self.make_cache() as cache: + self.wait_cache(since=TreeEvent.INITIALIZED) - # stoke up ConnectionClosedError - self.client.stop() - self.client.close() - self.client.handler.start() # keep the async completion - self.wait_cache(since=TreeEvent.CONNECTION_LOST) + # stoke up ConnectionClosedError + self.client.stop() + self.client.close() + self.client.handler.start() # keep the async completion + self.wait_cache(since=TreeEvent.CONNECTION_LOST) - with patch.object(TreeNode, "on_created") as on_created: - self.cache._root._call_client("exists", "/") - self.cache._root._call_client("get", "/") - self.cache._root._call_client("get_children", "/") + with patch.object(TreeNode, "on_created") as on_created: + cache._root._call_client("exists", "/") + cache._root._call_client("get", "/") + cache._root._call_client("get_children", "/") - self.wait_cache(since=TreeEvent.INITIALIZED) - on_created.assert_not_called() - assert self.cache._outstanding_ops == 0 + self.wait_cache(since=TreeEvent.INITIALIZED) + on_created.assert_not_called() + assert cache._outstanding_ops == 0 class FakeException(Exception): diff --git a/kazoo/tests/test_client.py b/kazoo/tests/test_client.py index c0263c1ae..76be9242e 100644 --- a/kazoo/tests/test_client.py +++ b/kazoo/tests/test_client.py @@ -1,1456 +1,1483 @@ -from __future__ import annotations - -import os -import socket -import tempfile -import threading -import time -import uuid -import unittest - -from typing import Any, TYPE_CHECKING -from unittest.mock import Mock, MagicMock, patch - -import pytest - - -from kazoo.client import KazooClient -from kazoo.exceptions import ( - AuthFailedError, - BadArgumentsError, - BadVersionError, - ConfigurationError, - ConnectionClosedError, - ConnectionLoss, - InvalidACLError, - NoAuthError, - NoChildrenForEphemeralsError, - NoNodeError, - NodeExistsError, - RolledBackError, - SessionExpiredError, - KazooException, -) -from kazoo.protocol.states import KazooState, KeeperState, WatchedEvent -from kazoo.handlers.threading import ( - SequentialThreadingHandler, - KazooTimeoutError, -) -from kazoo.protocol.connection import _CONNECTION_DROP -from kazoo.retry import KazooRetry -from kazoo.security import ( - make_digest_acl_credential, - CREATOR_ALL_ACL, - make_digest_acl, - ACL, - OPEN_ACL_UNSAFE, -) - -from kazoo.testing import KazooTestCase - -if TYPE_CHECKING: - from kazoo.testing.common import ManagedZooKeeper - from kazoo.interfaces import IAsyncResult - - -class TestClientTransitions(KazooTestCase): - @staticmethod - def make_event() -> threading.Event: - return threading.Event() - - def test_connection_and_disconnection(self) -> None: - states = [] - rc = threading.Event() - - @self.client.add_listener - def listener(state: KazooState) -> None: - states.append(state) - if state == KazooState.CONNECTED: - rc.set() - - self.client.stop() - assert states == [KazooState.LOST] - states.pop() - - self.client.start() - rc.wait(2) - assert states == [KazooState.CONNECTED] - rc.clear() - states.pop() - self.expire_session(self.make_event) - rc.wait(2) - - req_states = [KazooState.LOST, KazooState.CONNECTED] - assert states == req_states - - -class TestClientConstructor(unittest.TestCase): - def _makeOne(self, *args: Any, **kw: Any) -> KazooClient: - return KazooClient(*args, **kw) - - def test_invalid_handler(self) -> None: - with pytest.raises(ConfigurationError): - self._makeOne(handler=SequentialThreadingHandler) - - def test_chroot(self) -> None: - assert self._makeOne(hosts="127.0.0.1:2181/").chroot == "" - assert self._makeOne(hosts="127.0.0.1:2181/a").chroot == "/a" - assert self._makeOne(hosts="127.0.0.1/a").chroot == "/a" - assert self._makeOne(hosts="127.0.0.1/a/b").chroot == "/a/b" - assert ( - self._makeOne(hosts="127.0.0.1:2181,127.0.0.1:2182/a/b").chroot - == "/a/b" - ) - - def test_connection_timeout(self) -> None: - client = self._makeOne(hosts="127.0.0.1:9") - assert client.handler.timeout_exception is KazooTimeoutError - - with pytest.raises(KazooTimeoutError): - client.start(0.1) - - def test_ordered_host_selection(self) -> None: - client = self._makeOne( - hosts="127.0.0.1:9,127.0.0.2:9/a", randomize_hosts=False - ) - hosts = [h for h in client.hosts] - assert hosts == [("127.0.0.1", 9), ("127.0.0.2", 9)] - - def test_invalid_hostname(self) -> None: - client = self._makeOne(hosts="nosuchhost/a") - timeout = client.handler.timeout_exception - with pytest.raises(timeout): - client.start(0.1) - - def test_another_invalid_hostname(self) -> None: - with pytest.raises(ValueError): - self._makeOne(hosts="/nosuchhost/a") - - def test_retry_options_dict(self) -> None: - client = self._makeOne( - command_retry=dict(max_tries=99), connection_retry=dict(delay=99) - ) - assert type(client._conn_retry) is KazooRetry - assert type(client._retry) is KazooRetry - assert client._retry.max_tries == 99 - assert client._conn_retry.delay == 99 - - -class TestAuthentication(KazooTestCase): - def _makeAuth(self, *args: Any, **kwargs: Any) -> ACL: - return make_digest_acl(*args, **kwargs) - - def test_auth(self) -> None: - username = uuid.uuid4().hex - password = uuid.uuid4().hex - - digest_auth = "%s:%s" % (username, password) - acl = self._makeAuth(username, password, all=True) - - client = self._get_client() - client.start() - client.add_auth("digest", digest_auth) - client.default_acl = (acl,) - - try: - client.create("/1") - client.create("/1/2") - client.ensure_path("/1/2/3") - - eve = self._get_client() - - eve.start() - - with pytest.raises(NoAuthError): - eve.get("/1/2") - - # try again with the wrong auth token - eve.add_auth("digest", "badbad:bad") - - with pytest.raises(NoAuthError): - eve.get("/1/2") - - finally: - # Ensure we remove the ACL protected nodes - client.delete("/1", recursive=True) - eve.stop() - eve.close() - - def test_connect_auth(self) -> None: - - username = uuid.uuid4().hex - password = uuid.uuid4().hex - - digest_auth = "%s:%s" % (username, password) - acl = self._makeAuth(username, password, all=True) - - client = self._get_client(auth_data=[("digest", digest_auth)]) - client.start() - try: - client.create("/1", acl=(acl,)) - # Give ZK a chance to copy data to other ensemble nodes. - # Follower reads are sequentially consistent, but may briefly lag - # the leader until the commit is applied locally; sync flushes - # the channel between client's connected server and the leader. - self.client.sync("/1") - - with pytest.raises(NoAuthError): - self.client.get("/1") - - finally: - client.delete("/1") - client.stop() - client.close() - - def test_unicode_auth(self) -> None: - username = r"xe4/\hm" - password = r"/\xe4hm" - digest_auth = "%s:%s" % (username, password) - acl = self._makeAuth(username, password, all=True) - - client = self._get_client() - client.start() - client.add_auth("digest", digest_auth) - client.default_acl = (acl,) - - try: - client.create("/1") - client.ensure_path("/1/2/3") - - eve = self._get_client() - eve.start() - - with pytest.raises(NoAuthError): - eve.get("/1/2") - - # try again with the wrong auth token - eve.add_auth("digest", "badbad:bad") - - with pytest.raises(NoAuthError): - eve.get("/1/2") - - finally: - # Ensure we remove the ACL protected nodes - client.delete("/1", recursive=True) - eve.stop() - eve.close() - - def test_invalid_auth(self) -> None: - client = self._get_client() - client.start() - - with pytest.raises(TypeError): - client.add_auth( - "digest", - ("user", "pass"), # type: ignore[arg-type] - ) - - with pytest.raises(TypeError): - client.add_auth(None, ("user", "pass")) # type: ignore[arg-type] - - def test_async_auth(self) -> None: - client = self._get_client() - client.start() - username = uuid.uuid4().hex - password = uuid.uuid4().hex - digest_auth = "%s:%s" % (username, password) - result = client.add_auth_async("digest", digest_auth) - assert result.get() is True - - def test_async_auth_failure(self) -> None: - client = self._get_client() - client.start() - username = uuid.uuid4().hex - password = uuid.uuid4().hex - digest_auth = "%s:%s" % (username, password) - - with pytest.raises(AuthFailedError): - client.add_auth("unknown-scheme", digest_auth) - - def test_add_auth_on_reconnect(self) -> None: - client = self._get_client() - client.start() - client.add_auth("digest", "jsmith:jsmith") - - ev_lost = client.handler.event_object() - ev_connected = client.handler.event_object() - - def listener(state: KazooState) -> None: - if state in (KazooState.SUSPENDED, KazooState.LOST): - ev_lost.set() - elif state == KazooState.CONNECTED and ev_lost.is_set(): - ev_connected.set() - - client.add_listener(listener) - assert client._connection._socket is not None - client._connection._socket.shutdown(socket.SHUT_RDWR) - - ev_connected.wait(15) - assert ev_connected.is_set() - assert ("digest", "jsmith:jsmith") in client.auth_data - - -class TestConnection(KazooTestCase): - @staticmethod - def make_event() -> threading.Event: - return threading.Event() - - @staticmethod - def make_condition() -> threading.Condition: - return threading.Condition() - - def test_chroot_warning(self) -> None: - k = self._get_nonchroot_client() - k.chroot = "abba" - try: - with patch("warnings.warn") as mock_func: - k.start() - assert mock_func.called - finally: - k.stop() - - def test_session_expire(self) -> None: - - cv = self.make_event() - - def watch_events(event: KazooState) -> None: - if event == KazooState.LOST: - cv.set() - - self.client.add_listener(watch_events) - self.expire_session(self.make_event) - cv.wait(3) - assert cv.is_set() - - def test_bad_session_expire(self) -> None: - - cv = self.make_event() - ab = self.make_event() - - def watch_events(event: KazooState) -> None: - if event == KazooState.LOST: - ab.set() - raise Exception("oops") - - self.client.add_listener(watch_events) - self.expire_session(self.make_event) - ab.wait(5.0) - assert ab.is_set() - cv.wait(0.5) - assert not cv.is_set() - - def test_state_listener(self) -> None: - - states = [] - condition = self.make_condition() - - def listener(state: KazooState) -> None: - with condition: - states.append(state) - condition.notify_all() - - self.client.stop() - assert self.client.state == KazooState.LOST - self.client.add_listener(listener) - self.client.start(5) - - with condition: - if not states: - condition.wait(5) - - assert len(states) == 1 - assert states[0] == KazooState.CONNECTED - - def test_invalid_listener(self) -> None: - with pytest.raises(ConfigurationError): - self.client.add_listener(15) # type: ignore[arg-type] - - def test_listener_only_called_on_real_state_change(self) -> None: - - assert self.client.state == KazooState.CONNECTED - called = [False] - condition = self.make_event() - - def listener(state: KazooState) -> None: - called[0] = True - condition.set() - - self.client.add_listener(listener) - self.client._make_state_change(KazooState.CONNECTED) - condition.wait(3) - assert called[0] is False - - def test_no_connection(self) -> None: - client = self.client - client.stop() - assert client.connected is False - assert client.client_id is None - - with pytest.raises(ConnectionClosedError): - client.exists("/") - - def test_close_connecting_connection(self) -> None: - client = self.client - client.stop() - ev = self.make_event() - - def close_on_connecting(state: KazooState) -> None: - if state in (KazooState.CONNECTED, KazooState.LOST): - ev.set() - - client.add_listener(close_on_connecting) - client.start() - - # Wait until we connect - ev.wait(5) - ev.clear() - self.client._call(_CONNECTION_DROP, client.handler.async_result()) - - client.stop() - - # ...and then wait until the connection is lost - ev.wait(5) - - with pytest.raises(ConnectionClosedError): - self.client.create("/foobar") - - def test_double_start(self) -> None: - assert self.client.connected is True - self.client.start() - assert self.client.connected is True - - def test_double_stop(self) -> None: - self.client.stop() - assert self.client.connected is False - self.client.stop() - assert self.client.connected is False - - def test_restart(self) -> None: - assert self.client.connected is True - self.client.restart() - assert self.client.connected is True - - def test_closed(self) -> None: - client = self.client - client.stop() - - write_sock = client._connection._write_sock - - # close the connection to free the socket - client.close() - assert client._connection._write_sock is None - - # sneak in and patch client to simulate race between a thread - # calling stop(); close() and one running a command - oldstate = client._state - client._state = KeeperState.CONNECTED - client._connection._write_sock = write_sock - - try: - # simulate call made after write socket is closed - with pytest.raises(ConnectionClosedError): - client.exists("/") - - # simulate call made after write socket is set to None - client._connection._write_sock = None - - with pytest.raises(ConnectionClosedError): - client.exists("/") - - finally: - # reset for teardown - client._state = oldstate - client._connection._write_sock = None - - def test_watch_trigger_expire(self) -> None: - client = self.client - cv = self.make_event() - - client.create("/test", b"") - - def test_watch(event: WatchedEvent) -> None: - cv.set() - - client.get("/test/", watch=test_watch) - self.expire_session(self.make_event) - - cv.wait(3) - assert cv.is_set() - - -class TestClient(KazooTestCase): - def _makeOne(self, *args: Any) -> SequentialThreadingHandler: - return SequentialThreadingHandler(*args) - - def test_server_version_retries_fail(self) -> None: - - client = self.client - side_effects = [ - "", - "zookeeper.version=", - "zookeeper.version=1.", - "zookeeper.ver", - ] - client.command = MagicMock() # type: ignore[method-assign] - client.command.side_effect = side_effects - with pytest.raises(KazooException): - client.server_version(retries=len(side_effects) - 1) - - def test_server_version_retries_eventually_ok(self) -> None: - client = self.client - actual_version = "zookeeper.version=1.2" - side_effects = [] - for i in range(0, len(actual_version) + 1): - side_effects.append(actual_version[0:i]) - client.command = MagicMock() # type: ignore[method-assign] - client.command.side_effect = side_effects - assert client.server_version(retries=len(side_effects) - 1) == (1, 2) - - def test_client_id(self) -> None: - client_id = self.client.client_id - assert type(client_id) is tuple - # make sure password is of correct length - assert len(client_id[1]) == 16 - - def test_connected(self) -> None: - client = self.client - assert client.connected - - def test_create(self) -> None: - client = self.client - path = client.create("/1") - assert path == "/1" - assert client.exists("/1") - - def test_create_on_broken_connection(self) -> None: - client = self.client - client.start() - - client._state = KeeperState.EXPIRED_SESSION - with pytest.raises(SessionExpiredError): - client.create("/closedpath", b"bar") - - client._state = KeeperState.AUTH_FAILED - with pytest.raises(AuthFailedError): - client.create("/closedpath", b"bar") - - client.stop() - client.close() - - with pytest.raises(ConnectionClosedError): - client.create("/closedpath", b"bar") - - def test_create_null_data(self) -> None: - client = self.client - client.create("/nulldata", None) - value, _ = client.get("/nulldata") - assert value is None - - def test_create_empty_string(self) -> None: - client = self.client - client.create("/empty", b"") - value, _ = client.get("/empty") - assert value == b"" - - def test_create_unicode_path(self) -> None: - client = self.client - path = client.create("/ascii") - assert path == "/ascii" - path = client.create("/\xe4hm") - assert path == "/\xe4hm" - - def test_create_async_returns_unchrooted_path(self) -> None: - client = self.client - path = client.create_async("/1").get() - assert path == "/1" - - def test_create_invalid_path(self) -> None: - client = self.client - with pytest.raises(TypeError): - client.create(("a",)) # type: ignore[call-overload] - with pytest.raises(ValueError): - client.create(".") - with pytest.raises(ValueError): - client.create("/a/../b") - with pytest.raises(BadArgumentsError): - client.create("/b\x00") - with pytest.raises(BadArgumentsError): - client.create("/b\x1e") - - def test_create_invalid_arguments(self) -> None: - single_acl = OPEN_ACL_UNSAFE[0] - client = self.client - with pytest.raises(TypeError): - client.create("a", acl="all") # type: ignore[arg-type] - with pytest.raises(TypeError): - client.create("a", acl=single_acl) # type: ignore[arg-type] - with pytest.raises(TypeError): - client.create("a", value=["a"]) # type: ignore[call-overload] - with pytest.raises(TypeError): - client.create("a", ephemeral="yes") # type: ignore[call-overload] - with pytest.raises(TypeError): - client.create("a", sequence="yes") # type: ignore[call-overload] - with pytest.raises(TypeError): - client.create("a", makepath="yes") # type: ignore[call-overload] - - def test_create_value(self) -> None: - client = self.client - client.create("/1", b"bytes") - data, stat = client.get("/1") - assert data == b"bytes" - - def test_create_unicode_value(self) -> None: - client = self.client - with pytest.raises(TypeError): - client.create("/1", "\xe4hm") # type: ignore[call-overload] - - def test_create_large_value(self) -> None: - client = self.client - kb_512 = b"a" * (512 * 1024) - client.create("/1", kb_512) - assert client.exists("/1") - mb_2 = b"a" * (2 * 1024 * 1024) - with pytest.raises(ConnectionLoss): - client.create("/2", mb_2) - - def test_create_acl_duplicate(self) -> None: - single_acl = OPEN_ACL_UNSAFE[0] - client = self.client - client.create("/1", acl=[single_acl, single_acl]) - acls, stat = client.get_acls("/1") - assert len(acls) == 1 - - def test_create_acl_empty_list(self) -> None: - client = self.client - client.create("/1", acl=[]) - acls, stat = client.get_acls("/1") - assert acls == OPEN_ACL_UNSAFE - - def test_version_no_connection(self) -> None: - self.client.stop() - with pytest.raises(ConnectionLoss): - self.client.server_version() - - def test_create_ephemeral(self) -> None: - client = self.client - client.create("/1", b"ephemeral", ephemeral=True) - assert client.client_id is not None - data, stat = client.get("/1") - assert data == b"ephemeral" - assert stat.ephemeralOwner == client.client_id[0] - - def test_create_no_ephemeral(self) -> None: - client = self.client - client.create("/1", b"val1") - data, stat = client.get("/1") - assert not stat.ephemeralOwner - - def test_create_ephemeral_no_children(self) -> None: - client = self.client - client.create("/1", b"ephemeral", ephemeral=True) - with pytest.raises(NoChildrenForEphemeralsError): - client.create("/1/2", b"val1") - with pytest.raises(NoChildrenForEphemeralsError): - client.create("/1/2", b"val1", ephemeral=True) - - def test_create_sequence(self) -> None: - client = self.client - client.create("/folder") - path = client.create("/folder/a", b"sequence", sequence=True) - assert path == "/folder/a0000000000" - path2 = client.create("/folder/a", b"sequence", sequence=True) - assert path2 == "/folder/a0000000001" - path3 = client.create("/folder/", b"sequence", sequence=True) - assert path3 == "/folder/0000000002" - - def test_create_ephemeral_sequence(self) -> None: - basepath = "/" + uuid.uuid4().hex - realpath = self.client.create( - basepath, b"sandwich", sequence=True, ephemeral=True - ) - assert basepath != realpath and realpath.startswith(basepath) - data, stat = self.client.get(realpath) - assert data == b"sandwich" - - def test_create_makepath(self) -> None: - self.client.create("/1/2", b"val1", makepath=True) - data, stat = self.client.get("/1/2") - assert data == b"val1" - - self.client.create("/1/2/3/4/5", b"val2", makepath=True) - data, stat = self.client.get("/1/2/3/4/5") - assert data == b"val2" - - with pytest.raises(NodeExistsError): - self.client.create("/1/2/3/4/5", b"val2", makepath=True) - - def test_create_makepath_incompatible_acls(self) -> None: - credential = make_digest_acl_credential("username", "password") - alt_client = KazooClient( - self.cluster[0].address + self.client.chroot, - max_retries=5, - auth_data=[("digest", credential)], - handler=self._makeOne(), - ) - alt_client.start() - alt_client.create("/1/2", b"val2", makepath=True, acl=CREATOR_ALL_ACL) - - try: - with pytest.raises(NoAuthError): - self.client.create("/1/2/3/4/5", b"val2", makepath=True) - - finally: - alt_client.delete("/", recursive=True) - alt_client.stop() - - def test_create_no_makepath(self) -> None: - with pytest.raises(NoNodeError): - self.client.create("/1/2", b"val1") - with pytest.raises(NoNodeError): - self.client.create("/1/2", b"val1", makepath=False) - - self.client.create("/1/2", b"val1", makepath=True) - with pytest.raises(NoNodeError): - self.client.create("/1/2/3/4", b"val1", makepath=False) - - def test_create_exists(self) -> None: - client = self.client - path = client.create("/1") - with pytest.raises(NodeExistsError): - client.create(path) - - def test_create_stat(self) -> None: - client = self.client - path, stat1 = client.create("/1", b"bytes", include_data=True) - data, stat2 = client.get("/1") - assert data == b"bytes" - assert stat1 == stat2 - - def test_create_get_set(self) -> None: - nodepath = "/" + uuid.uuid4().hex - - self.client.create(nodepath, b"sandwich", ephemeral=True) - - data, stat = self.client.get(nodepath) - assert data == b"sandwich" - - newstat = self.client.set(nodepath, b"hats", stat.version) - assert newstat - assert newstat.version > stat.version - - # Some other checks of the ZnodeStat object we got - assert newstat.acl_version == stat.acl_version - assert newstat.created == stat.ctime / 1000.0 - assert newstat.last_modified == newstat.mtime / 1000.0 - assert newstat.owner_session_id == stat.ephemeralOwner - assert newstat.creation_transaction_id == stat.czxid - assert newstat.last_modified_transaction_id == newstat.mzxid - assert newstat.data_length == newstat.dataLength - assert newstat.children_count == stat.numChildren - assert newstat.children_version == stat.cversion - - def test_get_invalid_arguments(self) -> None: - client = self.client - with pytest.raises(TypeError): - client.get(("a", "b")) # type: ignore[arg-type] - with pytest.raises(TypeError): - client.get("a", watch=True) # type: ignore[arg-type] - - def test_bad_argument(self) -> None: - client = self.client - client.ensure_path("/1") - with pytest.raises(TypeError): - self.client.set("/1", 1) # type: ignore[arg-type] - - def test_ensure_path(self) -> None: - client = self.client - client.ensure_path("/1/2") - assert client.exists("/1/2") - - client.ensure_path("/1/2/3/4") - assert client.exists("/1/2/3/4") - - def test_sync(self) -> None: - client = self.client - assert client.sync("/") == "/" - # Albeit surprising, you can sync anything, even what does not exist. - assert client.sync("/not_there") == "/not_there" - - def test_exists(self) -> None: - nodepath = "/" + uuid.uuid4().hex - - exists = self.client.exists(nodepath) - assert exists is None - - self.client.create(nodepath, b"sandwich", ephemeral=True) - exists = self.client.exists(nodepath) - assert exists - assert isinstance(exists.version, int) - - multi_node_nonexistent = "/" + uuid.uuid4().hex + "/hats" - exists = self.client.exists(multi_node_nonexistent) - assert exists is None - - def test_exists_invalid_arguments(self) -> None: - client = self.client - with pytest.raises(TypeError): - client.exists(("a", "b")) # type: ignore[arg-type] - with pytest.raises(TypeError): - client.exists("a", watch=True) # type: ignore[arg-type] - - def test_exists_watch(self) -> None: - nodepath = "/" + uuid.uuid4().hex - event = self.client.handler.event_object() - - def w(watch_event: WatchedEvent) -> None: - assert watch_event.path == nodepath - event.set() - - exists = self.client.exists(nodepath, watch=w) - assert exists is None - - self.client.create(nodepath, ephemeral=True) - - event.wait(1) - assert event.is_set() is True - - def test_exists_watcher_exception(self) -> None: - nodepath = "/" + uuid.uuid4().hex - event = self.client.handler.event_object() - - # if the watcher throws an exception, all we can really do is log it - def w(watch_event: WatchedEvent) -> None: - assert watch_event.path == nodepath - event.set() - - raise Exception("test exception in callback") - - exists = self.client.exists(nodepath, watch=w) - assert exists is None - - self.client.create(nodepath, ephemeral=True) - - event.wait(1) - assert event.is_set() is True - - def test_create_delete(self) -> None: - nodepath = "/" + uuid.uuid4().hex - - self.client.create(nodepath, b"zzz") - - self.client.delete(nodepath) - - exists = self.client.exists(nodepath) - assert exists is None - - def test_get_acls(self) -> None: - user = "user" - passw = "pass" - acl = make_digest_acl(user, passw, all=True) - client = self.client - try: - client.create("/a", acl=[acl]) - client.add_auth("digest", "{}:{}".format(user, passw)) - assert acl in client.get_acls("/a")[0] - finally: - client.delete("/a") - - def test_get_acls_invalid_arguments(self) -> None: - client = self.client - with pytest.raises(TypeError): - client.get_acls(("a", "b")) # type: ignore[arg-type] - - def test_set_acls(self) -> None: - user = "user" - passw = "pass" - acl = make_digest_acl(user, passw, all=True) - client = self.client - client.create("/a") - try: - client.set_acls("/a", [acl]) - client.add_auth("digest", "{}:{}".format(user, passw)) - assert acl in client.get_acls("/a")[0] - finally: - client.delete("/a") - - def test_set_acls_empty(self) -> None: - client = self.client - client.create("/a") - with pytest.raises(InvalidACLError): - client.set_acls("/a", []) - - def test_set_acls_no_node(self) -> None: - client = self.client - with pytest.raises(NoNodeError): - client.set_acls("/a", OPEN_ACL_UNSAFE) - - def test_set_acls_invalid_arguments(self) -> None: - single_acl = OPEN_ACL_UNSAFE[0] - client = self.client - with pytest.raises(TypeError): - client.set_acls(("a", "b"), ()) # type: ignore[arg-type] - with pytest.raises(TypeError): - client.set_acls("a", single_acl) # type: ignore[arg-type] - with pytest.raises(TypeError): - client.set_acls("a", "all") # type: ignore[arg-type] - with pytest.raises(TypeError): - client.set_acls("a", [single_acl], "V1") # type: ignore[arg-type] - - def test_set(self) -> None: - client = self.client - client.create("a", b"first") - stat = client.set("a", b"second") - data, stat2 = client.get("a") - assert data == b"second" - assert stat == stat2 - - def test_set_null_data(self) -> None: - client = self.client - client.create("/nulldata", b"not none") - client.set("/nulldata", None) - value, _ = client.get("/nulldata") - assert value is None - - def test_set_empty_string(self) -> None: - client = self.client - client.create("/empty", b"not empty") - client.set("/empty", b"") - value, _ = client.get("/empty") - assert value == b"" - - def test_set_invalid_arguments(self) -> None: - client = self.client - client.create("a", b"first") - with pytest.raises(TypeError): - client.set(("a", "b"), b"value") # type: ignore[arg-type] - with pytest.raises(TypeError): - client.set("a", ["v", "w"]) # type: ignore[arg-type] - with pytest.raises(TypeError): - client.set("a", b"value", "V1") # type: ignore[arg-type] - - def test_delete(self) -> None: - client = self.client - client.ensure_path("/a/b") - assert "b" in client.get_children("a") - client.delete("/a/b") - assert "b" not in client.get_children("a") - - def test_delete_recursive(self) -> None: - client = self.client - client.ensure_path("/a/b/c") - client.ensure_path("/a/b/d") - client.delete("/a/b", recursive=True) - client.delete("/a/b/c", recursive=True) - assert "b" not in client.get_children("a") - - def test_delete_invalid_arguments(self) -> None: - client = self.client - client.ensure_path("/a/b") - with pytest.raises(TypeError): - client.delete("/a/b", recursive="all") # type: ignore[arg-type] - with pytest.raises(TypeError): - client.delete(("a", "b")) # type: ignore[arg-type] - with pytest.raises(TypeError): - client.delete("/a/b", version="V1") # type: ignore[arg-type] - - def test_get_children(self) -> None: - client = self.client - client.ensure_path("/a/b/c") - client.ensure_path("/a/b/d") - assert client.get_children("/a") == ["b"] - assert set(client.get_children("/a/b")) == set(["c", "d"]) - assert client.get_children("/a/b/c") == [] - - def test_get_children2(self) -> None: - client = self.client - client.ensure_path("/a/b") - children, stat = client.get_children("/a", include_data=True) - value, stat2 = client.get("/a") - assert children == ["b"] - assert stat2.version == stat.version - - def test_get_children2_many_nodes(self) -> None: - client = self.client - client.ensure_path("/a/b") - client.ensure_path("/a/c") - client.ensure_path("/a/d") - children, stat = client.get_children("/a", include_data=True) - value, stat2 = client.get("/a") - assert set(children) == set(["b", "c", "d"]) - assert stat2.version == stat.version - - def test_get_children_no_node(self) -> None: - client = self.client - with pytest.raises(NoNodeError): - client.get_children("/none") - with pytest.raises(NoNodeError): - client.get_children("/none", include_data=True) - - def test_get_children_invalid_path(self) -> None: - client = self.client - with pytest.raises(ValueError): - client.get_children("../a") - - def test_get_children_invalid_arguments(self) -> None: - client = self.client - with pytest.raises(TypeError): - client.get_children(("a", "b")) # type: ignore[call-overload] - with pytest.raises(TypeError): - client.get_children("a", watch=True) # type: ignore[call-overload] - with pytest.raises(TypeError): - client.get_children( # type: ignore[call-overload] - "a", include_data="yes" - ) - - def test_invalid_auth(self) -> None: - client = self.client - client.stop() - client._state = KeeperState.AUTH_FAILED - - with pytest.raises(AuthFailedError): - client.get("/") - - def test_client_state(self) -> None: - assert self.client.client_state == KeeperState.CONNECTED - - def test_update_host_list(self) -> None: - hosts = self.cluster[0].address - # create a client with only one server in its list - client = KazooClient(hosts=hosts) - client.start() - - # try to change the chroot, not currently allowed - with pytest.raises(ConfigurationError): - client.set_hosts(hosts + "/new_chroot") - - # grow the cluster to 3 - client.set_hosts(self.servers) - - # shut down the first host - try: - ev_connected = client.handler.event_object() - - def listener(state: KazooState) -> None: - if state == KazooState.CONNECTED: - ev_connected.set() - - client.add_listener(listener) - - self.cluster[0].stop() - ev_connected.wait(60) - assert ev_connected.is_set(), ( - f"Failover timed out after 60s: ev_connected not set. " - f"client.state={client.state}, " - f"client.client_state={client.client_state}, " - f"client.connected={client.connected}, " - f"hosts={client.hosts}" - ) - assert client.client_state == KeeperState.CONNECTED - finally: - self.cluster[0].run() - - # utility for test_request_queuing* - def _make_request_queuing_client( - self, - ) -> tuple[KazooClient, ManagedZooKeeper]: - - server = self.cluster[0] - handler = self._makeOne() - # create a client with only one server in its list, and - # infinite retries - client = KazooClient( - hosts=server.address + self.client.chroot, - handler=handler, - connection_retry=dict( - max_tries=-1, - delay=0.1, - backoff=1, - max_jitter=0.0, - sleep_func=handler.sleep_func, - ), - ) - - return client, server - - # utility for test_request_queuing* - def _request_queuing_common( - self, - client: KazooClient, - server: ManagedZooKeeper, - path: str, - expire_session: bool, - ) -> IAsyncResult: - ev_suspended = client.handler.event_object() - ev_connected = client.handler.event_object() - - def listener(state: KazooState) -> None: - if state == KazooState.SUSPENDED: - ev_suspended.set() - elif state == KazooState.CONNECTED: - ev_connected.set() - - client.add_listener(listener) - - # wait for the client to connect - client.start() - - try: - # force the client to suspend - server.stop() - - ev_suspended.wait(5) - assert ev_suspended.is_set() - ev_connected.clear() - - # submit a request, expecting it to be queued - result = client.create_async(path) - assert len(client._queue) != 0 - assert result.ready() is False - assert client.state == KazooState.SUSPENDED - - # optionally cause a SessionExpiredError to occur by - # mangling the first byte of the session password. - if expire_session: - b0 = b"\x00" - if client._session_passwd[0] == 0: - b0 = b"\xff" - client._session_passwd = b0 + client._session_passwd[1:] - finally: - server.run() - - # wait for the client to reconnect (either with a recovered - # session, or with a new one if expire_session was set) - ev_connected.wait(5) - assert ev_connected.is_set() - - return result - - def test_request_queuing_session_recovered(self) -> None: - path = "/" + uuid.uuid4().hex - client, server = self._make_request_queuing_client() - - try: - result = self._request_queuing_common( - client=client, server=server, path=path, expire_session=False - ) - - assert result.get() == path - assert client.exists(path) is not None - finally: - client.stop() - - def test_request_queuing_session_expired(self) -> None: - path = "/" + uuid.uuid4().hex - client, server = self._make_request_queuing_client() - - try: - result = self._request_queuing_common( - client=client, server=server, path=path, expire_session=True - ) - - assert len(client._queue) == 0 - with pytest.raises(SessionExpiredError): - result.get() - finally: - client.stop() - - -class TestSSLClient(KazooTestCase): - def setUp(self) -> None: - ssl_path = tempfile.mkdtemp() - key_path = os.path.join(ssl_path, "key.pem") - cert_path = os.path.join(ssl_path, "cert.pem") - cacert_path = os.path.join(ssl_path, "cacert.pem") - with open(key_path, "wb") as key_file: - key_file.write( - self.cluster.get_ssl_client_configuration()["client_key"] - ) - with open(cert_path, "wb") as cert_file: - cert_file.write( - self.cluster.get_ssl_client_configuration()["client_cert"] - ) - with open(cacert_path, "wb") as cacert_file: - cacert_file.write( - self.cluster.get_ssl_client_configuration()["ca_cert"] - ) - self.setup_zookeeper( - use_ssl=True, keyfile=key_path, certfile=cert_path, ca=cacert_path - ) - - def test_create(self) -> None: - client = self.client - path = client.create("/1") - assert path == "/1" - assert client.exists("/1") - - -dummy_dict = { - "aversion": 1, - "ctime": 0, - "cversion": 1, - "czxid": 110, - "dataLength": 1, - "ephemeralOwner": "ben", - "mtime": 1, - "mzxid": 1, - "numChildren": 0, - "pzxid": 1, - "version": 1, -} - - -class TestClientTransactions(KazooTestCase): - def setUp(self) -> None: - KazooTestCase.setUp(self) - - def test_basic_create(self) -> None: - t = self.client.transaction() - t.create("/freddy") - t.create("/fred", ephemeral=True) - t.create("/smith", sequence=True) - results = t.commit() - assert len(results) == 3 - assert results[0] == "/freddy" - assert results[2].startswith("/smith0") is True - - def test_bad_creates(self) -> None: - args_list = [ - (True,), - ("/smith", 0), - ("/smith", b"", "bleh"), - ("/smith", b"", None, "fred"), - ("/smith", b"", None, True, "fred"), - ] - - for args in args_list: - with pytest.raises(TypeError): - t = self.client.transaction() - t.create(*args) # type: ignore[arg-type] - - def test_default_acl(self) -> None: - username = uuid.uuid4().hex - password = uuid.uuid4().hex - - digest_auth = "%s:%s" % (username, password) - acl = make_digest_acl(username, password, all=True) - - self.client.add_auth("digest", digest_auth) - self.client.default_acl = (acl,) - - t = self.client.transaction() - t.create("/freddy") - results = t.commit() - assert results[0] == "/freddy" - - def test_basic_delete(self) -> None: - self.client.create("/fred") - t = self.client.transaction() - t.delete("/fred") - results = t.commit() - assert results[0] is True - - def test_bad_deletes(self) -> None: - args_list = [ - (True,), - ("/smith", "woops"), - ] - - for args in args_list: - with pytest.raises(TypeError): - t = self.client.transaction() - t.delete(*args) # type: ignore[arg-type] - - def test_set(self) -> None: - self.client.create("/fred", b"01") - t = self.client.transaction() - t.set_data("/fred", b"oops") - t.commit() - res = self.client.get("/fred") - assert res[0] == b"oops" - - def test_bad_sets(self) -> None: - args_list = [(42, 52), ("/smith", False), ("/smith", b"", "oops")] - - for args in args_list: - with pytest.raises(TypeError): - t = self.client.transaction() - t.set_data(*args) # type: ignore[arg-type] - - def test_check(self) -> None: - self.client.create("/fred") - version = self.client.get("/fred")[1].version - t = self.client.transaction() - t.check("/fred", version) - t.create("/blah") - results = t.commit() - assert results[0] is True - assert results[1] == "/blah" - - def test_bad_checks(self) -> None: - args_list = [(42, 52), ("/smith", "oops")] - - for args in args_list: - with pytest.raises(TypeError): - t = self.client.transaction() - t.check(*args) # type: ignore[arg-type] - - def test_bad_transaction(self) -> None: - t = self.client.transaction() - t.create("/fred") - t.delete("/smith") - results = t.commit() - assert results[0].__class__ == RolledBackError - assert results[1].__class__ == NoNodeError - - def test_bad_commit(self) -> None: - t = self.client.transaction() - t.committed = True - - with pytest.raises(ValueError): - t.commit() - - def test_bad_context(self) -> None: - with pytest.raises(TypeError): - with self.client.transaction() as t: - t.check(4232) # type: ignore[arg-type,call-arg] - - def test_context(self) -> None: - with self.client.transaction() as t: - t.create("/smith", b"32") - assert self.client.get("/smith")[0] == b"32" - - -class TestSessionCallbacks(unittest.TestCase): - def test_session_callback_states(self) -> None: - client = KazooClient() - client._live.set() - - client._session_callback(KeeperState.CONNECTED) - - # Now with stopped - client._stopped.set() - client._session_callback(KeeperState.CONNECTED) - - # Test several state transitions - client._stopped.clear() - client.start_async = ( # type: ignore[method-assign] - lambda: threading.Event() - ) - client._session_callback(KeeperState.CONNECTED) - assert client.state == KazooState.CONNECTED - - client._session_callback(KeeperState.AUTH_FAILED) - # FIXME mypy seems to be under the impression that the state can't - # change as a result of the above call, even though it can. - assert ( - client.state == KazooState.LOST # type: ignore[comparison-overlap] - ) - - client._session_callback(-250) # type: ignore[unreachable] - assert client.state == KazooState.SUSPENDED - - -class TestCallbacks(KazooTestCase): - def test_async_result_callbacks_are_always_called(self) -> None: - # create a callback object - callback_mock = Mock() - - # simulate waiting for a response - async_result = self.client.handler.async_result() - async_result.rawlink(callback_mock) - - # begin the procedure to stop the client - self.client.stop() - - # the response has just been received; - # this should be on another thread, - # simultaneously with the stop procedure - async_result.set_exception( - Exception("Anything that throws an exception") - ) - - # with the fix the callback should be called - assert callback_mock.call_count > 0 - - -class TestNonChrootClient(KazooTestCase): - def test_create(self) -> None: - client = self._get_nonchroot_client() - assert client.chroot == "" - client.start() - node = uuid.uuid4().hex - path = client.create(node, ephemeral=True) - client.delete(path) - client.stop() - - def test_unchroot(self) -> None: - client = self._get_nonchroot_client() - client.chroot = "/a" - # Unchroot'ing the chroot path should return "/" - assert client.unchroot("/a") == "/" - assert client.unchroot("/a/b") == "/b" - assert client.unchroot("/b/c") == "/b/c" - - -class TestReconfig(KazooTestCase): - def setUp(self) -> None: - KazooTestCase.setUp(self) - - def test_no_super_auth(self) -> None: - with pytest.raises(NoAuthError): - self.client.reconfig( - joining="server.999=0.0.0.0:1234:2345:observer;3456", - leaving=None, - new_members=None, - ) - - def test_add_remove_observer(self) -> None: - def free_sock_port() -> tuple[socket.socket, int]: - s = socket.socket() - s.bind(("", 0)) - return s, s.getsockname()[1] - - username = "super" - password = "test" - digest_auth = "%s:%s" % (username, password) - client = self._get_client(auth_data=[("digest", digest_auth)]) - client.start() - - # get ports for election, zab and client endpoints. we need to use - # ports for which we'd immediately get a RST upon connect(); otherwise - # the cluster could crash if it gets a SocketTimeoutException: - # https://issues.apache.org/jira/browse/ZOOKEEPER-2202 - s1, port1 = free_sock_port() - s2, port2 = free_sock_port() - s3, port3 = free_sock_port() - - joining = "server.100=0.0.0.0:%d:%d:observer;0.0.0.0:%d" % ( - port1, - port2, - port3, - ) - data, _ = client.reconfig( - joining=joining, leaving=None, new_members=None - ) - assert joining.encode("utf8") in data - - data, _ = client.reconfig( - joining=None, leaving="100", new_members=None - ) - assert joining.encode("utf8") not in data - - # try to add it again, but a config number in the future - curver = int(data.decode().split("\n")[-1].split("=")[1], base=16) - with pytest.raises(BadVersionError): - self.client.reconfig( - joining=joining, - leaving=None, - new_members=None, - from_config=curver + 1, - ) - - def test_bad_input(self) -> None: - with pytest.raises(BadArgumentsError): - self.client.reconfig( - joining="some thing", leaving=None, new_members=None - ) +from __future__ import annotations + +import os +import socket +import tempfile +import threading +import uuid +import unittest + +from typing import Any, TYPE_CHECKING +from unittest.mock import Mock, MagicMock, patch + +import pytest + + +from kazoo.client import KazooClient +from kazoo.exceptions import ( + AuthFailedError, + BadArgumentsError, + BadVersionError, + ConfigurationError, + ConnectionClosedError, + ConnectionLoss, + InvalidACLError, + NoAuthError, + NoChildrenForEphemeralsError, + NoNodeError, + NodeExistsError, + RolledBackError, + SessionExpiredError, + KazooException, +) +from kazoo.protocol.states import KazooState, KeeperState, WatchedEvent +from kazoo.handlers.threading import ( + SequentialThreadingHandler, + KazooTimeoutError, +) +from kazoo.protocol.connection import _CONNECTION_DROP +from kazoo.retry import KazooRetry +from kazoo.security import ( + make_digest_acl_credential, + CREATOR_ALL_ACL, + make_digest_acl, + OPEN_ACL_UNSAFE, +) + +from kazoo.testing import KazooTestCase + +if TYPE_CHECKING: + from kazoo.testing.common import ManagedZooKeeper + from kazoo.interfaces import IAsyncResult + + +class TestClientTransitions(KazooTestCase): + @staticmethod + def make_event() -> threading.Event: + return threading.Event() + + def test_connection_and_disconnection(self) -> None: + states = [] + rc = threading.Event() + + @self.client.add_listener + def listener(state: KazooState) -> None: + states.append(state) + if state == KazooState.CONNECTED: + rc.set() + + self.client.stop() + assert states == [KazooState.LOST] + states.pop() + + self.client.start() + rc.wait(2) + assert states == [KazooState.CONNECTED] + rc.clear() + states.pop() + self.expire_session(self.make_event) + rc.wait(2) + + req_states = [KazooState.LOST, KazooState.CONNECTED] + assert states == req_states + + +class TestClientConstructor(unittest.TestCase): + _makeOne = KazooClient + + def _old_makeOne(self, *args: Any, **kw: Any) -> KazooClient: + # This is a hack to so that test_invalid_handler doesn't generate a + # mypy error. + return KazooClient(*args, **kw) + + def test_invalid_handler(self) -> None: + with pytest.raises(ConfigurationError): + self._old_makeOne(handler=SequentialThreadingHandler) + + def test_chroot(self) -> None: + assert self._makeOne(hosts="127.0.0.1:2181/").chroot == "" + assert self._makeOne(hosts="127.0.0.1:2181/a").chroot == "/a" + assert self._makeOne(hosts="127.0.0.1/a").chroot == "/a" + assert self._makeOne(hosts="127.0.0.1/a/b").chroot == "/a/b" + assert ( + self._makeOne(hosts="127.0.0.1:2181,127.0.0.1:2182/a/b").chroot + == "/a/b" + ) + + def test_connection_timeout(self) -> None: + client = self._makeOne(hosts="127.0.0.1:9") + assert client.handler.timeout_exception is KazooTimeoutError + + with pytest.raises(KazooTimeoutError): + client.start(0.1) + + def test_ordered_host_selection(self) -> None: + client = self._makeOne( + hosts="127.0.0.1:9,127.0.0.2:9/a", randomize_hosts=False + ) + hosts = [h for h in client.hosts] + assert hosts == [("127.0.0.1", 9), ("127.0.0.2", 9)] + + def test_invalid_hostname(self) -> None: + client = self._makeOne(hosts="nosuchhost/a") + timeout = client.handler.timeout_exception + with pytest.raises(timeout): + client.start(0.1) + + def test_another_invalid_hostname(self) -> None: + with pytest.raises(ValueError): + self._makeOne(hosts="/nosuchhost/a") + + def test_retry_options_dict(self) -> None: + # Deprecated API, but still supported for backwards compatibility + client = self._makeOne( + command_retry=dict(max_tries=99), connection_retry=dict(delay=99) + ) + assert type(client._conn_retry) is KazooRetry + assert type(client._retry) is KazooRetry + assert client._retry.max_tries == 99 + assert client._conn_retry.delay == 99 + + def test_retry_options_one_retry(self) -> None: + # Deprecated API, but still supported for backwards compatibility + client = self._makeOne( + command_retry=KazooRetry(max_tries=98), + connection_retry=dict(delay=97), + ) + assert type(client._conn_retry) is KazooRetry + assert type(client._retry) is KazooRetry + assert client._retry.max_tries == 98 + assert client._conn_retry.delay == 97 + + def test_retry_options_other_retry(self) -> None: + # Deprecated API, but still supported for backwards compatibility + client = self._makeOne( + command_retry=dict(max_tries=88), + connection_retry=KazooRetry(delay=87), + ) + assert type(client._conn_retry) is KazooRetry + assert type(client._retry) is KazooRetry + assert client._retry.max_tries == 88 + assert client._conn_retry.delay == 87 + + def test_retry_options_both_retry(self) -> None: + client = self._makeOne( + command_retry=KazooRetry(max_tries=96), + connection_retry=KazooRetry(delay=95), + ) + assert type(client._conn_retry) is KazooRetry + assert type(client._retry) is KazooRetry + assert client._retry.max_tries == 96 + assert client._conn_retry.delay == 95 + + +class TestAuthentication(KazooTestCase): + def test_auth(self) -> None: + username = uuid.uuid4().hex + password = uuid.uuid4().hex + + digest_auth = "%s:%s" % (username, password) + acl = make_digest_acl(username, password, all=True) + + client = self._get_client() + client.start() + client.add_auth("digest", digest_auth) + client.default_acl = (acl,) + + try: + client.create("/1") + client.create("/1/2") + client.ensure_path("/1/2/3") + + eve = self._get_client() + + eve.start() + + with pytest.raises(NoAuthError): + eve.get("/1/2") + + # try again with the wrong auth token + eve.add_auth("digest", "badbad:bad") + + with pytest.raises(NoAuthError): + eve.get("/1/2") + + finally: + # Ensure we remove the ACL protected nodes + client.delete("/1", recursive=True) + eve.stop() + eve.close() + + def test_connect_auth(self) -> None: + username = uuid.uuid4().hex + password = uuid.uuid4().hex + + digest_auth = "%s:%s" % (username, password) + acl = make_digest_acl(username, password, all=True) + + client = self._get_client(auth_data=[("digest", digest_auth)]) + client.start() + try: + client.create("/1", acl=(acl,)) + # Give ZK a chance to copy data to other ensemble nodes. + # Follower reads are sequentially consistent, but may briefly lag + # the leader until the commit is applied locally; sync flushes + # the channel between client's connected server and the leader. + self.client.sync("/1") + + with pytest.raises(NoAuthError): + self.client.get("/1") + + finally: + client.delete("/1") + client.stop() + client.close() + + def test_unicode_auth(self) -> None: + username = r"xe4/\hm" + password = r"/\xe4hm" + digest_auth = "%s:%s" % (username, password) + acl = make_digest_acl(username, password, all=True) + + client = self._get_client() + client.start() + client.add_auth("digest", digest_auth) + client.default_acl = (acl,) + + try: + client.create("/1") + client.ensure_path("/1/2/3") + + eve = self._get_client() + eve.start() + + with pytest.raises(NoAuthError): + eve.get("/1/2") + + # try again with the wrong auth token + eve.add_auth("digest", "badbad:bad") + + with pytest.raises(NoAuthError): + eve.get("/1/2") + + finally: + # Ensure we remove the ACL protected nodes + client.delete("/1", recursive=True) + eve.stop() + eve.close() + + def test_invalid_auth(self) -> None: + # Fixes deprecated warning for add_auth() with a tuple instead of a + # string + client = self._get_client() + client.start() + + with pytest.raises(TypeError): + client.add_auth( + "digest", + ("user", "pass"), # type: ignore[arg-type] + ) + + with pytest.raises(TypeError): + client.add_auth(None, ("user", "pass")) # type: ignore[arg-type] + + def test_async_auth(self) -> None: + client = self._get_client() + client.start() + username = uuid.uuid4().hex + password = uuid.uuid4().hex + digest_auth = "%s:%s" % (username, password) + result = client.add_auth_async("digest", digest_auth) + assert result.get() is True + + def test_async_auth_failure(self) -> None: + client = self._get_client() + client.start() + username = uuid.uuid4().hex + password = uuid.uuid4().hex + digest_auth = "%s:%s" % (username, password) + + with pytest.raises(AuthFailedError): + client.add_auth("unknown-scheme", digest_auth) + + def test_add_auth_on_reconnect(self) -> None: + client = self._get_client() + client.start() + client.add_auth("digest", "jsmith:jsmith") + + ev_lost = client.handler.event_object() + ev_connected = client.handler.event_object() + + def listener(state: KazooState) -> None: + if state in (KazooState.SUSPENDED, KazooState.LOST): + ev_lost.set() + elif state == KazooState.CONNECTED and ev_lost.is_set(): + ev_connected.set() + + client.add_listener(listener) + assert client._connection._socket is not None + client._connection._socket.shutdown(socket.SHUT_RDWR) + + ev_connected.wait(15) + assert ev_connected.is_set() + assert ("digest", "jsmith:jsmith") in client.auth_data + + +class TestConnection(KazooTestCase): + @staticmethod + def make_event() -> threading.Event: + return threading.Event() + + @staticmethod + def make_condition() -> threading.Condition: + return threading.Condition() + + def test_chroot_warning(self) -> None: + k = self._get_nonchroot_client() + k.chroot = "abba" + try: + with patch("warnings.warn") as mock_func: + k.start() + assert mock_func.called + finally: + k.stop() + + def test_session_expire(self) -> None: + + cv = self.make_event() + + def watch_events(event: KazooState) -> None: + if event == KazooState.LOST: + cv.set() + + self.client.add_listener(watch_events) + self.expire_session(self.make_event) + cv.wait(3) + assert cv.is_set() + + def test_bad_session_expire(self) -> None: + + cv = self.make_event() + ab = self.make_event() + + def watch_events(event: KazooState) -> None: + if event == KazooState.LOST: + ab.set() + raise Exception("oops") + + self.client.add_listener(watch_events) + self.expire_session(self.make_event) + ab.wait(5.0) + assert ab.is_set() + cv.wait(0.5) + assert not cv.is_set() + + def test_state_listener(self) -> None: + states = [] + condition = self.make_condition() + + def listener(state: KazooState) -> None: + with condition: + states.append(state) + condition.notify_all() + + self.client.stop() + assert self.client.state == KazooState.LOST + self.client.add_listener(listener) + self.client.start(5) + + with condition: + if not states: + condition.wait(5) + + assert len(states) == 1 + assert states[0] == KazooState.CONNECTED + + def test_invalid_listener(self) -> None: + with pytest.raises(ConfigurationError): + self.client.add_listener(15) # type: ignore[arg-type] + + def test_listener_only_called_on_real_state_change(self) -> None: + assert self.client.state == KazooState.CONNECTED + called = [False] + condition = self.make_event() + + def listener(state: KazooState) -> None: + called[0] = True + condition.set() + + self.client.add_listener(listener) + self.client._make_state_change(KazooState.CONNECTED) + condition.wait(3) + assert called[0] is False + + def test_no_connection(self) -> None: + client = self.client + client.stop() + assert client.connected is False + assert client.client_id is None + + with pytest.raises(ConnectionClosedError): + client.exists("/") + + def test_close_connecting_connection(self) -> None: + client = self.client + client.stop() + ev = self.make_event() + + def close_on_connecting(state: KazooState) -> None: + if state in (KazooState.CONNECTED, KazooState.LOST): + ev.set() + + client.add_listener(close_on_connecting) + client.start() + + # Wait until we connect + ev.wait(5) + ev.clear() + self.client._call(_CONNECTION_DROP, client.handler.async_result()) + + client.stop() + + # ...and then wait until the connection is lost + ev.wait(5) + + with pytest.raises(ConnectionClosedError): + self.client.create("/foobar") + + def test_double_start(self) -> None: + assert self.client.connected is True + self.client.start() + assert self.client.connected is True + + def test_double_stop(self) -> None: + self.client.stop() + assert self.client.connected is False + self.client.stop() + assert self.client.connected is False + + def test_restart(self) -> None: + assert self.client.connected is True + self.client.restart() + assert self.client.connected is True + + def test_closed(self) -> None: + client = self.client + client.stop() + + write_sock = client._connection._write_sock + + # close the connection to free the socket + client.close() + assert client._connection._write_sock is None + + # sneak in and patch client to simulate race between a thread + # calling stop(); close() and one running a command + oldstate = client._state + client._state = KeeperState.CONNECTED + client._connection._write_sock = write_sock + + try: + # simulate call made after write socket is closed + with pytest.raises(ConnectionClosedError): + client.exists("/") + + # simulate call made after write socket is set to None + client._connection._write_sock = None + + with pytest.raises(ConnectionClosedError): + client.exists("/") + + finally: + # reset for teardown + client._state = oldstate + client._connection._write_sock = None + + def test_watch_trigger_expire(self) -> None: + client = self.client + cv = self.make_event() + + client.create("/test", b"") + + def test_watch(event: WatchedEvent) -> None: + cv.set() + + client.get("/test/", watch=test_watch) + self.expire_session(self.make_event) + + cv.wait(3) + assert cv.is_set() + + +class TestClient(KazooTestCase): + _makeOne = SequentialThreadingHandler + + def test_server_version_retries_fail(self) -> None: + + client = self.client + side_effects = [ + "", + "zookeeper.version=", + "zookeeper.version=1.", + "zookeeper.ver", + ] + client.command = MagicMock() # type: ignore[method-assign] + client.command.side_effect = side_effects + with pytest.raises(KazooException): + client.server_version(retries=len(side_effects) - 1) + + def test_server_version_retries_eventually_ok(self) -> None: + client = self.client + actual_version = "zookeeper.version=1.2" + side_effects = [] + for i in range(0, len(actual_version) + 1): + side_effects.append(actual_version[0:i]) + client.command = MagicMock() # type: ignore[method-assign] + client.command.side_effect = side_effects + assert client.server_version(retries=len(side_effects) - 1) == (1, 2) + + def test_client_id(self) -> None: + client_id = self.client.client_id + assert type(client_id) is tuple + # make sure password is of correct length + assert len(client_id[1]) == 16 + + def test_connected(self) -> None: + client = self.client + assert client.connected + + def test_create(self) -> None: + client = self.client + path = client.create("/1") + assert path == "/1" + assert client.exists("/1") + + def test_create_on_broken_connection(self) -> None: + client = self.client + client.start() + + client._state = KeeperState.EXPIRED_SESSION + with pytest.raises(SessionExpiredError): + client.create("/closedpath", b"bar") + + client._state = KeeperState.AUTH_FAILED + with pytest.raises(AuthFailedError): + client.create("/closedpath", b"bar") + + client.stop() + client.close() + + with pytest.raises(ConnectionClosedError): + client.create("/closedpath", b"bar") + + def test_create_null_data(self) -> None: + client = self.client + client.create("/nulldata", None) + value, _ = client.get("/nulldata") + assert value is None + + def test_create_empty_string(self) -> None: + client = self.client + client.create("/empty", b"") + value, _ = client.get("/empty") + assert value == b"" + + def test_create_unicode_path(self) -> None: + client = self.client + path = client.create("/ascii") + assert path == "/ascii" + path = client.create("/\xe4hm") + assert path == "/\xe4hm" + + def test_create_async_returns_unchrooted_path(self) -> None: + client = self.client + path = client.create_async("/1").get() + assert path == "/1" + + def test_create_invalid_path(self) -> None: + client = self.client + with pytest.raises(TypeError): + client.create(("a",)) # type: ignore[call-overload] + with pytest.raises(ValueError): + client.create(".") + with pytest.raises(ValueError): + client.create("/a/../b") + with pytest.raises(BadArgumentsError): + client.create("/b\x00") + with pytest.raises(BadArgumentsError): + client.create("/b\x1e") + + def test_create_invalid_arguments(self) -> None: + single_acl = OPEN_ACL_UNSAFE[0] + client = self.client + with pytest.raises(TypeError): + client.create("a", acl="all") # type: ignore[arg-type] + with pytest.raises(TypeError): + client.create("a", acl=single_acl) # type: ignore[arg-type] + with pytest.raises(TypeError): + client.create("a", value=["a"]) # type: ignore[call-overload] + with pytest.raises(TypeError): + client.create("a", ephemeral="yes") # type: ignore[call-overload] + with pytest.raises(TypeError): + client.create("a", sequence="yes") # type: ignore[call-overload] + with pytest.raises(TypeError): + client.create("a", makepath="yes") # type: ignore[call-overload] + + def test_create_value(self) -> None: + client = self.client + client.create("/1", b"bytes") + data, stat = client.get("/1") + assert data == b"bytes" + + def test_create_unicode_value(self) -> None: + client = self.client + with pytest.raises(TypeError): + client.create("/1", "\xe4hm") # type: ignore[call-overload] + + def test_create_large_value(self) -> None: + client = self.client + kb_512 = b"a" * (512 * 1024) + client.create("/1", kb_512) + assert client.exists("/1") + mb_2 = b"a" * (2 * 1024 * 1024) + with pytest.raises(ConnectionLoss): + client.create("/2", mb_2) + + def test_create_acl_duplicate(self) -> None: + single_acl = OPEN_ACL_UNSAFE[0] + client = self.client + client.create("/1", acl=[single_acl, single_acl]) + acls, stat = client.get_acls("/1") + assert len(acls) == 1 + + def test_create_acl_empty_list(self) -> None: + client = self.client + client.create("/1", acl=[]) + acls, stat = client.get_acls("/1") + assert acls == OPEN_ACL_UNSAFE + + def test_version_no_connection(self) -> None: + self.client.stop() + with pytest.raises(ConnectionLoss): + self.client.server_version() + + def test_create_ephemeral(self) -> None: + client = self.client + client.create("/1", b"ephemeral", ephemeral=True) + assert client.client_id is not None + data, stat = client.get("/1") + assert data == b"ephemeral" + assert stat.ephemeralOwner == client.client_id[0] + + def test_create_no_ephemeral(self) -> None: + client = self.client + client.create("/1", b"val1") + data, stat = client.get("/1") + assert not stat.ephemeralOwner + + def test_create_ephemeral_no_children(self) -> None: + client = self.client + client.create("/1", b"ephemeral", ephemeral=True) + with pytest.raises(NoChildrenForEphemeralsError): + client.create("/1/2", b"val1") + with pytest.raises(NoChildrenForEphemeralsError): + client.create("/1/2", b"val1", ephemeral=True) + + def test_create_sequence(self) -> None: + client = self.client + client.create("/folder") + path = client.create("/folder/a", b"sequence", sequence=True) + assert path == "/folder/a0000000000" + path2 = client.create("/folder/a", b"sequence", sequence=True) + assert path2 == "/folder/a0000000001" + path3 = client.create("/folder/", b"sequence", sequence=True) + assert path3 == "/folder/0000000002" + + def test_create_ephemeral_sequence(self) -> None: + basepath = "/" + uuid.uuid4().hex + realpath = self.client.create( + basepath, b"sandwich", sequence=True, ephemeral=True + ) + assert basepath != realpath and realpath.startswith(basepath) + data, stat = self.client.get(realpath) + assert data == b"sandwich" + + def test_create_makepath(self) -> None: + self.client.create("/1/2", b"val1", makepath=True) + data, stat = self.client.get("/1/2") + assert data == b"val1" + + self.client.create("/1/2/3/4/5", b"val2", makepath=True) + data, stat = self.client.get("/1/2/3/4/5") + assert data == b"val2" + + with pytest.raises(NodeExistsError): + self.client.create("/1/2/3/4/5", b"val2", makepath=True) + + def test_create_makepath_incompatible_acls(self) -> None: + credential = make_digest_acl_credential("username", "password") + alt_client = KazooClient( + self.cluster[0].address + self.client.chroot, + max_retries=5, + auth_data=[("digest", credential)], + handler=self._makeOne(), + ) + alt_client.start() + alt_client.create("/1/2", b"val2", makepath=True, acl=CREATOR_ALL_ACL) + + try: + with pytest.raises(NoAuthError): + self.client.create("/1/2/3/4/5", b"val2", makepath=True) + + finally: + alt_client.delete("/", recursive=True) + alt_client.stop() + + def test_create_no_makepath(self) -> None: + with pytest.raises(NoNodeError): + self.client.create("/1/2", b"val1") + with pytest.raises(NoNodeError): + self.client.create("/1/2", b"val1", makepath=False) + + self.client.create("/1/2", b"val1", makepath=True) + with pytest.raises(NoNodeError): + self.client.create("/1/2/3/4", b"val1", makepath=False) + + def test_create_exists(self) -> None: + client = self.client + path = client.create("/1") + with pytest.raises(NodeExistsError): + client.create(path) + + def test_create_stat(self) -> None: + client = self.client + path, stat1 = client.create("/1", b"bytes", include_data=True) + data, stat2 = client.get("/1") + assert data == b"bytes" + assert stat1 == stat2 + + def test_create_get_set(self) -> None: + nodepath = "/" + uuid.uuid4().hex + + self.client.create(nodepath, b"sandwich", ephemeral=True) + + data, stat = self.client.get(nodepath) + assert data == b"sandwich" + + newstat = self.client.set(nodepath, b"hats", stat.version) + assert newstat + assert newstat.version > stat.version + + # Some other checks of the ZnodeStat object we got + assert newstat.acl_version == stat.acl_version + assert newstat.created == stat.ctime / 1000.0 + assert newstat.last_modified == newstat.mtime / 1000.0 + assert newstat.owner_session_id == stat.ephemeralOwner + assert newstat.creation_transaction_id == stat.czxid + assert newstat.last_modified_transaction_id == newstat.mzxid + assert newstat.data_length == newstat.dataLength + assert newstat.children_count == stat.numChildren + assert newstat.children_version == stat.cversion + + def test_get_invalid_arguments(self) -> None: + client = self.client + with pytest.raises(TypeError): + client.get(("a", "b")) # type: ignore[arg-type] + with pytest.raises(TypeError): + client.get("a", watch=True) # type: ignore[arg-type] + + def test_bad_argument(self) -> None: + client = self.client + client.ensure_path("/1") + with pytest.raises(TypeError): + self.client.set("/1", 1) # type: ignore[arg-type] + + def test_ensure_path(self) -> None: + client = self.client + client.ensure_path("/1/2") + assert client.exists("/1/2") + + client.ensure_path("/1/2/3/4") + assert client.exists("/1/2/3/4") + + def test_sync(self) -> None: + client = self.client + assert client.sync("/") == "/" + # Albeit surprising, you can sync anything, even what does not exist. + assert client.sync("/not_there") == "/not_there" + + def test_exists(self) -> None: + nodepath = "/" + uuid.uuid4().hex + + exists = self.client.exists(nodepath) + assert exists is None + + self.client.create(nodepath, b"sandwich", ephemeral=True) + exists = self.client.exists(nodepath) + assert exists + assert isinstance(exists.version, int) + + multi_node_nonexistent = "/" + uuid.uuid4().hex + "/hats" + exists = self.client.exists(multi_node_nonexistent) + assert exists is None + + def test_exists_invalid_arguments(self) -> None: + client = self.client + with pytest.raises(TypeError): + client.exists(("a", "b")) # type: ignore[arg-type] + with pytest.raises(TypeError): + client.exists("a", watch=True) # type: ignore[arg-type] + + def test_exists_watch(self) -> None: + nodepath = "/" + uuid.uuid4().hex + event = self.client.handler.event_object() + + def w(watch_event: WatchedEvent) -> None: + assert watch_event.path == nodepath + event.set() + + exists = self.client.exists(nodepath, watch=w) + assert exists is None + + self.client.create(nodepath, ephemeral=True) + + event.wait(1) + assert event.is_set() is True + + def test_exists_watcher_exception(self) -> None: + nodepath = "/" + uuid.uuid4().hex + event = self.client.handler.event_object() + + # if the watcher throws an exception, all we can really do is log it + def w(watch_event: WatchedEvent) -> None: + assert watch_event.path == nodepath + event.set() + + raise Exception("test exception in callback") + + exists = self.client.exists(nodepath, watch=w) + assert exists is None + + self.client.create(nodepath, ephemeral=True) + + event.wait(1) + assert event.is_set() is True + + def test_create_delete(self) -> None: + nodepath = "/" + uuid.uuid4().hex + + self.client.create(nodepath, b"zzz") + + self.client.delete(nodepath) + + exists = self.client.exists(nodepath) + assert exists is None + + def test_get_acls(self) -> None: + user = "user" + passw = "pass" + acl = make_digest_acl(user, passw, all=True) + client = self.client + try: + client.create("/a", acl=[acl]) + client.add_auth("digest", "{}:{}".format(user, passw)) + assert acl in client.get_acls("/a")[0] + finally: + client.delete("/a") + + def test_get_acls_invalid_arguments(self) -> None: + client = self.client + with pytest.raises(TypeError): + client.get_acls(("a", "b")) # type: ignore[arg-type] + + def test_set_acls(self) -> None: + user = "user" + passw = "pass" + acl = make_digest_acl(user, passw, all=True) + client = self.client + client.create("/a") + try: + client.set_acls("/a", [acl]) + client.add_auth("digest", "{}:{}".format(user, passw)) + assert acl in client.get_acls("/a")[0] + finally: + client.delete("/a") + + def test_set_acls_empty(self) -> None: + client = self.client + client.create("/a") + with pytest.raises(InvalidACLError): + client.set_acls("/a", []) + + def test_set_acls_no_node(self) -> None: + client = self.client + with pytest.raises(NoNodeError): + client.set_acls("/a", OPEN_ACL_UNSAFE) + + def test_set_acls_invalid_arguments(self) -> None: + single_acl = OPEN_ACL_UNSAFE[0] + client = self.client + with pytest.raises(TypeError): + client.set_acls(("a", "b"), ()) # type: ignore[arg-type] + with pytest.raises(TypeError): + client.set_acls("a", single_acl) # type: ignore[arg-type] + with pytest.raises(TypeError): + client.set_acls("a", "all") # type: ignore[arg-type] + with pytest.raises(TypeError): + client.set_acls("a", [single_acl], "V1") # type: ignore[arg-type] + + def test_set(self) -> None: + client = self.client + client.create("a", b"first") + stat = client.set("a", b"second") + data, stat2 = client.get("a") + assert data == b"second" + assert stat == stat2 + + def test_set_null_data(self) -> None: + client = self.client + client.create("/nulldata", b"not none") + client.set("/nulldata", None) + value, _ = client.get("/nulldata") + assert value is None + + def test_set_empty_string(self) -> None: + client = self.client + client.create("/empty", b"not empty") + client.set("/empty", b"") + value, _ = client.get("/empty") + assert value == b"" + + def test_set_invalid_arguments(self) -> None: + client = self.client + client.create("a", b"first") + with pytest.raises(TypeError): + client.set(("a", "b"), b"value") # type: ignore[arg-type] + with pytest.raises(TypeError): + client.set("a", ["v", "w"]) # type: ignore[arg-type] + with pytest.raises(TypeError): + client.set("a", b"value", "V1") # type: ignore[arg-type] + + def test_delete(self) -> None: + client = self.client + client.ensure_path("/a/b") + assert "b" in client.get_children("a") + client.delete("/a/b") + assert "b" not in client.get_children("a") + + def test_delete_recursive(self) -> None: + client = self.client + client.ensure_path("/a/b/c") + client.ensure_path("/a/b/d") + client.delete("/a/b", recursive=True) + client.delete("/a/b/c", recursive=True) + assert "b" not in client.get_children("a") + + def test_delete_invalid_arguments(self) -> None: + client = self.client + client.ensure_path("/a/b") + with pytest.raises(TypeError): + client.delete("/a/b", recursive="all") # type: ignore[arg-type] + with pytest.raises(TypeError): + client.delete(("a", "b")) # type: ignore[arg-type] + with pytest.raises(TypeError): + client.delete("/a/b", version="V1") # type: ignore[arg-type] + + def test_get_children(self) -> None: + client = self.client + client.ensure_path("/a/b/c") + client.ensure_path("/a/b/d") + assert client.get_children("/a") == ["b"] + assert set(client.get_children("/a/b")) == set(["c", "d"]) + assert client.get_children("/a/b/c") == [] + + def test_get_children2(self) -> None: + client = self.client + client.ensure_path("/a/b") + children, stat = client.get_children("/a", include_data=True) + value, stat2 = client.get("/a") + assert children == ["b"] + assert stat2.version == stat.version + + def test_get_children2_many_nodes(self) -> None: + client = self.client + client.ensure_path("/a/b") + client.ensure_path("/a/c") + client.ensure_path("/a/d") + children, stat = client.get_children("/a", include_data=True) + value, stat2 = client.get("/a") + assert set(children) == set(["b", "c", "d"]) + assert stat2.version == stat.version + + def test_get_children_no_node(self) -> None: + client = self.client + with pytest.raises(NoNodeError): + client.get_children("/none") + with pytest.raises(NoNodeError): + client.get_children("/none", include_data=True) + + def test_get_children_invalid_path(self) -> None: + client = self.client + with pytest.raises(ValueError): + client.get_children("../a") + + def test_get_children_invalid_arguments(self) -> None: + client = self.client + with pytest.raises(TypeError): + client.get_children(("a", "b")) # type: ignore[call-overload] + with pytest.raises(TypeError): + client.get_children("a", watch=True) # type: ignore[call-overload] + with pytest.raises(TypeError): + client.get_children( # type: ignore[call-overload] + "a", include_data="yes" + ) + + def test_invalid_auth(self) -> None: + client = self.client + client.stop() + client._state = KeeperState.AUTH_FAILED + + with pytest.raises(AuthFailedError): + client.get("/") + + def test_client_state(self) -> None: + assert self.client.client_state == KeeperState.CONNECTED + + def test_update_host_list(self) -> None: + hosts = self.cluster[0].address + # create a client with only one server in its list + client = KazooClient(hosts=hosts) + client.start() + + # try to change the chroot, not currently allowed + with pytest.raises(ConfigurationError): + client.set_hosts(hosts + "/new_chroot") + + # grow the cluster to 3 + client.set_hosts(self.servers) + + # shut down the first host + try: + ev_connected = client.handler.event_object() + + def listener(state: KazooState) -> None: + if state == KazooState.CONNECTED: + ev_connected.set() + + client.add_listener(listener) + + self.cluster[0].stop() + ev_connected.wait(60) + assert ev_connected.is_set(), ( + f"Failover timed out after 60s: ev_connected not set. " + f"client.state={client.state}, " + f"client.client_state={client.client_state}, " + f"client.connected={client.connected}, " + f"hosts={client.hosts}" + ) + assert client.client_state == KeeperState.CONNECTED + finally: + self.cluster[0].run() + + # utility for test_request_queuing* + def _make_request_queuing_client( + self, + ) -> tuple[KazooClient, ManagedZooKeeper]: + + server = self.cluster[0] + handler = self._makeOne() + # create a client with only one server in its list, and + # infinite retries + client = KazooClient( + hosts=server.address + self.client.chroot, + handler=handler, + connection_retry=dict( + max_tries=-1, + delay=0.1, + backoff=1, + max_jitter=0.0, + sleep_func=handler.sleep_func, + ), + ) + + return client, server + + # utility for test_request_queuing* + def _request_queuing_common( + self, + client: KazooClient, + server: ManagedZooKeeper, + path: str, + expire_session: bool, + ) -> IAsyncResult: + ev_suspended = client.handler.event_object() + ev_connected = client.handler.event_object() + + def listener(state: KazooState) -> None: + if state == KazooState.SUSPENDED: + ev_suspended.set() + elif state == KazooState.CONNECTED: + ev_connected.set() + + client.add_listener(listener) + + # wait for the client to connect + client.start() + + try: + # force the client to suspend + server.stop() + + ev_suspended.wait(5) + assert ev_suspended.is_set() + ev_connected.clear() + + # submit a request, expecting it to be queued + result = client.create_async(path) + assert len(client._queue) != 0 + assert result.ready() is False + assert client.state == KazooState.SUSPENDED + + # optionally cause a SessionExpiredError to occur by + # mangling the first byte of the session password. + if expire_session: + b0 = b"\x00" + if client._session_passwd[0] == 0: + b0 = b"\xff" + client._session_passwd = b0 + client._session_passwd[1:] + finally: + server.run() + + # wait for the client to reconnect (either with a recovered + # session, or with a new one if expire_session was set) + ev_connected.wait(5) + assert ev_connected.is_set() + + return result + + def test_request_queuing_session_recovered(self) -> None: + path = "/" + uuid.uuid4().hex + client, server = self._make_request_queuing_client() + + try: + result = self._request_queuing_common( + client=client, server=server, path=path, expire_session=False + ) + + assert result.get() == path + assert client.exists(path) is not None + finally: + client.stop() + + def test_request_queuing_session_expired(self) -> None: + path = "/" + uuid.uuid4().hex + client, server = self._make_request_queuing_client() + + try: + result = self._request_queuing_common( + client=client, server=server, path=path, expire_session=True + ) + + assert len(client._queue) == 0 + with pytest.raises(SessionExpiredError): + result.get() + finally: + client.stop() + + +class TestSSLClient(KazooTestCase): + def setUp(self) -> None: + ssl_path = tempfile.mkdtemp() + key_path = os.path.join(ssl_path, "key.pem") + cert_path = os.path.join(ssl_path, "cert.pem") + cacert_path = os.path.join(ssl_path, "cacert.pem") + with open(key_path, "wb") as key_file: + key_file.write( + self.cluster.get_ssl_client_configuration()["client_key"] + ) + with open(cert_path, "wb") as cert_file: + cert_file.write( + self.cluster.get_ssl_client_configuration()["client_cert"] + ) + with open(cacert_path, "wb") as cacert_file: + cacert_file.write( + self.cluster.get_ssl_client_configuration()["ca_cert"] + ) + self.setup_zookeeper( + use_ssl=True, keyfile=key_path, certfile=cert_path, ca=cacert_path + ) + + def test_create(self) -> None: + client = self.client + path = client.create("/1") + assert path == "/1" + assert client.exists("/1") + + +dummy_dict = { + "aversion": 1, + "ctime": 0, + "cversion": 1, + "czxid": 110, + "dataLength": 1, + "ephemeralOwner": "ben", + "mtime": 1, + "mzxid": 1, + "numChildren": 0, + "pzxid": 1, + "version": 1, +} + + +class TestClientTransactions(KazooTestCase): + def setUp(self) -> None: + KazooTestCase.setUp(self) + + def test_basic_create(self) -> None: + t = self.client.transaction() + t.create("/freddy") + t.create("/fred", ephemeral=True) + t.create("/smith", sequence=True) + results = t.commit() + assert len(results) == 3 + assert results[0] == "/freddy" + assert results[2].startswith("/smith0") is True + + def test_bad_creates(self) -> None: + args_list = [ + (True,), + ("/smith", 0), + ("/smith", b"", "bleh"), + ("/smith", b"", None, "fred"), + ("/smith", b"", None, True, "fred"), + ] + + for args in args_list: + with pytest.raises(TypeError): + t = self.client.transaction() + t.create(*args) # type: ignore[arg-type] + + def test_default_acl(self) -> None: + username = uuid.uuid4().hex + password = uuid.uuid4().hex + + digest_auth = "%s:%s" % (username, password) + acl = make_digest_acl(username, password, all=True) + + self.client.add_auth("digest", digest_auth) + self.client.default_acl = (acl,) + + t = self.client.transaction() + t.create("/freddy") + results = t.commit() + assert results[0] == "/freddy" + + def test_basic_delete(self) -> None: + self.client.create("/fred") + t = self.client.transaction() + t.delete("/fred") + results = t.commit() + assert results[0] is True + + def test_bad_deletes(self) -> None: + args_list = [(True,), ("/smith", "woops")] + + for args in args_list: + with pytest.raises(TypeError): + t = self.client.transaction() + t.delete(*args) # type: ignore[arg-type] + + def test_set(self) -> None: + self.client.create("/fred", b"01") + t = self.client.transaction() + t.set_data("/fred", b"oops") + t.commit() + res = self.client.get("/fred") + assert res[0] == b"oops" + + def test_bad_sets(self) -> None: + args_list = [(42, 52), ("/smith", False), ("/smith", b"", "oops")] + + for args in args_list: + with pytest.raises(TypeError): + t = self.client.transaction() + t.set_data(*args) # type: ignore[arg-type] + + def test_check(self) -> None: + self.client.create("/fred") + version = self.client.get("/fred")[1].version + t = self.client.transaction() + t.check("/fred", version) + t.create("/blah") + results = t.commit() + assert results[0] is True + assert results[1] == "/blah" + + def test_bad_checks(self) -> None: + args_list = [(42, 52), ("/smith", "oops")] + + for args in args_list: + with pytest.raises(TypeError): + t = self.client.transaction() + t.check(*args) # type: ignore[arg-type] + + def test_bad_transaction(self) -> None: + t = self.client.transaction() + t.create("/fred") + t.delete("/smith") + results = t.commit() + assert results[0].__class__ == RolledBackError + assert results[1].__class__ == NoNodeError + + def test_bad_commit(self) -> None: + t = self.client.transaction() + t.committed = True + + with pytest.raises(ValueError): + t.commit() + + def test_bad_context(self) -> None: + with pytest.raises(TypeError): + with self.client.transaction() as t: + t.check(4232) # type: ignore[arg-type,call-arg] + + def test_context(self) -> None: + with self.client.transaction() as t: + t.create("/smith", b"32") + assert self.client.get("/smith")[0] == b"32" + + +class TestSessionCallbacks(unittest.TestCase): + def test_session_callback_states(self) -> None: + client = KazooClient() + client._live.set() + + client._session_callback(KeeperState.CONNECTED) + + # Now with stopped + client._stopped.set() + client._session_callback(KeeperState.CONNECTED) + + # Test several state transitions + client._stopped.clear() + client.start_async = ( # type: ignore[method-assign] + lambda: threading.Event() + ) + client._session_callback(KeeperState.CONNECTED) + assert client.state == KazooState.CONNECTED + + client._session_callback(KeeperState.AUTH_FAILED) + # FIXME mypy seems to be under the impression that the state can't + # change as a result of the above call, even though it can. + assert ( + client.state == KazooState.LOST # type: ignore[comparison-overlap] + ) + + client._session_callback(-250) # type: ignore[unreachable] + assert client.state == KazooState.SUSPENDED + + +class TestCallbacks(KazooTestCase): + def test_async_result_callbacks_are_always_called(self) -> None: + # create a callback object + callback_mock = Mock() + + # simulate waiting for a response + async_result = self.client.handler.async_result() + async_result.rawlink(callback_mock) + + # begin the procedure to stop the client + self.client.stop() + + # the response has just been received; + # this should be on another thread, + # simultaneously with the stop procedure + async_result.set_exception( + Exception("Anything that throws an exception") + ) + + # with the fix the callback should be called + assert callback_mock.call_count > 0 + + +class TestNonChrootClient(KazooTestCase): + def test_create(self) -> None: + client = self._get_nonchroot_client() + assert client.chroot == "" + client.start() + node = uuid.uuid4().hex + path = client.create(node, ephemeral=True) + client.delete(path) + client.stop() + + def test_unchroot(self) -> None: + client = self._get_nonchroot_client() + client.chroot = "/a" + # Unchroot'ing the chroot path should return "/" + assert client.unchroot("/a") == "/" + assert client.unchroot("/a/b") == "/b" + assert client.unchroot("/b/c") == "/b/c" + + +class TestReconfig(KazooTestCase): + def setUp(self) -> None: + KazooTestCase.setUp(self) + + def test_no_super_auth(self) -> None: + with pytest.raises(NoAuthError): + self.client.reconfig( + joining="server.999=0.0.0.0:1234:2345:observer;3456", + leaving=None, + new_members=None, + ) + + def test_add_remove_observer(self) -> None: + def free_sock_port() -> tuple[socket.socket, int]: + s = socket.socket() + s.bind(("", 0)) + return s, s.getsockname()[1] + + username = "super" + password = "test" + digest_auth = "%s:%s" % (username, password) + client = self._get_client(auth_data=[("digest", digest_auth)]) + client.start() + + # get ports for election, zab and client endpoints. we need to use + # ports for which we'd immediately get a RST upon connect(); otherwise + # the cluster could crash if it gets a SocketTimeoutException: + # https://issues.apache.org/jira/browse/ZOOKEEPER-2202 + s1, port1 = free_sock_port() + s2, port2 = free_sock_port() + s3, port3 = free_sock_port() + + joining = "server.100=0.0.0.0:%d:%d:observer;0.0.0.0:%d" % ( + port1, + port2, + port3, + ) + data, _ = client.reconfig( + joining=joining, leaving=None, new_members=None + ) + assert joining.encode("utf8") in data + + data, _ = client.reconfig( + joining=None, leaving="100", new_members=None + ) + assert joining.encode("utf8") not in data + + # try to add it again, but a config number in the future + curver = int(data.decode().split("\n")[-1].split("=")[1], base=16) + with pytest.raises(BadVersionError): + self.client.reconfig( + joining=joining, + leaving=None, + new_members=None, + from_config=curver + 1, + ) + + def test_bad_input(self) -> None: + with pytest.raises(BadArgumentsError): + self.client.reconfig( + joining="some thing", leaving=None, new_members=None + ) diff --git a/kazoo/tests/test_connection.py b/kazoo/tests/test_connection.py index 7f9021fcc..1f5d6c8be 100644 --- a/kazoo/tests/test_connection.py +++ b/kazoo/tests/test_connection.py @@ -14,11 +14,7 @@ import pytest from kazoo.exceptions import ConnectionLoss, NotReadOnlyCallError -from kazoo.protocol.serialization import ( - Connect, - int_struct, - write_string, -) +from kazoo.protocol.serialization import Connect, int_struct, write_string from kazoo.protocol.states import KazooState, KeeperState from kazoo.protocol.connection import _CONNECTION_DROP from kazoo.testing import KazooTestCase @@ -400,11 +396,14 @@ def log_exception(*args: Any) -> None: args, exc_info = error_stack[-1] assert args == ("Unhandled exception in connection loop",) - assert exc_info[0] == RuntimeError + assert exc_info[0] is RuntimeError self.client.handler.sleep_func(0.2) assert self.connection_routine is not None - assert not self.connection_routine.is_alive() + # This vvvv doesn't work for greenlet handlers as the greenlet doesn't + # implement is_alive... + if (alive := getattr(self.connection_routine, "is_alive")) is not None: + assert not alive() assert len(self._zk_loop_errors) == 1 assert self._zk_loop_errors[0] == exc_info[1] diff --git a/kazoo/tests/test_eventlet_handler.py b/kazoo/tests/test_eventlet_handler.py index e410496a3..058fc173e 100644 --- a/kazoo/tests/test_eventlet_handler.py +++ b/kazoo/tests/test_eventlet_handler.py @@ -1,29 +1,45 @@ from __future__ import annotations import contextlib +import importlib import unittest -from typing import Generator, Literal, TYPE_CHECKING +from typing import Any, Generator, Literal, TYPE_CHECKING import pytest from kazoo.handlers.utils import create_tcp_socket from kazoo.handlers import utils +from kazoo.interfaces import IAsyncResult from kazoo.protocol import states as kazoo_states +from kazoo.testing.async_result_common import ( + AsyncResultCommon, + ThreadlikeContext, +) if TYPE_CHECKING: from kazoo.handlers.eventlet import SequentialEventletHandler -try: +if importlib.util.find_spec("eventlet") is None: + # The double test here stops complaints about imports not being at the top + # of the file. + pytest.importorskip("eventlet", reason="eventlet not available") +else: from eventlet.green import socket + import eventlet from kazoo.handlers import eventlet as eventlet_handler -except ImportError: - pytestmark = pytest.mark.skip(reason="eventlet not available") + + +class EventletContext(ThreadlikeContext): + def __enter__(self) -> None: + # Spawn the eventlet and then immediately sleep to hand over control + eventlet.spawn(self._target) # type:ignore[no-untyped-call] + eventlet.sleep(0) # type:ignore[no-untyped-call] @contextlib.contextmanager def start_stop_one( - handler: SequentialEventletHandler = None, # type: ignore[assignment] + handler: SequentialEventletHandler | None = None, ) -> Generator[SequentialEventletHandler]: if not handler: handler = eventlet_handler.SequentialEventletHandler() @@ -34,6 +50,18 @@ def start_stop_one( handler.stop() +# Doesn't work with eventlet, so commented out for now. +class TestEventletAsyncResult(AsyncResultCommon, unittest.TestCase): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__( + eventlet_handler.SequentialEventletHandler, + lambda handler: eventlet_handler.AsyncResult(handler), + thread_context_factory=EventletContext, + *args, + **kwargs, + ) + + class TestEventletHandler(unittest.TestCase): def test_started(self) -> None: with start_stop_one() as handler: @@ -65,10 +93,10 @@ def cb() -> None: assert len(captures) == 1 def test_async_link(self) -> None: - captures: list[SequentialEventletHandler] = [] + captures: list[IAsyncResult] = [] - def cb(handler: SequentialEventletHandler) -> None: - captures.append(handler) + def cb(async_result: IAsyncResult) -> None: + captures.append(async_result) with start_stop_one() as handler: r = handler.async_result() @@ -85,14 +113,14 @@ def test_timeout_raising(self) -> None: raise handler.timeout_exception("This is a timeout") def test_async_ok(self) -> None: - captures: list[Literal[1] | SequentialEventletHandler] = [] + captures: list[Literal[1] | IAsyncResult] = [] def delayed() -> Literal[1]: captures.append(1) return 1 - def after_delayed(handler: SequentialEventletHandler) -> None: - captures.append(handler) + def after_delayed(async_result: IAsyncResult) -> None: + captures.append(async_result) with start_stop_one() as handler: r = handler.async_result() diff --git a/kazoo/tests/test_gevent_handler.py b/kazoo/tests/test_gevent_handler.py index eda2505e4..2d0c7c66d 100644 --- a/kazoo/tests/test_gevent_handler.py +++ b/kazoo/tests/test_gevent_handler.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import unittest import sys @@ -8,25 +9,56 @@ from kazoo.exceptions import NoNodeError from kazoo.handlers.utils import create_tcp_socket +from kazoo.interfaces import IAsyncResult from kazoo.protocol.states import Callback, KazooState, ZnodeStat from kazoo.testing import KazooTestCase - -try: - import gevent # noqa: F401 +from kazoo.testing.async_result_common import ( + AsyncResultCommon, + ThreadlikeContext, +) + +if importlib.util.find_spec("gevent") is None: + # The double test here stops complaints about imports not being at the top + # of the file. + pytest.importorskip("gevent", reason="gevent not available") +else: + import gevent from gevent.event import Event from gevent.queue import Empty from gevent import socket from kazoo.handlers.gevent import AsyncResult, SequentialGeventHandler -except ImportError: - pytestmark = pytest.mark.skip(reason="gevent not available") + + +class GeventContext(ThreadlikeContext): + def __enter__(self) -> None: + # Spawn the geventlet and then immediately sleep to hand over control + gevent.spawn(self._target) + gevent.sleep(0) + + +@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") +class TestGeventAsyncResult(AsyncResultCommon, unittest.TestCase): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__( + SequentialGeventHandler, + lambda handler: AsyncResult(), + thread_context_factory=GeventContext, + *args, + **kwargs, + ) + + # We override this because the wait behaviour for AsyncResult is different + # for gevent and everything else even though IASyncResult is meant to be + # based on the gevent AsyncResult + def test_wait(self, *, is_green: bool = False) -> None: + super().test_wait(is_green=True) @pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") class TestGeventHandler(unittest.TestCase): - def _makeOne(self, *args: Any) -> SequentialGeventHandler: - return SequentialGeventHandler(*args) + _makeOne = SequentialGeventHandler - def _getAsync(self) -> Type[AsyncResult[Any]]: + def _getAsync(self) -> Type[IAsyncResult]: return AsyncResult def _getEvent(self) -> Type[Event]: @@ -43,12 +75,6 @@ def test_matching_async(self) -> None: async_handler = self._getAsync() assert isinstance(h.async_result(), async_handler) - def test_exception_raising(self) -> None: - h = self._makeOne() - - with pytest.raises(h.timeout_exception): - raise h.timeout_exception("This is a timeout") - def test_exception_in_queue(self) -> None: h = self._makeOne() h.start() @@ -78,12 +104,11 @@ def func() -> None: @pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows") class TestBasicGeventClient(KazooTestCase): + _makeOne = SequentialGeventHandler + def setUp(self) -> None: KazooTestCase.setUp(self) - def _makeOne(self, *args: Any) -> SequentialGeventHandler: - return SequentialGeventHandler(*args) - def _getEvent(self) -> Type[Event]: return Event diff --git a/kazoo/tests/test_hosts.py b/kazoo/tests/test_hosts.py index 80517d5d7..6cb603725 100644 --- a/kazoo/tests/test_hosts.py +++ b/kazoo/tests/test_hosts.py @@ -16,7 +16,7 @@ def test_ipv4(self) -> None: ("192.168.1.2", 2181), ("132.254.111.10", 2181), ] - assert chroot is None + assert chroot == "" hosts, chroot = collect_hosts( ["127.0.0.1:2181", "192.168.1.2:2181", "132.254.111.10:2181"] @@ -26,26 +26,26 @@ def test_ipv4(self) -> None: ("192.168.1.2", 2181), ("132.254.111.10", 2181), ] - assert chroot is None + assert chroot == "" def test_ipv6(self) -> None: hosts, chroot = collect_hosts("[fe80::200:5aee:feaa:20a2]:2181") assert hosts == [("fe80::200:5aee:feaa:20a2", 2181)] - assert chroot is None + assert chroot == "" hosts, chroot = collect_hosts(["[fe80::200:5aee:feaa:20a2]:2181"]) assert hosts == [("fe80::200:5aee:feaa:20a2", 2181)] - assert chroot is None + assert chroot == "" def test_hosts_list(self) -> None: hosts, chroot = collect_hosts("zk01:2181, zk02:2181, zk03:2181") expected1 = [("zk01", 2181), ("zk02", 2181), ("zk03", 2181)] assert hosts == expected1 - assert chroot is None + assert chroot == "" hosts, chroot = collect_hosts(["zk01:2181", "zk02:2181", "zk03:2181"]) assert hosts == expected1 - assert chroot is None + assert chroot == "" expected2 = "/test" hosts, chroot = collect_hosts("zk01:2181, zk02:2181, zk03:2181/test") diff --git a/kazoo/tests/test_lock.py b/kazoo/tests/test_lock.py index d03c2e193..a91a48fc6 100644 --- a/kazoo/tests/test_lock.py +++ b/kazoo/tests/test_lock.py @@ -341,7 +341,7 @@ def _acquire() -> None: attempts.append(int(lock.acquire(blocking=False))) threads = [] - for _i in range(0, self.thread_count): + for _ in range(0, self.thread_count): t = self.make_thread(target=_acquire) threads.append(t) t.start() @@ -374,7 +374,7 @@ def _acquire() -> None: differences.append(end_count - starting_count) threads = [] - for _i in range(0, self.thread_count): + for _ in range(0, self.thread_count): t = self.make_thread(target=_acquire) threads.append(t) t.start() diff --git a/kazoo/tests/test_retry.py b/kazoo/tests/test_retry.py index 9e67c6270..05ffa6edf 100644 --- a/kazoo/tests/test_retry.py +++ b/kazoo/tests/test_retry.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any +from functools import partial from unittest import mock import pytest @@ -8,19 +8,7 @@ from kazoo import exceptions as ke from kazoo import retry as kr - -def _make_retry(*args: Any, **kwargs: Any) -> kr.KazooRetry: - """Return a KazooRetry instance with a dummy sleep function.""" - - def _sleep_func(_time: float) -> None: - pass - - # FIXME better way of doing this? Use TypedDict perhaps? - return kr.KazooRetry( - *args, - sleep_func=_sleep_func, - **kwargs, # type: ignore[misc] - ) +_make_retry = partial(kr.KazooRetry, sleep_func=lambda _time: None) def _make_try_func(times: int = 1) -> mock.Mock: @@ -28,7 +16,7 @@ def _make_try_func(times: int = 1) -> mock.Mock: returning None. """ callmock = mock.Mock( - side_effect=[kr.ForceRetryError("Failed!")] * times + [None], + side_effect=[kr.ForceRetryError("Failed!")] * times + [None] ) return callmock diff --git a/kazoo/tests/test_sasl.py b/kazoo/tests/test_sasl.py index 411777053..d04b77451 100644 --- a/kazoo/tests/test_sasl.py +++ b/kazoo/tests/test_sasl.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import os import subprocess import time @@ -7,17 +8,12 @@ import pytest from kazoo.testing import KazooTestHarness -from kazoo.exceptions import ( - AuthFailedError, - NoAuthError, -) +from kazoo.exceptions import AuthFailedError, NoAuthError class TestLegacySASLDigestAuthentication(KazooTestHarness): def setUp(self) -> None: - try: - import puresasl # NOQA - except ImportError: + if importlib.util.find_spec("puresasl") is None: pytest.skip("PureSASL not available.") os.environ["ZOOKEEPER_JAAS_AUTH"] = "digest" @@ -58,9 +54,7 @@ def test_invalid_sasl_auth(self) -> None: class TestSASLDigestAuthentication(KazooTestHarness): def setUp(self) -> None: - try: - import puresasl # NOQA - except ImportError: + if importlib.util.find_spec("puresasl") is None: pytest.skip("PureSASL not available.") os.environ["ZOOKEEPER_JAAS_AUTH"] = "digest" @@ -112,13 +106,9 @@ def test_invalid_sasl_auth(self) -> None: class TestSASLGSSAPIAuthentication(KazooTestHarness): def setUp(self) -> None: # puresasl isn't available under windows, so we can't do this test. - try: - import puresasl - except ImportError: + if importlib.util.find_spec("puresasl") is None: pytest.skip("PureSASL not available.") - try: - import kerberos # type: ignore[import-not-found,import-untyped] - except ImportError: + if importlib.util.find_spec("kerberos") is None: pytest.skip("Kerberos support not available.") if not os.environ.get("KRB5_TEST_ENV"): pytest.skip("Test Kerberos environ not setup.") diff --git a/kazoo/tests/test_selectors_select.py b/kazoo/tests/test_selectors_select.py index 0d9e77da5..84abb3bbb 100644 --- a/kazoo/tests/test_selectors_select.py +++ b/kazoo/tests/test_selectors_select.py @@ -10,7 +10,7 @@ import sys import unittest -from typing import cast, TYPE_CHECKING +from typing import TYPE_CHECKING from kazoo.handlers.utils import selector_select @@ -24,17 +24,8 @@ (sys.platform[:3] == "win"), "can't easily test on this system" ) class SelectTestCase(unittest.TestCase): - class Nope: - pass - - class Almost: - def fileno(self) -> str: - return "fileno" - def test_error_conditions(self) -> None: self.assertRaises(TypeError, select, 1, 2, 3) - self.assertRaises(TypeError, select, [self.Nope()], [], []) - self.assertRaises(TypeError, select, [self.Almost()], [], []) self.assertRaises(TypeError, select, [], [], [], "not a number") self.assertRaises(ValueError, select, [], [], [], -1) @@ -59,23 +50,14 @@ def test_returned_list_identity(self) -> None: def test_select(self) -> None: cmd = "for i in 0 1 2 3 4 5 6 7 8 9; do echo testing...; sleep 1; done" with subprocess.Popen( - cmd, - shell=True, - stdout=subprocess.PIPE, - text=True, + cmd, shell=True, stdout=subprocess.PIPE, text=True ) as process: assert process.stdout is not None for tout in (0, 1, 2, 4, 8, 16) + (None,) * 10: - rfd, wfd, xfd = select( - [cast("HasFileNo", process.stdout)], [], [], tout - ) + rfd, wfd, xfd = select([process.stdout], [], [], tout) if (rfd, wfd, xfd) == ([], [], []): continue - if (rfd, wfd, xfd) == ( - [cast("HasFileNo", process.stdout)], - [], - [], - ): + if (rfd, wfd, xfd) == ([process.stdout], [], []): line = process.stdout.readline() if not line: break @@ -101,7 +83,3 @@ def fileno(self) -> int: finally: s1.close() s2.close() - - -if __name__ == "__main__": - unittest.main() diff --git a/kazoo/tests/test_serialization.py b/kazoo/tests/test_serialization.py index ff339c093..cc643ddb8 100644 --- a/kazoo/tests/test_serialization.py +++ b/kazoo/tests/test_serialization.py @@ -209,8 +209,7 @@ def test_serialize(self) -> None: def test_deserialize(self) -> None: result_stat = serialization.SetData.deserialize( - _stat_struct.pack(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11), - 0, + _stat_struct.pack(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11), 0 ) assert result_stat == _make_stat() @@ -253,8 +252,7 @@ def test_serialize(self) -> None: def test_deserialize(self) -> None: result_stat = serialization.SetACL.deserialize( - _stat_struct.pack(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11), - 0, + _stat_struct.pack(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11), 0 ) assert result_stat == _make_stat() @@ -269,8 +267,7 @@ def test_serialize(self) -> None: def test_deserialize(self) -> None: children = serialization.GetChildren.deserialize( - _int_struct.pack(2) + _write_string("a") + _write_string("b"), - 0, + _int_struct.pack(2) + _write_string("a") + _write_string("b"), 0 ) assert children == ["a", "b"] diff --git a/kazoo/tests/test_threading_handler.py b/kazoo/tests/test_threading_handler.py index f4ac5d434..fb6ec1923 100644 --- a/kazoo/tests/test_threading_handler.py +++ b/kazoo/tests/test_threading_handler.py @@ -4,13 +4,46 @@ import threading import unittest -from typing import Any, Type -from unittest.mock import Mock +from typing import Any, Callable, Type import pytest from kazoo.handlers.threading import AsyncResult, SequentialThreadingHandler from kazoo.handlers.utils import create_tcp_socket +from kazoo.testing.async_result_common import ( + AsyncResultCommon, + ThreadlikeContext, +) + + +class ThreadContext(ThreadlikeContext): + def __init__(self, target: Callable[[], None]): + super().__init__(target) + self._thread = threading.Thread(target=self._target) + + def __enter__(self) -> None: + self._thread.start() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object | None, + ) -> None: + self._thread.join() + + +class TestThreadingAsyncResult(AsyncResultCommon, unittest.TestCase): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__( + SequentialThreadingHandler, + lambda handler: AsyncResult(handler), + thread_context_factory=ThreadContext, + # threading.Condition, + # SequentialThreadingHandler.timeout_exception, + *args, + **kwargs, + ) class TestThreadingHandler(unittest.TestCase): @@ -23,9 +56,7 @@ def _getAsync(self) -> Type[AsyncResult]: def test_proper_threading(self) -> None: h = self._makeOne() h.start() - # In Python 3.3 _Event is gone, before Event is function - event_class = getattr(threading, "_Event", threading.Event) - assert isinstance(h.event_object(), event_class) + assert isinstance(h.event_object(), threading.Event) def test_matching_async(self) -> None: h = self._makeOne() @@ -69,326 +100,3 @@ def test_huge_file_descriptor(self) -> None: h.stop() for sock in socks: sock.close() - - -class TestThreadingAsync(unittest.TestCase): - def _makeOne(self, *args: Any) -> AsyncResult: - return AsyncResult(*args) - - def _makeHandler(self) -> SequentialThreadingHandler: - return SequentialThreadingHandler() - - def test_ready(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - assert async_result.ready() is False - async_result.set("val") - assert async_result.ready() is True - assert async_result.successful() is True - assert async_result.exception is None - - def test_callback_queued(self) -> None: - mock_handler = Mock() - mock_handler.completion_queue = Mock() - async_result = self._makeOne(mock_handler) - - async_result.rawlink(lambda a: a) - async_result.set("val") - - assert mock_handler.completion_queue.put.called - - def test_set_exception(self) -> None: - mock_handler = Mock() - mock_handler.completion_queue = Mock() - async_result = self._makeOne(mock_handler) - async_result.rawlink(lambda a: a) - async_result.set_exception(ImportError("Error occurred")) - - assert isinstance(async_result.exception, ImportError) - assert mock_handler.completion_queue.put.called - - def test_get_wait_while_setting(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - bv = threading.Event() - cv = threading.Event() - - def wait_for_val() -> None: - bv.set() - val = async_result.get() - lst.append(val) - cv.set() - - th = threading.Thread(target=wait_for_val) - th.start() - bv.wait() - - async_result.set("fred") - cv.wait() - assert lst == ["fred"] - th.join() - - def test_get_with_nowait(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - timeout = self._makeHandler().timeout_exception - - with pytest.raises(timeout): - async_result.get(block=False) - - with pytest.raises(timeout): - async_result.get_nowait() - - def test_get_with_exception(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - bv = threading.Event() - cv = threading.Event() - - def wait_for_val() -> None: - bv.set() - try: - val = async_result.get() - except ImportError: - lst.append("oops") - else: - lst.append(val) - cv.set() - - th = threading.Thread(target=wait_for_val) - th.start() - bv.wait() - - async_result.set_exception(ImportError()) - cv.wait() - assert lst == ["oops"] - th.join() - - def test_wait(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst: list[bool | str] = [] - bv = threading.Event() - cv = threading.Event() - - def wait_for_val() -> None: - bv.set() - try: - val = async_result.wait(10) - except ImportError: - lst.append("oops") - else: - lst.append(val) - cv.set() - - th = threading.Thread(target=wait_for_val) - th.start() - bv.wait(10) - - async_result.set("fred") - cv.wait(15) - assert lst == [True] - th.join() - - def test_wait_race(self) -> None: - """Test that there is no race condition in `IAsyncResult.wait()`. - - Guards against the reappearance of: - https://github.com/python-zk/kazoo/issues/485 - """ - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - async_result.set("immediate") - - cv = threading.Event() - - def wait_for_val() -> None: - # NB: should not sleep - async_result.wait(20) - cv.set() - - th = threading.Thread(target=wait_for_val) - th.daemon = True - th.start() - - # if the wait() didn't sleep (correctly), cv will be set quickly - # if it did sleep, the cv will not be set yet and this will timeout - cv.wait(10) - assert cv.is_set() is True - th.join() - - def test_set_before_wait(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - cv = threading.Event() - async_result.set("fred") - - def wait_for_val() -> None: - val = async_result.get() - lst.append(val) - cv.set() - - th = threading.Thread(target=wait_for_val) - th.start() - cv.wait() - assert lst == ["fred"] - th.join() - - def test_set_exc_before_wait(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - cv = threading.Event() - async_result.set_exception(ImportError()) - - def wait_for_val() -> None: - try: - val = async_result.get() - except ImportError: - lst.append("ooops") - else: - lst.append(val) - cv.set() - - th = threading.Thread(target=wait_for_val) - th.start() - cv.wait() - assert lst == ["ooops"] - th.join() - - def test_linkage(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - cv = threading.Event() - - lst = [] - - def add_on() -> None: - lst.append(True) - - def wait_for_val() -> None: - async_result.get() - cv.set() - - th = threading.Thread(target=wait_for_val) - th.start() - - async_result.rawlink(add_on) - async_result.set(b"fred") - assert mock_handler.completion_queue.put.called - async_result.unlink(add_on) - cv.wait() - assert async_result.value == b"fred" - th.join() - - def test_linkage_not_ready(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - - def add_on() -> None: - lst.append(True) - - async_result.set("fred") - assert not mock_handler.completion_queue.called - async_result.rawlink(add_on) - assert mock_handler.completion_queue.put.called - - def test_link_and_unlink(self) -> None: - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - - def add_on() -> None: - lst.append(True) - - async_result.rawlink(add_on) - assert not mock_handler.completion_queue.put.called - async_result.unlink(add_on) - async_result.set("fred") - assert not mock_handler.completion_queue.put.called - - def test_captured_exception(self) -> None: - from kazoo.handlers.utils import capture_exceptions - - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - @capture_exceptions(async_result) - def exceptional_function() -> float: - return 1 / 0 - - exceptional_function() - - with pytest.raises(ZeroDivisionError): - async_result.get() - - def test_no_capture_exceptions(self) -> None: - from kazoo.handlers.utils import capture_exceptions - - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - - def add_on() -> None: - lst.append(True) - - async_result.rawlink(add_on) - - @capture_exceptions(async_result) - def regular_function() -> bool: - return True - - regular_function() - - assert not mock_handler.completion_queue.put.called - - def test_wraps(self) -> None: - from kazoo.handlers.utils import wrap - - mock_handler = Mock() - async_result = self._makeOne(mock_handler) - - lst = [] - - def add_on(result: AsyncResult) -> None: - lst.append(result.get()) - - async_result.rawlink(add_on) - - @wrap(async_result) - def regular_function() -> str: - return "hello" - - assert regular_function() == "hello" - assert mock_handler.completion_queue.put.called - assert async_result.get() == "hello" - - def test_multiple_callbacks(self) -> None: - mockback1 = Mock(name="mockback1") - mockback2 = Mock(name="mockback2") - handler = self._makeHandler() - handler.start() - - async_result = self._makeOne(handler) - async_result.rawlink(mockback1) - async_result.rawlink(mockback2) - async_result.set("howdy") - async_result.wait() - handler.stop() - - mockback2.assert_called_once_with(async_result) - mockback1.assert_called_once_with(async_result) diff --git a/pyproject.toml b/pyproject.toml index 9455bf328..a81a5440f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,11 +57,14 @@ test-extras = [ "pyjks==20.0.0", "unittest-parametrize==1.8.0", ] +coverage = [ + "coverage==7.10.7" +] pytest = [ "pytest==8.4.2", ] test = [ - "coverage==7.10.7", + {include-group = "coverage"}, {include-group = "pytest"}, "pytest-cov==7.1.0", "pytest-timeout==2.4.0", @@ -126,7 +129,8 @@ line-ending = "auto" [tool.pytest.ini_options] addopts = "-ra -v --color=yes" -log_cli = true +# log_cli = true +log_cli = false log_cli_date_format = "%Y-%m-%d %H:%M:%S" log_cli_format = "%(asctime)s %(levelname)s %(message)s" log_cli_level = "INFO" diff --git a/run_failure.py b/run_failure.py deleted file mode 100644 index 7bc1dac2d..000000000 --- a/run_failure.py +++ /dev/null @@ -1,28 +0,0 @@ -import os -import sys - - -def test(arg): - return os.system("bin/pytest -v %s" % arg) - - -def main(args): - if not args: - print( - "Run as bin/python run_failure.py , for example: \n" - "bin/python run_failure.py " - "kazoo.tests.test_watchers:KazooChildrenWatcherTests" - ) - return - arg = args[0] - i = 0 - while 1: - i += 1 - print("Run number: %s" % i) - ret = test(arg) - if ret != 0: - break - - -if __name__ == "__main__": - main(sys.argv[1:]) diff --git a/tox.ini b/tox.ini index 88995960e..8b01121bf 100644 --- a/tox.ini +++ b/tox.ini @@ -31,14 +31,22 @@ allowlist_externals = {toxinidir}/ensure-zookeeper-env.sh {toxinidir}/init_krb5.sh bash + echo commands = + echo Current environment: {env:TOX_ENV_NAME} bash \ sasl: {toxinidir}/init_krb5.sh {envtmpdir}/kerberos \ {toxinidir}/ensure-zookeeper-env.sh \ - pytest {posargs: -ra -v --cov-report=xml --cov=kazoo kazoo/tests} + pytest {posargs: -ra -v --cov-append --cov-report=xml --cov=kazoo kazoo/tests} [testenv:build] +[testenv:clean_coverage] +dependency_groups = + coverage +skip_install = true +commands = coverage erase + [testenv:docs] dependency_groups = test-extras