6 Commits

Author SHA1 Message Date
a417c7484b fixed mypy
All checks were successful
CI / build (push) Successful in 42s
2024-12-01 15:38:54 +08:00
04aae1f976 switched builder to hostinger
Some checks failed
CI / build (push) Failing after 40s
2024-12-01 15:29:34 +08:00
79246f70c4 improved index_of_with_escape
Some checks are pending
CI / build (push) Waiting to run
2024-11-18 09:00:23 +08:00
36f7031fea added index_of_with_escape function 2024-11-11 06:06:22 +08:00
37591f78d9 simplified notification code 2024-11-03 23:02:01 +08:00
6ef7f8107e added Try
All checks were successful
CI / build (push) Successful in 18s
2024-11-03 22:42:41 +08:00
10 changed files with 407 additions and 199 deletions

View File

@@ -5,7 +5,7 @@ on:
- '*'
jobs:
build:
runs-on: woryzen
runs-on: hostinger
steps:
- name: Checkout sources
uses: actions/checkout@v4
@@ -18,7 +18,9 @@ jobs:
- name: Create virtualenv
run: |
python -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/pip install -r requirements.txt .
- name: Run mypy
run: .venv/bin/python -m mypy -p src
- name: Run unit tests
run: .venv/bin/python -m unittest discover -s tests
- name: Execute build

View File

@@ -1,140 +0,0 @@
#
# 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

@@ -2,10 +2,139 @@
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --output-file=requirements.txt pyproject.toml
# pip-compile --extra=dev --output-file=requirements.txt
#
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple
typing-extensions==4.12.2
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.29.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.5.0
# 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.4
# 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

@@ -7,10 +7,13 @@ from .private import (
tmpdir,
decorator_with_kwargs,
classproperty,
AsyncQueueIterator
AsyncQueueIterator,
aenumerate,
index_of_with_escape
)
from .maybe import Maybe
from .notification import TopicManager, Subscriber
from ._try import Try
__all__ = [
'format_filesize',
@@ -24,5 +27,8 @@ __all__ = [
'classproperty',
'TopicManager',
'Subscriber',
'AsyncQueueIterator'
'AsyncQueueIterator',
'aenumerate',
'Try',
'index_of_with_escape'
]

52
src/pwo/_try.py Normal file
View File

@@ -0,0 +1,52 @@
from typing import (
Callable,
TypeVar,
Optional
)
ERR = TypeVar("ERR", bound=Exception)
class Try[T]:
value: T | Exception
def __init__(self, value: T | Exception):
self.value = value
def handle[U](self, cb: Callable[[Optional[T], Optional[Exception]], U]) -> 'Try[U]':
value = self.value
if isinstance(value, Exception):
return Try.of(lambda: cb(None, value))
else:
return Try.of(lambda: cb(value, None))
def get(self, alternative: Optional[T] = None) -> T:
if isinstance(self.value, Exception):
if alternative is None:
raise self.value
else:
return alternative
else:
return self.value
def then_try[U](self, cb: Callable[[T], U]) -> 'Try[U]':
value = self.value
if isinstance(value, Exception):
return Try.failure(value)
else:
return Try.of(lambda: cb(value))
@staticmethod
def success[U](value: U) -> 'Try[U]':
return Try(value)
@staticmethod
def failure[U](ex: Exception) -> 'Try[U]':
return Try(ex)
@staticmethod
def of[U](cb: Callable[[], U]) -> 'Try[U]':
try:
return Try(cb())
except Exception as ex:
return Try(ex)

View File

@@ -24,10 +24,11 @@ class Maybe(Generic[T]):
@property
def value(self) -> T:
if self.is_empty:
result = self._value
if result is None:
raise ValueError('Empty Maybe')
else:
return self._value
return result
@property
def is_present(self) -> bool:

View File

@@ -1,5 +1,5 @@
from .private import AsyncQueueIterator
from asyncio import Queue, AbstractEventLoop, Future, CancelledError
from asyncio import Queue, AbstractEventLoop, Future, CancelledError, timeout
from typing import Callable, Optional
from logging import getLogger
@@ -8,39 +8,39 @@ log = getLogger(__name__)
class Subscriber:
_unsubscribe_callback: Callable[['Subscriber'], None]
_event: Optional[Future]
_event: Optional[Future[bool]]
_loop: AbstractEventLoop
def __init__(self, unsubscribe: Callable[['Subscriber'], None], loop: AbstractEventLoop):
self._unsubscribe_callback = unsubscribe
self._event: Optional[Future] = None
self._event: Optional[Future[bool]] = None
self._loop = loop
def unsubscribe(self) -> None:
self._event.cancel()
evt = self._event
if evt is not None:
evt.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()
async def wait(self, tout: Optional[float]) -> bool:
def callback():
if not self._event.done():
self._event.set_result(False)
handle = self._loop.call_later(tout, callback)
future: Future[bool] = self._loop.create_future()
self._event = future
try:
log.debug('Subscriber %s is waiting for an event', id(self))
return await self._event
except CancelledError:
async with timeout(tout):
log.debug('Subscriber %s is waiting for an event', id(self))
return await future
except TimeoutError:
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)
evt = self._event
if evt is None:
raise ValueError('Event is None')
if not evt.done():
evt.set_result(True)
def reset(self) -> None:
self._event = self._loop.create_future()
@@ -48,7 +48,7 @@ class Subscriber:
class TopicManager:
_loop: AbstractEventLoop
_queue: Queue
_queue: Queue[Optional[str]]
_subscribers: dict[str, set[Subscriber]]
def __init__(self, loop: AbstractEventLoop):
@@ -60,7 +60,7 @@ class TopicManager:
subscriptions = self._subscribers
subscriptions_per_topic = subscriptions.setdefault(topic, set())
def unsubscribe_callback(subscription):
def unsubscribe_callback(subscription: Subscriber) -> None:
subscriptions_per_topic.remove(subscription)
log.debug('Unsubscribed %s from topic %s', id(result), topic)
@@ -69,7 +69,7 @@ class TopicManager:
subscriptions_per_topic.add(result)
return result
def _notify_subscriptions(self, topic):
def _notify_subscriptions(self, topic: str) -> None:
subscriptions = self._subscribers
subscriptions_per_topic = subscriptions.get(topic, None)
if subscriptions_per_topic:
@@ -77,14 +77,14 @@ class TopicManager:
for s in subscriptions_per_topic:
s.notify()
async def process_events(self):
async def process_events(self) -> None:
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():
def post_event(self, topic: str) -> None:
def callback() -> None:
self._queue.put_nowait(topic)
log.debug(f"Posted event for topic '{topic}', queue size: {self._queue.qsize()}")

