diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index d7ee4fc..2be5758 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -1,25 +1,29 @@ name: CI on: push: - branches: [ master ] + tags: + - '*' jobs: build: runs-on: woryzen steps: - name: Checkout sources uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - uses: actions/setup-python@v5 with: cache: 'pip' - name: Create virtualenv run: | python -m venv .venv - .venv/bin/pip install -r requirements.txt + .venv/bin/pip install -r requirements-dev.txt - name: Run unit tests run: .venv/bin/python -m unittest discover -s tests - name: Execute build run: | - .venv/bin/python -m build + .venv/bin/pyproject-build - name: Publish artifacts env: TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} diff --git a/.gitignore b/.gitignore index b228f96..2cc8145 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -dist .idea __pycache__ .venv *.pyc *.egg-info - +/build +/dist +src/pwo/_version.py diff --git a/pyproject.toml b/pyproject.toml index 7421a56..2ea8929 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "pwo" -version = "0.0.3" +dynamic = ["version"] authors = [ { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, ] @@ -17,7 +17,12 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - 'typing_extensions==4.7.1' + 'typing_extensions' +] + +[project.optional-dependencies] +dev = [ + "pip-tools", "build", "mypy", "ipdb", "twine" ] [project.urls] @@ -35,4 +40,7 @@ no_implicit_optional = true warn_return_any = true warn_unused_ignores = true exclude = ["scripts", "docs", "test"] -strict = true \ No newline at end of file +strict = true + +[tool.setuptools_scm] +version_file = "src/pwo/_version.py" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..aa25c2f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,140 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --extra=dev --output-file=requirements-dev.txt pyproject.toml +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +asttokens==2.4.1 + # via stack-data +build==1.2.2.post1 + # via + # pip-tools + # pwo (pyproject.toml) +certifi==2024.8.30 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.0 + # via requests +click==8.1.7 + # via pip-tools +cryptography==43.0.3 + # via secretstorage +decorator==5.1.1 + # via + # ipdb + # ipython +docutils==0.21.2 + # via readme-renderer +executing==2.1.0 + # via stack-data +idna==3.10 + # via requests +importlib-metadata==8.5.0 + # via twine +ipdb==0.13.13 + # via pwo (pyproject.toml) +ipython==8.28.0 + # via ipdb +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.0.1 + # via keyring +jaraco-functools==4.1.0 + # via keyring +jedi==0.19.1 + # via ipython +jeepney==0.8.0 + # via + # keyring + # secretstorage +keyring==25.4.1 + # via twine +markdown-it-py==3.0.0 + # via rich +matplotlib-inline==0.1.7 + # via ipython +mdurl==0.1.2 + # via markdown-it-py +more-itertools==10.5.0 + # via + # jaraco-classes + # jaraco-functools +mypy==1.13.0 + # via pwo (pyproject.toml) +mypy-extensions==1.0.0 + # via mypy +nh3==0.2.18 + # via readme-renderer +packaging==24.1 + # via build +parso==0.8.4 + # via jedi +pexpect==4.9.0 + # via ipython +pip-tools==7.4.1 + # via pwo (pyproject.toml) +pkginfo==1.10.0 + # via twine +prompt-toolkit==3.0.48 + # via ipython +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 + # via stack-data +pycparser==2.22 + # via cffi +pygments==2.18.0 + # via + # ipython + # readme-renderer + # rich +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +readme-renderer==44.0 + # via twine +requests==2.32.3 + # via + # requests-toolbelt + # twine +requests-toolbelt==1.0.0 + # via twine +rfc3986==2.0.0 + # via twine +rich==13.9.3 + # via twine +secretstorage==3.3.3 + # via keyring +six==1.16.0 + # via asttokens +stack-data==0.6.3 + # via ipython +traitlets==5.14.3 + # via + # ipython + # matplotlib-inline +twine==5.1.1 + # via pwo (pyproject.toml) +typing-extensions==4.12.2 + # via + # mypy + # pwo (pyproject.toml) +urllib3==2.2.3 + # via + # requests + # twine +wcwidth==0.2.13 + # via prompt-toolkit +wheel==0.44.0 + # via pip-tools +zipp==3.20.2 + # via importlib-metadata + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements.txt b/requirements.txt index b39a50b..e34638a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,34 +1,11 @@ -build==1.2.1 -certifi==2024.6.2 -cffi==1.16.0 -charset-normalizer==3.3.2 -cryptography==42.0.8 -docutils==0.21.2 -idna==3.7 -importlib_metadata==7.2.0 -jaraco.classes==3.4.0 -jaraco.context==5.3.0 -jaraco.functools==4.0.1 -jeepney==0.8.0 -keyring==25.2.1 -markdown-it-py==3.0.0 -mdurl==0.1.2 -more-itertools==10.3.0 -mypy==1.10.0 -mypy-extensions==1.0.0 -nh3==0.2.17 -packaging==24.1 -pkginfo==1.11.1 -pycparser==2.22 -Pygments==2.18.0 -pyproject_hooks==1.1.0 -readme_renderer==43.0 -requests==2.32.3 -requests-toolbelt==1.0.0 -rfc3986==2.0.0 -rich==13.7.1 -SecretStorage==3.3.3 -twine==5.1.0 -typing_extensions==4.12.2 -urllib3==2.2.2 -zipp==3.19.2 +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=requirements.txt pyproject.toml +# +--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple +--extra-index-url https://pypi.org/simple + +typing-extensions==4.12.2 + # via pwo (pyproject.toml) diff --git a/src/pwo/__init__.py b/src/pwo/__init__.py index b0e772b..7eaad4a 100644 --- a/src/pwo/__init__.py +++ b/src/pwo/__init__.py @@ -5,10 +5,11 @@ from .private import ( async_test, ExceptionHandlerOutcome, tmpdir, - decorator_with_kwargs + decorator_with_kwargs, + classproperty ) from .maybe import Maybe - +from .notification import TopicManager, Subscriber __all__ = [ 'format_filesize', 'async_retry', @@ -17,5 +18,8 @@ __all__ = [ 'ExceptionHandlerOutcome', 'Maybe', 'tmpdir', - 'decorator_with_kwargs' + 'decorator_with_kwargs', + 'classproperty', + 'TopicManager', + 'Subscriber' ] diff --git a/src/pwo/notification.py b/src/pwo/notification.py new file mode 100644 index 0000000..605a708 --- /dev/null +++ b/src/pwo/notification.py @@ -0,0 +1,91 @@ +from .private import AsyncQueueIterator +from asyncio import Queue, AbstractEventLoop, Future, CancelledError +from typing import Callable, Optional +from logging import getLogger + +log = getLogger(__name__) + + +class Subscriber: + _unsubscribe_callback: Callable[['Subscriber'], None] + _event: Optional[Future] + _loop: AbstractEventLoop + + def __init__(self, unsubscribe: Callable[['Subscriber'], None], loop: AbstractEventLoop): + self._unsubscribe_callback = unsubscribe + self._event: Optional[Future] = None + self._loop = loop + + def unsubscribe(self) -> None: + self._event.cancel() + self._unsubscribe_callback(self) + log.debug('Deleted subscriber %s', id(self)) + + async def wait(self, tout: float) -> bool: + self._event = self._loop.create_future() + + def callback(): + if not self._event.done(): + self._event.set_result(False) + + handle = self._loop.call_later(tout, callback) + try: + log.debug('Subscriber %s is waiting for an event', id(self)) + return await self._event + except CancelledError: + return False + finally: + handle.cancel() + + def notify(self) -> None: + log.debug('Subscriber %s notified', id(self)) + if not self._event.done(): + self._event.set_result(True) + + def reset(self) -> None: + self._event = self._loop.create_future() + + +class TopicManager: + _loop: AbstractEventLoop + _queue: Queue + _subscribers: dict[str, set[Subscriber]] + + def __init__(self, loop: AbstractEventLoop): + self._subscribers: dict[str, set[Subscriber]] = dict() + self._loop = loop + self._queue = Queue() + + def subscribe(self, topic: str) -> Subscriber: + subscriptions = self._subscribers + subscriptions_per_topic = subscriptions.setdefault(topic, set()) + + def unsubscribe_callback(subscription): + subscriptions_per_topic.remove(subscription) + log.debug('Unsubscribed %s from topic %s', id(result), topic) + + result = Subscriber(unsubscribe_callback, self._loop) + log.debug('Created subscriber %s to topic %s', id(result), topic) + subscriptions_per_topic.add(result) + return result + + def _notify_subscriptions(self, topic): + subscriptions = self._subscribers + subscriptions_per_topic = subscriptions.get(topic, None) + if subscriptions_per_topic: + log.debug(f"Subscribers on '{topic}': {len(subscriptions_per_topic)}") + for s in subscriptions_per_topic: + s.notify() + + async def process_events(self): + async for evt in AsyncQueueIterator(self._queue): + log.debug(f"Processed event for topic '{evt}'") + self._notify_subscriptions(evt) + log.debug(f"Event processor has completed") + + def post_event(self, topic): + def callback(): + self._queue.put_nowait(topic) + log.debug(f"Posted event for topic '{topic}', queue size: {self._queue.qsize()}") + + self._loop.call_soon_threadsafe(callback) diff --git a/src/pwo/private.py b/src/pwo/private.py index 5209414..210b772 100644 --- a/src/pwo/private.py +++ b/src/pwo/private.py @@ -1,12 +1,12 @@ import math -from tempfile import TemporaryDirectory -from pathlib import Path +from asyncio import sleep as async_sleep, Runner, Queue from enum import Enum, auto -from typing import Callable -from inspect import signature -from time import sleep -from asyncio import sleep as async_sleep, Runner from functools import wraps, partial +from inspect import signature +from pathlib import Path +from tempfile import TemporaryDirectory +from time import sleep +from typing import Callable def decorator_with_kwargs(decorator: Callable) -> Callable: @@ -162,7 +162,6 @@ def tmpdir(f, dir=None, ignore_cleanup_errors=False, delete=True): - @wraps(f) def result(*args, **kwargs): with TemporaryDirectory( @@ -176,3 +175,50 @@ def tmpdir(f, }) return result + + +class ClassPropertyDescriptor: + + def __init__(self, fget, fset=None): + self.fget = fget + self.fset = fset + + def __get__(self, obj, klass=None): + if klass is None: + klass = type(obj) + return self.fget.__get__(obj, klass)() + + def __set__(self, obj, value): + if not self.fset: + raise AttributeError("can't set attribute") + type_ = type(obj) + return self.fset.__get__(obj, type_)(value) + + def setter(self, func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + self.fset = func + return self + + +def classproperty(func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + return ClassPropertyDescriptor(func) + + +class AsyncQueueIterator: + _queue: Queue + + def __init__(self, queue: Queue): + self._queue = queue + + def __aiter__(self): + return self + + async def __anext__(self): + item = await self._queue.get() + if item is None: + raise StopAsyncIteration + return item +