8 Commits

Author SHA1 Message Date
e0e763170f small fixes to type annotations
All checks were successful
CI / build (push) Successful in 18s
2024-11-01 09:21:01 +08:00
40791a9c6e updated dependencies
All checks were successful
CI / build (push) Successful in 15s
added topic class
2024-10-24 03:18:20 +08:00
4bddc35633 fixed bug in Maybe.value
All checks were successful
CI / build (push) Successful in 11s
2024-09-10 00:04:30 +08:00
e89e306e96 improved @tmpdir decorator
All checks were successful
CI / build (push) Successful in 12s
2024-06-24 21:24:48 +08:00
436bd737fa added decorator_with_kwargs
All checks were successful
CI / build (push) Successful in 12s
2024-06-24 21:18:08 +08:00
29dd81159c added tmpdir
All checks were successful
CI / build (push) Successful in 12s
2024-06-24 20:35:38 +08:00
07e511ae2c exported Maybe
All checks were successful
CI / build (push) Successful in 13s
2024-06-24 20:22:41 +08:00
8cf62ff3d2 added __or__ operator to pwo.maybe.Maybe 2024-06-24 20:21:48 +08:00
9 changed files with 470 additions and 86 deletions

View File

@@ -1,25 +1,29 @@
name: CI name: CI
on: on:
push: push:
branches: [ master ] tags:
- '*'
jobs: jobs:
build: build:
runs-on: woryzen runs-on: woryzen
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
cache: 'pip' cache: 'pip'
- name: Create virtualenv - name: Create virtualenv
run: | run: |
python -m venv .venv python -m venv .venv
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements-dev.txt
- name: Run unit tests - name: Run unit tests
run: .venv/bin/python -m unittest discover -s tests run: .venv/bin/python -m unittest discover -s tests
- name: Execute build - name: Execute build
run: | run: |
.venv/bin/python -m build .venv/bin/pyproject-build
- name: Publish artifacts - name: Publish artifacts
env: env:
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }} TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}

5
.gitignore vendored
View File

@@ -1,7 +1,8 @@
dist
.idea .idea
__pycache__ __pycache__
.venv .venv
*.pyc *.pyc
*.egg-info *.egg-info
/build
/dist
src/pwo/_version.py

View File

@@ -1,10 +1,10 @@
[build-system] [build-system]
requires = ["setuptools>=61.0"] requires = ["setuptools>=61.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "pwo" name = "pwo"
version = "0.0.2" dynamic = ["version"]
authors = [ authors = [
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" }, { name="Walter Oggioni", email="oggioni.walter@gmail.com" },
] ]
@@ -17,7 +17,12 @@ classifiers = [
"Operating System :: OS Independent", "Operating System :: OS Independent",
] ]
dependencies = [ dependencies = [
'typing_extensions==4.7.1' 'typing_extensions'
]
[project.optional-dependencies]
dev = [
"pip-tools", "build", "mypy", "ipdb", "twine"
] ]
[project.urls] [project.urls]
@@ -35,4 +40,7 @@ no_implicit_optional = true
warn_return_any = true warn_return_any = true
warn_unused_ignores = true warn_unused_ignores = true
exclude = ["scripts", "docs", "test"] exclude = ["scripts", "docs", "test"]
strict = true strict = true
[tool.setuptools_scm]
version_file = "src/pwo/_version.py"

140
requirements-dev.txt Normal file
View 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

View File

@@ -1,34 +1,11 @@
build==1.2.1 #
certifi==2024.6.2 # This file is autogenerated by pip-compile with Python 3.12
cffi==1.16.0 # by the following command:
charset-normalizer==3.3.2 #
cryptography==42.0.8 # pip-compile --output-file=requirements.txt pyproject.toml
docutils==0.21.2 #
idna==3.7 --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
importlib_metadata==7.2.0 --extra-index-url https://pypi.org/simple
jaraco.classes==3.4.0
jaraco.context==5.3.0 typing-extensions==4.12.2
jaraco.functools==4.0.1 # via pwo (pyproject.toml)
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

View File

