added topic class
This commit is contained in:
@@ -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 }}
|
||||
|
@@ -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]
|
||||
|
140
requirements-dev.txt
Normal file
140
requirements-dev.txt
Normal file
@@ -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
|
@@ -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)
|
||||
|
@@ -5,7 +5,8 @@ from .private import (
|
||||
async_test,
|
||||
ExceptionHandlerOutcome,
|
||||
tmpdir,
|
||||
decorator_with_kwargs
|
||||
decorator_with_kwargs,
|
||||
classproperty
|
||||
)
|
||||
from .maybe import Maybe
|
||||
|
||||
@@ -17,5 +18,6 @@ __all__ = [
|
||||
'ExceptionHandlerOutcome',
|
||||
'Maybe',
|
||||
'tmpdir',
|
||||
'decorator_with_kwargs'
|
||||
'decorator_with_kwargs',
|
||||
'classproperty'
|
||||
]
|
||||
|
@@ -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
|
||||
|
||||
|
91
src/pwo/topic.py
Normal file
91
src/pwo/topic.py
Normal file
@@ -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 Topic:
|
||||
_loop: AbstractEventLoop
|
||||
_queue: Queue
|
||||
_subscriptions: dict[str, set[Subscriber]]
|
||||
|
||||
def __init__(self, loop: AbstractEventLoop):
|
||||
self._subscriptions: dict[str, set[Subscriber]] = dict()
|
||||
self._loop = loop
|
||||
self._queue = Queue()
|
||||
|
||||
def subscribe(self, path: str) -> Subscriber:
|
||||
subscriptions = self._subscriptions
|
||||
subscriptions_per_path = subscriptions.setdefault(path, set())
|
||||
|
||||
def unsubscribe_callback(subscription):
|
||||
subscriptions_per_path.remove(subscription)
|
||||
log.debug('Unsubscribed %s from topic %s', id(result), path)
|
||||
|
||||
result = Subscriber(unsubscribe_callback, self._loop)
|
||||
log.debug('Created subscriber %s to topic %s', id(result), path)
|
||||
subscriptions_per_path.add(result)
|
||||
return result
|
||||
|
||||
def _notify_subscriptions(self, path):
|
||||
subscriptions = self._subscriptions
|
||||
subscriptions_per_path = subscriptions.get(path, None)
|
||||
if subscriptions_per_path:
|
||||
log.debug(f"Subscribers on '{path}': {len(subscriptions_per_path)}")
|
||||
for s in subscriptions_per_path:
|
||||
s.notify()
|
||||
|
||||
async def process_events(self):
|
||||
async for evt in AsyncQueueIterator(self._queue):
|
||||
log.debug(f"Processed event for path '{evt}'")
|
||||
self._notify_subscriptions(evt)
|
||||
log.debug(f"Event processor has completed")
|
||||
|
||||
def post_event(self, path):
|
||||
def callback():
|
||||
self._queue.put_nowait(path)
|
||||
log.debug(f"Posted event for topic '{path}', queue size: {self._queue.qsize()}")
|
||||
|
||||
self._loop.call_soon_threadsafe(callback)
|
Reference in New Issue
Block a user