View File

@@ -6,10 +6,21 @@ from inspect import signature
from pathlib import Path
from tempfile import TemporaryDirectory
from time import sleep
from typing import Callable, AsyncIterator
from typing import (
Callable,
AsyncIterator,
Optional,
Self,
AsyncIterable,
Awaitable,
Any,
Coroutine,
Never,
Tuple
)
def decorator_with_kwargs(decorator: Callable) -> Callable:
def decorator_with_kwargs(decorator: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator factory to give decorated decorators the skill to receive
optional keyword arguments.
@@ -48,7 +59,7 @@ def decorator_with_kwargs(decorator: Callable) -> Callable:
"""
@wraps(decorator)
def decorator_wrapper(*args, **kwargs):
def decorator_wrapper(*args, **kwargs) -> Any: # type: ignore
if (len(kwargs) == 0) and (len(args) == 1) and callable(args[0]):
return decorator(args[0])
if len(args) == 0:
@@ -94,15 +105,15 @@ class ExceptionHandlerOutcome(Enum):
@decorator_with_kwargs
def retry(
function,
function: Callable[..., Any],
max_attempts: int = 3,
multiplier: float = 2,
initial_delay: float = 1.0,
exception_handler: Callable[[Exception], ExceptionHandlerOutcome] =
lambda _: ExceptionHandlerOutcome.CONTINUE
):
) -> Callable[..., Any]:
@wraps(function)
def result(*args, **kwargs):
def result(*args: Any, **kwargs: Any) -> Any:
attempts = 0
delay = initial_delay
while True:
@@ -121,14 +132,14 @@ def retry(
@decorator_with_kwargs
def async_retry(
function,
function: Callable[..., Any],
max_attempts: int = 3,
multiplier: float = 2,
initial_delay: float = 1.0,
exception_handler=lambda _: ExceptionHandlerOutcome.CONTINUE
):
exception_handler: Callable[[Exception], ExceptionHandlerOutcome] = lambda _: ExceptionHandlerOutcome.CONTINUE
) -> Callable[..., Any]:
@wraps(function)
async def result(*args, **kwargs):
async def result(*args: Any, **kwargs: Any) -> Any:
attempts = 0
delay = initial_delay
while True:
@@ -145,16 +156,16 @@ def async_retry(
return result
def async_test(coro):
def async_test(coro: Callable[..., Coroutine[Never, Never, None]]) -> Callable[..., None]:
@wraps(coro)
def wrapper(*args, **kwargs):
def wrapper(*args: Any, **kwargs: Any) -> None:
with Runner() as runner:
runner.run(coro(*args, **kwargs))
return wrapper
@decorator_with_kwargs
@decorator_with_kwargs # type: ignore
def tmpdir(f,
argument_name='temp_dir',
suffix=None,
@@ -162,7 +173,7 @@ def tmpdir(f,
dir=None,
ignore_cleanup_errors=False,
delete=True):
@wraps(f)
@wraps(f) # type: ignore
def result(*args, **kwargs):
with TemporaryDirectory(
suffix=suffix,
@@ -177,48 +188,98 @@ def tmpdir(f,
return result
class ClassPropertyDescriptor:
class ClassPropertyDescriptor[T]:
def __init__(self, fget, fset=None):
def __init__(self, fget: Callable[[], T], fset: Optional[Callable[[T], None]]=None):
self.fget = fget
self.fset = fset
def __get__(self, obj, klass=None):
def __get__(self, obj, klass=None): # type: ignore
if klass is None:
klass = type(obj)
return self.fget.__get__(obj, klass)()
def __set__(self, obj, value):
def __set__(self, obj, value): # type: ignore
if not self.fset:
raise AttributeError("can't set attribute")
type_ = type(obj)
return self.fset.__get__(obj, type_)(value)
def setter(self, func):
def setter(self, func): # type: ignore
if not isinstance(func, (classmethod, staticmethod)):
func = classmethod(func)
self.fset = func
return self
def classproperty(func):
def classproperty(func): # type: ignore
if not isinstance(func, (classmethod, staticmethod)):
func = classmethod(func)
return ClassPropertyDescriptor(func)
class AsyncQueueIterator[T]:
_queue: Queue[T]
class AsyncQueueIterator[T](AsyncIterator[T]):
_queue: Queue[Optional[T]]
def __init__(self, queue: Queue[T]):
def __init__(self, queue: Queue[Optional[T]]):
self._queue = queue
def __aiter__(self) -> AsyncIterator[T]:
return self
async def __anext__(self) -> [T]:
async def __anext__(self) -> T:
item = await self._queue.get()
if item is None:
raise StopAsyncIteration
return item
class aenumerate[T](AsyncIterator[Tuple[int, T]]):
"""enumerate for async for"""
_aiterable: AsyncIterable[T]
_i: int
def __init__(self, aiterable: AsyncIterable[T], start: int = 0):
self._aiterable = aiterable
self._i = start - 1
def __aiter__(self) -> Self:
self._ait = self._aiterable.__aiter__()
return self
async def __anext__(self) -> Tuple[int, T]:
val = await self._ait.__anext__()
self._i += 1
return self._i, val
def index_of_with_escape(haystack: str, needle: str, escape: str, begin: int, end: int = 0) -> int:
result = -1
cursor = begin
if end == 0:
end = len(haystack)
escape_count = 0
while cursor < end:
c = haystack[cursor]
if escape_count > 0:
escape_count -= 1
if c[0] == escape:
result = -1
elif escape_count == 0:
if c[0] == escape:
escape_count += 1
if cursor + len(needle) <= len(haystack):
test = haystack[cursor:cursor + len(needle)]
if test == needle:
result = cursor
if result >= 0 and escape_count == 0:
break
cursor += 1
return result

View File

@@ -1,6 +1,7 @@
import unittest
from src.pwo import retry, async_retry, async_test
from pwo import retry, async_retry, async_test, AsyncQueueIterator, aenumerate, index_of_with_escape
from asyncio import Queue
class PrivateTest(unittest.TestCase):
@@ -70,5 +71,59 @@ class PrivateTest(unittest.TestCase):
await bar()
self.assertEqual(max_attempts, attempt)
if __name__ == '__main__':
unittest.main()
@async_test
async def test_async_queue_iterator(self):
queue = Queue()
queue_size = 10
objects = [object() for _ in range(queue_size)]
async def poll() -> int:
completed = 0
async for i, obj in aenumerate(AsyncQueueIterator(queue)):
self.assertIs(objects[i], obj)
completed += 1
return completed
handle = poll()
for o in objects:
queue.put_nowait(o)
queue.put_nowait(None)
processed = await handle
self.assertEqual(queue_size, processed)
class TestIndexOfWithEscape(unittest.TestCase):
def run_test_case(self, haystack, needle, escape, expected_solution):
solution = []
i = 0
while True:
i = index_of_with_escape(haystack, needle, escape, i, len(haystack))
if i < 0:
break
solution.append(i)
i += 1
self.assertEqual(expected_solution, solution)
def test_simple(self):
self.run_test_case(" dsds $sdsa \\$dfivbdsf \\\\$sdgsga", '$', '\\', [6, 25])
def test_simple2(self):
self.run_test_case("asdasd$$vdfv$", '$', '$', [12])
def test_no_needle(self):
self.run_test_case("asdasd$$vdfv$", '#', '\\', [])
def test_escaped_needle(self):
self.run_test_case("asdasd$$vdfv$#sdfs", '#', '$', [])
def test_not_escaped_needle(self):
self.run_test_case("asdasd$$#vdfv$#sdfs", '#', '$', [8])
def test_special_case(self):
self.run_test_case("\n${sys:user.home}${env:HOME}", ':', '\\', [6, 22])
def test_wide_needle(self):
self.run_test_case("asdasd\\${#vdfv|${#sdfs}", '${', '\\', [15])

42
tests/test_try.py Normal file
View File

@@ -0,0 +1,42 @@
import unittest
from pwo import Try
class TestException(Exception):
def __init__(self, msg: str):
super().__init__(msg)
class TryTest(unittest.TestCase):
def setUp(self):
pass
def test_try(self):
with self.subTest("Test failure"):
def throw_test_exception():
raise TestException("error")
t = Try.of(throw_test_exception)
with self.assertRaises(TestException):
t.get()
t = Try.failure(TestException("error"))
with self.assertRaises(TestException):
t.get()
with self.subTest("Test success"):
def complete_successfully():
return 42
t = Try.of(complete_successfully)
self.assertEqual(42, t.get())
t2 = t.handle(lambda value, err: value * 2)
self.assertEqual(84, t2.get())