@@ -1,9 +1,28 @@
from .private import format_filesize, async_retry, retry, async_test, ExceptionHandlerOutcome from .private import (
format_filesize,
async_retry,
retry,
async_test,
ExceptionHandlerOutcome,
tmpdir,
decorator_with_kwargs,
classproperty,
AsyncQueueIterator
)
from .maybe import Maybe
from .notification import TopicManager, Subscriber
__all__ = [ __all__ = [
'format_filesize', 'format_filesize',
'async_retry', 'async_retry',
'retry', 'retry',
'async_test', 'async_test',
'ExceptionHandlerOutcome' 'ExceptionHandlerOutcome',
'Maybe',
'tmpdir',
'decorator_with_kwargs',
'classproperty',
'TopicManager',
'Subscriber',
'AsyncQueueIterator'
] ]

View File

@@ -24,11 +24,10 @@ class Maybe(Generic[T]):
@property @property
def value(self) -> T: def value(self) -> T:
value = self._value if self.is_empty:
if not value:
raise ValueError('Empty Maybe') raise ValueError('Empty Maybe')
else: else:
return value return self._value
@property @property
def is_present(self) -> bool: def is_present(self) -> bool:
@@ -52,6 +51,9 @@ class Maybe(Generic[T]):
def flat_map(self, transformer: Callable[[T], Maybe[U]]) -> Maybe[U]: def flat_map(self, transformer: Callable[[T], Maybe[U]]) -> Maybe[U]:
return transformer(self.value) if self.is_present else Maybe.empty() return transformer(self.value) if self.is_present else Maybe.empty()
def __or__(self, alt: Maybe[T]) -> Maybe[T]:
return self if self.is_present else alt
def or_else(self, alt: T) -> T: def or_else(self, alt: T) -> T:
return self.value if self.is_present else alt return self.value if self.is_present else alt
@@ -64,7 +66,7 @@ class Maybe(Generic[T]):
else: else:
raise supplier() raise supplier()
def or_else_get(self, supplier: Callable[[], T]) -> Maybe[T]: def or_else_get(self, supplier: Callable[[], Optional[T]]) -> Maybe[T]:
return self if self.is_present else Maybe.of_nullable(supplier()) return self if self.is_present else Maybe.of_nullable(supplier())
def if_present(self, callback: Callable[[T], U]) -> None: def if_present(self, callback: Callable[[T], U]) -> None:

91
src/pwo/notification.py Normal file
View 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 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)

View File

