diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3957d0e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions # See documentation for possible values + directory: .github/ # Location of package manifests + schedule: + interval: weekly diff --git a/.github/workflows/build-n-test.yml b/.github/workflows/build-n-test.yml new file mode 100644 index 0000000..560302b --- /dev/null +++ b/.github/workflows/build-n-test.yml @@ -0,0 +1,42 @@ +name: Build ๐Ÿ› ๏ธ and test ๐Ÿงช eventkit + +on: [push, workflow_dispatch] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [pypy3.10, "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + # Print the current Python version + - name: Display Python version + run: python -c "import sys; print(sys.version)" + - name: Install dependencies + run: | + python -m pip install -U pip poetry + poetry install --with dev + + - name: Ruff static code analysis + run: | + poetry run ruff check eventkit/ + poetry run ruff format eventkit/ + + - name: MyPy static code analysis + run: | + poetry run mypy . + + - name: Test with pytest + run: | + poetry run pytest -vs + + - name: Build a binary wheel and a source tarball + run: | + poetry build -n diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..bf420da --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,91 @@ +name: Publish Python ๐Ÿ distribution ๐Ÿ“ฆ to PyPI + +on: + workflow_dispatch: + push: + tags: + - v* + +jobs: + build: + name: Build distribution ๐Ÿ“ฆ + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version: 3.x + cache: pip + - name: "Install pypa/build" + run: | + python -m pip install -U pip poetry + - name: "Build a binary wheel and a source tarball" + run: | + poetry build -n + - name: "Store the distribution packages" + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish Python ๐Ÿ distribution ๐Ÿ“ฆ to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish on tag pushes + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/eventkit + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: "Download all the dists" + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: "Publish distribution ๐Ÿ“ฆ to PyPI" + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: >- + Sign the ๐Ÿ“ฆ with Sigstore and upload them to GitHub Release + needs: + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: "Download all the dists" + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: "Sign the dists with Sigstore" + uses: sigstore/gh-action-sigstore-python@v2.1.1 + with: + inputs: >- + ./dist/*.tar.gz ./dist/*.whl + - name: "Create GitHub Release" + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create '${{ github.ref_name }}' \ + --repo '${{ github.repository }}' --notes "" + - name: "Upload artifact signatures to GitHub Release" + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload '${{ github.ref_name }}' dist/** \ + --repo '${{ github.repository }}' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 0746bb5..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: eventkit - -on: [ push, pull_request ] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ 3.8, 3.9, "3.10", "3.11", "3.12" ] - - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 mypy pytest . - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - - name: Flake8 static code analysis - run: - flake8 eventkit - - - name: MyPy static code analysis - run: | - mypy -p eventkit - - - name: Test with pytest - run: | - pytest tests diff --git a/.gitignore b/.gitignore index b7d56d6..2995a65 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,6 @@ docs/html/.doctrees docs/doctrees eventkit.egg-info dist/ +html/ poetry.lock .python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96e1cca..08edb37 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,3 +14,7 @@ repos: hooks: - id: ruff - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index a8fbeac..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = python3 -msphinx -SPHINXPROJ = distex -SOURCEDIR = . -BUILDDIR = . - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py index dbea3c8..b0ed635 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,5 @@ +from importlib.metadata import version as packageversion + extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", @@ -12,12 +14,11 @@ copyright = "2021, Ewald de Wit" author = "Ewald de Wit" -__version__ = None -exec(open("../eventkit/version.py").read()) +__version__ = packageversion("eventkit") version = ".".join(__version__.split(".")[:2]) release = __version__ -language = None +language = "en" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] pygments_style = "sphinx" todo_include_todos = False diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index d930974..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,36 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=python3 -msphinx -) -set SOURCEDIR=. -set BUILDDIR=_build -set SPHINXPROJ=distex - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The Sphinx module was not found. Make sure you have Sphinx installed, - echo.then set the SPHINXBUILD environment variable to point to the full - echo.path of the 'sphinx-build' executable. Alternatively you may add the - echo.Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% - -:end -popd diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 310cd82..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sphinxcontrib-napoleon -sphinx-autodoc-typehints -sphinx-rtd-theme diff --git a/eventkit/event.py b/eventkit/event.py index 271d826..95034b3 100644 --- a/eventkit/event.py +++ b/eventkit/event.py @@ -1,23 +1,120 @@ +from __future__ import annotations + import asyncio +import itertools import logging import types import weakref -from typing import ( - Any as AnyType, -) +from dataclasses import dataclass, field +from typing import Any as AnyType from typing import ( AsyncIterable, Awaitable, + Callable, + Final, Iterable, List, - Optional, Tuple, Union, ) -from .util import NO_VALUE, get_event_loop, main_event_loop +from .util import NO_VALUE, _NoValue, get_event_loop + + +@dataclass(slots=True) +class Slot: + obj: AnyType + weakref: Callable[[AnyType], AnyType] | None + func: Callable[[AnyType], AnyType] + + +@dataclass(slots=True) +class Slots: + slots: list[Slot] = field(default_factory=list) + + def add(self, obj, weakref, func) -> None: + self.slots.append(Slot(obj, weakref, func)) + + def remove(self, obj, func): + """Remove a specific obj/func combination from the slots collection.""" + self.slots = list( + itertools.filterfalse( + lambda x: (x.obj is obj or x.weakref and x.weakref() is obj) + and x.func is func, + self.slots, + ) + ) + + def remove_obj(self, obj): + self.slots = list( + itertools.filterfalse( + lambda x: x.obj is obj or x.weakref and x.weakref() is obj, + self.slots, + ) + ) + + def remove_ref(self, ref): + self.slots = list( + itertools.filterfalse( + lambda x: x.weakref is ref, + self.slots, + ) + ) + + def exists(self, obj, func): + return any( + [ + (x.obj is obj or x.weakref and x.weakref() is obj) and x.func is func + for x in self.slots + ] + ) + + @property + def count(self) -> int: + return len(self.slots) + + def clear(self) -> None: + self.slots = [] + + def __call__(self, caller, *args, **kwargs): + """Loop over all active callbacks and call them""" + for slot in self.slots.copy(): + ref = slot.weakref + func = slot.func + + try: + if ref: + obj = ref() + else: + obj = slot.obj + + result = None + if obj is None: + if func: + result = func(*args, **kwargs) + else: + if func: + result = func(obj, *args, **kwargs) + else: + result = obj(*args, **kwargs) + + # even though asyncio.iscoroutine() would also work here, + # this manual hasattr() check performs better. + if result and hasattr(result, "__await__"): + asyncio.ensure_future(result, loop=get_event_loop()) + except Exception as error: + # It's not really clear in the documentation or usage that exceptions + # get returned via an 'error_event' callback. We should make sure + # people know this clearly so event handler callback errors are noticed. + if len(caller.error_event): + caller.error_event.emit(caller, error) + else: + caller.logger.exception( + f"Value {args} caused exception for event {caller}" + ) +@dataclass(slots=False) class Event: """ Enable event passing between loosely coupled components. @@ -28,47 +125,43 @@ class Event: name: Name to use for this event. """ - __slots__ = ( - "error_event", - "done_event", - "_name", - "_value", - "_slots", - "_done", - "_source", - "__weakref__", - ) + _name: str = "" + _with_error_done_events: bool = True - NO_VALUE = NO_VALUE - logger = logging.getLogger(__name__) + # Sub event that emits errors from this event as ``emit(source, exception)``. + error_event: Event | None = None - error_event: Optional["Event"] - done_event: Optional["Event"] - _name: str - _value: AnyType - _slots: List[List] - _done: bool - _source: Optional["Event"] + # Sub event that emits when this event is done as ``emit(source)``. + done_event: Event | None = None - def __init__(self, name: str = "", _with_error_done_events: bool = True): - self.error_event = None - """ - Sub event that emits errors from this event as - ``emit(source, exception)``. - """ - self.done_event = None - """ - Sub event that emits when this event is done as - ``emit(source)``. - """ - if _with_error_done_events: + logger: logging.Logger = field(default_factory=lambda: logging.getLogger(__name__)) + + _value: AnyType = NO_VALUE + _slots: Final[Slots] = field(default_factory=Slots) + _done: bool = False + _source: Event | None = None + __weakref__: AnyType = None + _task: AnyType = None + + NO_VALUE: Final[_NoValue] = NO_VALUE + + def __post_init__(self) -> None: + if self._with_error_done_events: self.error_event = Event("error", False) self.done_event = Event("done", False) - self._slots = [] # list of [obj, weakref, func] sublists - self._name = name or self.__class__.__qualname__ - self._value = NO_VALUE - self._done = False - self._source = None + + if not self._name: + self._name = self.__class__.__qualname__ + + def __hash__(self) -> int: + return hash( + ( + self.name, + self._with_error_done_events, + self.error_event, + self.done_event, + ) + ) def name(self) -> str: """ @@ -97,6 +190,7 @@ def value(self): This event's last emitted value. """ v = self._value + return ( NO_VALUE if v is NO_VALUE else v[0] if len(v) == 1 else v if v else NO_VALUE ) @@ -143,18 +237,22 @@ def g(a, b): # let the operator connect itself to this event listener.set_source(self) return self + obj, func = self._split(listener) if not keep_ref and hasattr(obj, "__weakref__"): ref = weakref.ref(obj, self._onFinalize) obj = None else: ref = None - slot = [obj, ref, func] - self._slots.append(slot) + + self._slots.add(obj, ref, func) + if self.done_event and done is not None: self.done_event.connect(done) + if self.error_event and error is not None: self.error_event.connect(error) + return self def disconnect(self, listener, error=None, done=None): @@ -171,15 +269,14 @@ def disconnect(self, listener, error=None, done=None): done: The done callback to disconnect. """ obj, func = self._split(listener) - for slot in self._slots: - if (slot[0] is obj or slot[1] and slot[1]() is obj) and slot[2] is func: - slot[0] = slot[1] = slot[2] = None - break - self._slots = [s for s in self._slots if s != [None, None, None]] + self._slots.remove(obj, func) + if error is not None: self.error_event.disconnect(error) + if done is not None: self.done_event.disconnect(done) + return self def disconnect_obj(self, obj): @@ -191,12 +288,11 @@ def disconnect_obj(self, obj): obj: The target object that is to be completely removed from this event. """ - for slot in self._slots: - if slot[0] is obj or slot[1] and slot[1]() is obj: - slot[0] = slot[1] = slot[2] = None - self._slots = [s for s in self._slots if s != [None, None, None]] + self._slots.remove_obj(obj) + if self.error_event is not None: self.error_event.disconnect_obj(obj) + if self.done_event is not None: self.done_event.disconnect_obj(obj) @@ -208,47 +304,20 @@ def emit(self, *args): args: Argument values to emit to listeners. """ self._value = args - for obj, ref, func in self._slots.copy(): - try: - if ref: - obj = ref() - - result = None - if obj is None: - if func: - result = func(*args) - else: - if func: - result = func(obj, *args) - else: - result = obj(*args) - - if result and hasattr(result, "__await__"): - loop = get_event_loop() - asyncio.ensure_future(result, loop=loop) - - except Exception as error: - if len(self.error_event): - self.error_event.emit(self, error) - else: - Event.logger.exception( - f"Value {args} caused exception for event {self}" - ) + self._slots(self, *args) def emit_threadsafe(self, *args): """ Threadsafe version of :meth:`emit` that doesn't invoke the listeners directly but via the event loop of the main thread. """ - main_event_loop.call_soon_threadsafe(self.emit, *args) + get_event_loop().call_soon_threadsafe(self.emit, *args) def clear(self): """ Disconnect all listeners. """ - for slot in self._slots: - slot[0] = slot[1] = slot[2] = None - self._slots = [] + self._slots.clear() def run(self) -> List: """ @@ -258,7 +327,9 @@ def run(self) -> List: import eventkit as ev ev.Timer(0.25, count=10).run() - -> + + Outputs: + [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5] .. note:: @@ -297,6 +368,7 @@ def pipe(self, *targets: "Event"): t = Event.create(t) t.set_source(source) source = t + return source def fork(self, *targets: "Event") -> "Fork": @@ -322,16 +394,14 @@ def fork(self, *targets: "Event") -> "Fork": t = Event.create(t) t.set_source(self) fork.append(t) + return fork def set_source(self, source): self._source = source def _onFinalize(self, ref): - for slot in self._slots: - if slot[1] is ref: - slot[0] = slot[1] = slot[2] = None - self._slots = [s for s in self._slots if s != [None, None, None]] + self._slots.remove_ref(ref) @staticmethod def _split(c): @@ -340,19 +410,22 @@ def _split(c): """ if isinstance(c, types.FunctionType): return (None, c) - elif isinstance(c, types.MethodType): + + if isinstance(c, types.MethodType): return (c.__self__, c.__func__) - elif isinstance(c, types.BuiltinMethodType): + + if isinstance(c, types.BuiltinMethodType): if type(c.__self__) is type: # built-in method return (c.__self__, c) else: # built-in function return (None, c) - elif hasattr(c, "__call__"): + + if hasattr(c, "__call__"): return (c, None) - else: - raise ValueError(f"Invalid callable: {c}") + + raise ValueError(f"Invalid callable: {c}") async def aiter(self, skip_to_last: bool = False, tuples: bool = False): """ @@ -392,6 +465,7 @@ def on_done(source): if self.done(): return + q: asyncio.Queue[Tuple[str, AnyType]] = asyncio.Queue() self.connect(on_event, on_error, on_done) try: @@ -423,7 +497,7 @@ def __repr__(self): return f"Event<{self.name()}, {self._slots}>" def __len__(self): - return len(self._slots) + return self._slots.count def __bool__(self): return True @@ -431,6 +505,7 @@ def __bool__(self): def __getitem__(self, fork_targets) -> "Fork": if not hasattr(fork_targets, "__iter__"): fork_targets = (fork_targets,) + return self.fork(*fork_targets) def __await__(self): @@ -462,6 +537,7 @@ def on_future_done(f): if self.done(): raise ValueError("Event already done") + fut = asyncio.Future() self.connect(on_event, on_error) fut.add_done_callback(on_future_done) @@ -483,10 +559,7 @@ def __contains__(self, c): See if callable is already connected. """ obj, func = self._split(c) - return any( - (s[0] is obj or s[1] and s[1]() is obj) and s[2] is func - for s in self._slots - ) + return self._slots.exists(obj, func) def __reduce__(self): """ @@ -523,17 +596,20 @@ def create(obj): """ if isinstance(obj, Event): return obj + if hasattr(obj, "__call__"): obj = obj() if isinstance(obj, Event): return obj - elif hasattr(obj, "__aiter__"): + + if hasattr(obj, "__aiter__"): return Event.aiterate(obj) - elif hasattr(obj, "__await__"): + + if hasattr(obj, "__await__"): return Event.wait(obj) - else: - raise ValueError(f"Invalid type: {obj}") + + raise ValueError(f"Invalid type: {obj}") @staticmethod def wait(future: Awaitable) -> "Wait": @@ -602,7 +678,7 @@ def repeat( times: Relative times for individual values, in seconds since start of event. The sequence should match ``values``. """ - return Repeat(interval, value, count, times) + return Repeat(value=value, count=count, interval=interval, times=times) @staticmethod def range( @@ -893,7 +969,7 @@ def map(self, func, timeout=None, ordered=True, task_limit=None) -> "Map": ``timeout``, ``ordered`` and ``task_limit`` apply to async functions only. """ - return Map(func, timeout, ordered, task_limit, self) + return Map(func, timeout, ordered, task_limit, source=self) def emap(self, constr, joiner: "AddableJoinOp") -> "Emap": """ @@ -1356,9 +1432,7 @@ def end_on_error(self) -> "EndOnError": Reduce, Sum, ) -from .ops.aggregate import ( - List as ListOp, -) +from .ops.aggregate import List as ListOp from .ops.array import ( Array, ArrayAll, diff --git a/eventkit/ops/create.py b/eventkit/ops/create.py index 445e975..24774b3 100644 --- a/eventkit/ops/create.py +++ b/eventkit/ops/create.py @@ -16,8 +16,8 @@ def __init__(self, future, name="wait"): self._task = None self.set_done() else: - loop = get_event_loop() - self._task = asyncio.ensure_future(future, loop=loop) + # Note: the loop= *is* necessary here. + self._task = asyncio.ensure_future(future, loop=get_event_loop()) future.add_done_callback(self._on_task_done) def _on_task_done(self, task): @@ -26,6 +26,7 @@ def _on_task_done(self, task): except Exception as error: result = NO_VALUE self.error_event.emit(self, error) + self.emit(result) self._task = None self.set_done() @@ -40,8 +41,9 @@ class Aiterate(Event): def __init__(self, ait): Event.__init__(self, ait.__qualname__) - loop = get_event_loop() - self._task = asyncio.ensure_future(self._looper(ait), loop=loop) + + # Note: the loop= *is* necessary here. + self._task = asyncio.ensure_future(self._looper(ait), loop=get_event_loop()) async def _looper(self, ait): try: @@ -49,6 +51,7 @@ async def _looper(self, ait): self.emit(args) except Exception as error: self.error_event.emit(self, error) + self._task = None self.set_done() diff --git a/eventkit/ops/transform.py b/eventkit/ops/transform.py index fb4d839..afcdd38 100644 --- a/eventkit/ops/transform.py +++ b/eventkit/ops/transform.py @@ -3,7 +3,7 @@ import time from collections import deque -from ..util import NO_VALUE, get_event_loop +from ..util import NO_VALUE from .combine import Chain, Concat, Merge, Switch from .op import Op @@ -110,6 +110,7 @@ def __init__(self, *selections, source=None): s[0] = 0 else: s.insert(0, 0) + self._selections.append(s) def on_source(self, *args): @@ -121,7 +122,9 @@ def on_source(self, *args): value = getattr(value, attr) except Exception: value = NO_VALUE + values.append(value) + self.emit(*values) @@ -170,6 +173,7 @@ def on_source(self, *args): def on_source_done(self, source): if self._list: self.emit(self._list) + Op.on_source_done(self, self._source) @@ -195,11 +199,13 @@ def on_source_done(self, source): if self._list: self.emit(self._list) self._list = None + if self._timer is not None: self._timer.disconnect( self._on_timer, self.on_source_error, self.on_source_done ) self._timer = None + Op.on_source_done(self, self._source) @@ -210,6 +216,7 @@ def __init__(self, func, timeout=0, ordered=True, task_limit=None, source=None): Op.__init__(self, source) if source is not None and source.done(): return + self._func = func self._timeout = timeout self._ordered = ordered @@ -219,7 +226,7 @@ def __init__(self, func, timeout=0, ordered=True, task_limit=None, source=None): def on_source(self, *args): obj = self._func(*args) - if hasattr(obj, "__await__"): + if asyncio.iscoroutine(obj): # function returns an awaitable if not self._task_limit or len(self._tasks) < self._task_limit: # schedule right away @@ -235,14 +242,15 @@ def on_source_done(self, source): if not self._tasks: # only end when no tasks are pending Op.on_source_done(self, self._source) + self._source = None def _create_task(self, coro): # schedule a task to be run if self._timeout: coro = asyncio.wait_for(coro, self._timeout) - loop = get_event_loop() - task = asyncio.ensure_future(coro, loop=loop) + + task = asyncio.create_task(coro) task.add_done_callback(self._on_task_done) self._tasks.append(task) diff --git a/eventkit/util.py b/eventkit/util.py index 86af382..da34ab9 100644 --- a/eventkit/util.py +++ b/eventkit/util.py @@ -1,6 +1,6 @@ import asyncio import datetime as dt -from typing import AsyncIterator +from typing import AsyncIterator, Final class _NoValue: @@ -13,15 +13,13 @@ def __repr__(self): __str__ = __repr__ -NO_VALUE = _NoValue() +NO_VALUE: Final = _NoValue() def get_event_loop(): - """Get asyncio event loop, running or not.""" - return asyncio.get_event_loop_policy().get_event_loop() - - -main_event_loop = get_event_loop() + """Get asyncio event loop or create one if it doesn't exist.""" + loop = asyncio.get_event_loop_policy().get_event_loop() + return loop async def timerange(start=0, end=None, step: float = 1) -> AsyncIterator[dt.datetime]: diff --git a/pyproject.toml b/pyproject.toml index 384a7a0..6d8ae1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,10 +13,6 @@ classifiers = [ "Intended Audience :: Science/Research", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -24,16 +20,30 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.8" +python = ">=3.10" numpy = "*" -[tool.poetry.dev-dependencies] +[tool.poetry.urls] +"Bug Tracker" = "https://github.com/ib-api-reloaded/eventkit/issues" + +[tool.poetry.group.dev] +optional = true + +[tool.poetry.group.dev.dependencies] ruff = "^0.5.7" pre-commit = "^3.5.0" +pytest = "^8.3.2" +pytest-xdist = "^3.6.1" +mypy = "^1.11.1" -[tool.poetry.urls] -"Bug Tracker" = "https://github.com/ib-api-reloaded/eventkit/issues" +[tool.poetry.group.docs] +optional = true +[tool.poetry.group.docs.dependencies] +sphinx-autodoc-typehints = "^2.0.0" +sphinx-rtd-theme = "^2.0.0" +myst-parser = "^2.0.0" +sphinxcontrib-napoleon = "^0.7" [build-system] requires = ["poetry-core"] @@ -58,3 +68,5 @@ ignore = [ "E402", "F401", ] +[tool.pytest.ini_options] +log_cli = true diff --git a/tests/create_test.py b/tests/create_test.py index c82b508..a2134c4 100644 --- a/tests/create_test.py +++ b/tests/create_test.py @@ -2,15 +2,15 @@ import unittest from eventkit import Event +from eventkit.util import get_event_loop array1 = list(range(10)) array2 = list(range(100, 110)) -loop = asyncio.get_event_loop_policy().get_event_loop() - class CreateTest(unittest.TestCase): def test_wait(self): + loop = get_event_loop() fut = asyncio.Future(loop=loop) loop.call_later(0.001, fut.set_result, 42) event = Event.wait(fut) diff --git a/tests/event_test.py b/tests/event_test.py index 839d1ba..9dfbeb9 100644 --- a/tests/event_test.py +++ b/tests/event_test.py @@ -1,11 +1,16 @@ import asyncio +import gc +import platform import unittest import eventkit as ev from eventkit import Event +from eventkit.util import get_event_loop -loop = asyncio.get_event_loop_policy().get_event_loop() -run = loop.run_until_complete + +def run(*args, **kwargs): + loop = get_event_loop() + return loop.run_until_complete(*args, **kwargs) class Object: @@ -39,6 +44,8 @@ def test_functor(self): self.assertEqual(obj2.value, -1) del obj2 + if platform.python_implementation() == "PyPy": + gc.collect() self.assertEqual(len(event), 1) event -= obj1 self.assertNotIn(obj1, event) @@ -57,6 +64,8 @@ def test_method(self): self.assertEqual(obj2.value, -1) del obj2 + if platform.python_implementation() == "PyPy": + gc.collect() self.assertEqual(len(event), 1) event -= obj1.method self.assertNotIn(obj1.method, event) @@ -163,6 +172,26 @@ def test_operator_connect(self): ev1.emit(i) self.assertEqual(result, list(range(10, 20))) + def test_emit_threadsafe(self): + async def coro(d): + result.append(d) + await asyncio.sleep(0) + + result = [] + event = Event("test") + event += coro + + event.emit_threadsafe(4) + event.emit_threadsafe(2) + run(asyncio.sleep(0)) + self.assertEqual(result, [4, 2]) + + result.clear() + event -= coro + event.emit_threadsafe(8) + run(asyncio.sleep(0)) + self.assertEqual(result, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/slots_test.py b/tests/slots_test.py new file mode 100644 index 0000000..94b27dc --- /dev/null +++ b/tests/slots_test.py @@ -0,0 +1,235 @@ +"""Test Slot and Slots""" + +import weakref + +import pytest + +from eventkit.event import Event, Slot, Slots + + +class Obj: + """Test Object""" + + value: int + + def __call__(self): + return self.method() + + def method(self): + self.value = 42 + return self.value + + def error_handler(self, caller, error): + self.value = 73 + assert isinstance(error, ValueError) + + def error(self): + raise ValueError + + +def func(): + """Test Function""" + return 42 + + +def error_func(): + """Test error function""" + raise ValueError + + +@pytest.fixture +def slots_fixture(): + """Slots fixture""" + obj = Obj() + _obj, meth = Event._split(obj.method) + obj2 = Obj() + wr = weakref.ref(obj2) + slots = Slots() + slots.add(_obj, None, meth) + slots.add(None, None, func) + slots.add(None, wr, None) + yield slots, obj.method, wr, obj + + +class TestSlot: + """Slot tests""" + + def test_slot_obj(self): + """Test slot obj""" + + obj = Obj() + _obj, _meth = Event._split(obj.method) + slot_obj = Slot(_obj, None, _meth) + # + assert slot_obj.obj() == 42 + + def test_slot_callable(self): + """Test slot callable""" + + obj = Obj() + slot_obj = Slot(obj, None, None) + # + assert slot_obj.obj() == 42 + + def test_slot_func(self): + """Test slot func""" + + slot_obj = Slot(None, None, func) + # + assert slot_obj.func() == 42 + + def test_slot_weakref(self): + """Test slot weakref""" + obj = Obj() + wr = weakref.ref(obj) + slot1 = Slot(obj, None, None) + slot2 = Slot(None, wr, None) + # + assert slot1.obj() == 42 + del obj + assert slot2.weakref().method() == 42 + + +class TestSlots: + """Test Slots""" + + def test_slots_add(self): + """Test Slots.add""" + meth = Obj().method + wr = weakref.ref(Obj()) + slots = Slots() + # + slots.add(meth, None, None) + # + assert len(slots.slots) == slots.count + assert slots.slots[0].obj is meth + # + slots.add(None, None, func) + # + assert len(slots.slots) == slots.count + assert slots.slots[-1].func is func + # + slots.add(None, wr, None) + # + assert len(slots.slots) == slots.count + assert slots.slots[-1].weakref is wr + + def test_slots_exists(self, slots_fixture): + """Test Slots.exists""" + + slots, meth, wr, _ = slots_fixture + # + _obj, _meth = Event._split(meth) + assert slots.exists(_obj, _meth) + assert slots.exists(wr(), None) + assert slots.exists(None, func) + + def test_slots_remove_obj(self, slots_fixture): + """Test Slots.remove_obj""" + slots, meth, wr, _ = slots_fixture + # + _obj, _meth = Event._split(meth) + slots.remove_obj(_obj) + assert not slots.exists(_obj, _meth) + assert slots.exists(wr(), None) + # + slots.remove_obj(wr) + assert not slots.exists(wr, None) + # + assert slots.exists(None, func) + + def test_slots_remove_ref(self, slots_fixture): + """Test Slots.remove_ref""" + slots, meth, wr, _ = slots_fixture + + # + _obj, _meth = Event._split(meth) + slots.remove_ref(wr) + assert not slots.exists(wr, None) + assert slots.exists(_obj, _meth) + assert slots.exists(None, func) + + def test_slots_remove(self, slots_fixture): + """Test Slots.remove""" + slots, meth, wr, _ = slots_fixture + + # + slots.remove(meth, None) + assert not slots.exists(meth, None) + assert slots.exists(wr(), None) + # + slots.remove(wr, None) + assert not slots.exists(wr, None) + assert slots.exists(None, func) + # + slots.remove(None, func) + assert not slots.exists(None, func) + assert slots.count == len(slots.slots) + + def test_slots_clear(self, slots_fixture): + """Test Slots.clear""" + [slots, _, _, _] = slots_fixture + # + slots.clear() + # + assert slots.count == len(slots.slots) + + def test_slots_call(self, slots_fixture): + """Test Slots.__call__""" + slots, _, wr, obj = slots_fixture + + ev = Event("test_slots_call") + slots(ev) + assert obj.value == 42 + assert wr().value == 42 + + def test_slots_call_exception(self): + """Test Slots.__call__ with exception""" + slots = Slots() + + event = Event("test_slots_call_exception") + obj = Obj() + obj2 = Obj() + obj3 = Obj() + event.error_event.connect(obj2.error_handler) + slots.add(None, None, func) # success + slots.add(obj3.method, None, None) # success + slots.add(obj.error, None, None) # fail + slots(event) + assert obj2.value == 73 + assert not hasattr(obj, "value") + assert obj3.value == 42 + + def test_slots_call_exception_func(self): + """Test Slots.__call__ with exception raised by function""" + slots = Slots() + + event = Event("test_slots_call_exception") + obj = Obj() + obj2 = Obj() + obj3 = Obj() + event.error_event.connect(obj2.error_handler) + slots.add(None, None, func) # success + slots.add(obj3.method, None, None) # success + slots.add(None, None, error_func) # fail + # + slots(event) + # on error set obj2.value + assert obj2.value == 73 + # on error obj.value not set + assert not hasattr(obj, "value") + assert obj3.value == 42 + + def test_slots_call_exception_logger(self): + """Test Slots.__call__ with exception and Event.logger""" + slots = Slots() + + event = Event("test_slots_call_exception_logger") + obj = Obj() + obj2 = Obj() + slots.add(None, None, func) # success + slots.add(obj2.method, None, None) # success + slots.add(obj.error, None, None) # fail -> log + slots(event) + assert not hasattr(obj, "value") + assert obj2.value == 42 diff --git a/tests/transform_test.py b/tests/transform_test.py index 4711979..8b7ffae 100644 --- a/tests/transform_test.py +++ b/tests/transform_test.py @@ -6,9 +6,6 @@ from eventkit import Event -loop = asyncio.get_event_loop_policy().get_event_loop() -loop.set_debug(True) - array = list(range(20))