@@ -1,8 +1,78 @@
import math import math
from asyncio import sleep as async_sleep, Runner, Queue
from enum import Enum, auto from enum import Enum, auto
from typing import Callable from functools import wraps, partial
from inspect import signature
from pathlib import Path
from tempfile import TemporaryDirectory
from time import sleep from time import sleep
from asyncio import sleep as async_sleep, Runner from typing import Callable, AsyncIterator
def decorator_with_kwargs(decorator: Callable) -> Callable:
"""Decorator factory to give decorated decorators the skill to receive
optional keyword arguments.
If a decorator "some_decorator" is decorated with this function:
@decorator_with_kwargs
def some_decorator(decorated_function, kwarg1=1, kwarg2=2):
def wrapper(*decorated_function_args, **decorated_function_kwargs):
'''Modifies the behavior of decorated_function according
to the value of kwarg1 and kwarg2'''
...
return wrapper
It will be usable in the following ways:
@some_decorator
def func(x):
...
@some_decorator()
def func(x):
...
@some_decorator(kwarg1=3) # or other combinations of kwargs
def func(x, y):
...
:param decorator: decorator to be given optional kwargs-handling skills
:type decorator: Callable
:raises TypeError: if the decorator does not receive a single Callable or
keyword arguments
:raises TypeError: if the signature of the decorated decorator does not
conform to: Callable, **keyword_arguments
:return: modified decorator
:rtype: Callable
"""
@wraps(decorator)
def decorator_wrapper(*args, **kwargs):
if (len(kwargs) == 0) and (len(args) == 1) and callable(args[0]):
return decorator(args[0])
if len(args) == 0:
return partial(decorator, **kwargs)
raise TypeError(
f'{decorator.__name__} expects either a single Callable '
'or keyword arguments'
)
signature_values = signature(decorator).parameters.values()
signature_args = [
param.name for param in signature_values
if param.default == param.empty
]
if len(signature_args) != 1:
raise TypeError(
f'{decorator.__name__} signature should be of the form:\n'
f'{decorator.__name__}(function: typing.Callable, '
'kwarg_1=default_1, kwarg_2=default_2, ...) -> Callable'
)
return decorator_wrapper
_size_uoms = ('B', 'KiB', 'MiB', 'GiB', 'KiB') _size_uoms = ('B', 'KiB', 'MiB', 'GiB', 'KiB')
@@ -17,66 +87,138 @@ def format_filesize(size: int) -> str:
return '%.2f ' % (size / math.pow(1024, counter)) + _size_uoms[counter] return '%.2f ' % (size / math.pow(1024, counter)) + _size_uoms[counter]
class ExceptionHandlerOutcome(Enum): class ExceptionHandlerOutcome(Enum):
THROW = auto() THROW = auto()
CONTINUE = auto() CONTINUE = auto()
@decorator_with_kwargs
def retry( def retry(
function,
max_attempts: int = 3, max_attempts: int = 3,
multiplier: float = 2, multiplier: float = 2,
initial_delay: float = 1.0, initial_delay: float = 1.0,
exception_handler: Callable[[Exception], ExceptionHandlerOutcome] = exception_handler: Callable[[Exception], ExceptionHandlerOutcome] =
lambda _: ExceptionHandlerOutcome.CONTINUE lambda _: ExceptionHandlerOutcome.CONTINUE
): ):
def wrapper(function): @wraps(function)
def result(*args, **kwargs): def result(*args, **kwargs):
attempts = 0 attempts = 0
delay = initial_delay delay = initial_delay
while True: while True:
attempts += 1 attempts += 1
try: try:
return function(*args, **kwargs) return function(*args, **kwargs)
except Exception as ex: except Exception as ex:
if attempts < max_attempts and exception_handler(ex) == ExceptionHandlerOutcome.CONTINUE: if attempts < max_attempts and exception_handler(ex) == ExceptionHandlerOutcome.CONTINUE:
sleep(delay) sleep(delay)
delay *= multiplier delay *= multiplier
else: else:
raise ex raise ex
return result return result
return wrapper
@decorator_with_kwargs
def async_retry( def async_retry(
function,
max_attempts: int = 3, max_attempts: int = 3,
multiplier: float = 2, multiplier: float = 2,
initial_delay: float = 1.0, initial_delay: float = 1.0,
exception_handler=lambda _: ExceptionHandlerOutcome.CONTINUE exception_handler=lambda _: ExceptionHandlerOutcome.CONTINUE
): ):
def wrapper(function): @wraps(function)
async def result(*args, **kwargs): async def result(*args, **kwargs):
attempts = 0 attempts = 0
delay = initial_delay delay = initial_delay
while True: while True:
attempts += 1 attempts += 1
try: try:
return await function(*args, **kwargs) return await function(*args, **kwargs)
except Exception as ex: except Exception as ex:
if attempts < max_attempts and exception_handler(ex) == ExceptionHandlerOutcome.CONTINUE: if attempts < max_attempts and exception_handler(ex) == ExceptionHandlerOutcome.CONTINUE:
await async_sleep(delay) await async_sleep(delay)
delay *= multiplier delay *= multiplier
else: else:
raise ex raise ex
return result return result
return wrapper
def async_test(coro): def async_test(coro):
@wraps(coro)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
with Runner() as runner: with Runner() as runner:
runner.run(coro(*args, **kwargs)) runner.run(coro(*args, **kwargs))
return wrapper
return wrapper
@decorator_with_kwargs
def tmpdir(f,
argument_name='temp_dir',
suffix=None,
prefix=None,
dir=None,
ignore_cleanup_errors=False,
delete=True):
@wraps(f)
def result(*args, **kwargs):
with TemporaryDirectory(
suffix=suffix,
prefix=prefix,
dir=dir,
ignore_cleanup_errors=ignore_cleanup_errors,
delete=delete) as temp_dir:
f(*args, **kwargs, **{
argument_name: Path(temp_dir)
})
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[T]:
_queue: Queue[T]
def __init__(self, queue: Queue[T]):
self._queue = queue
def __aiter__(self) -> AsyncIterator[T]:
return self
async def __anext__(self) -> [T]:
item = await self._queue.get()
if item is None:
raise StopAsyncIteration
return item