- commit
- e8447dd8daf816df9be346b112c65e57e538fcd5
- parent
- 782851e16a4988987e94d22388efb84d2ab988ac
- Author
- Tobias Bengfort <tobias.bengfort@posteo.de>
- Date
- 2026-04-12 17:21
refactor: split into multiple files
Diffstat
| M | .github/workflows/main.yml | 6 | +++--- |
| M | pyproject.toml | 5 | +---- |
| A | tests/test_core.py | 146 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| R | tests.py -> tests/test_multiplex.py | 157 | +------------------------------------------------------------ |
| A | tests/utils.py | 28 | ++++++++++++++++++++++++++++ |
| A | xiio/__init__.py | 10 | ++++++++++ |
| R | xiio.py -> xiio/core.py | 90 | ------------------------------------------------------------ |
| A | xiio/multiplex.py | 103 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
8 files changed, 293 insertions, 252 deletions
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
@@ -11,9 +11,9 @@ jobs: 11 11 - uses: actions/checkout@v6 12 12 - uses: actions/setup-python@v6 13 13 - run: python3 -m pip install ruff ty coverage14 -1 - run: ruff check xiio.py tests.py15 -1 - run: ty check xiio.py16 -1 - run: python3 -m coverage run -m unittest-1 14 - run: ruff check xiio tests -1 15 - run: ty check xiio -1 16 - run: python3 -m coverage run -m unittest discover tests 17 17 - run: python3 -m coverage report 18 18 publish: 19 19 needs: [test]
diff --git a/pyproject.toml b/pyproject.toml
@@ -15,9 +15,6 @@ authors = [ 15 15 [project.urls] 16 16 Homepage = "https://github.com/xi/xiio" 17 1718 -1 [tool.setuptools]19 -1 py-modules = ["xiio"]20 -121 18 [tool.coverage.run] 22 19 branch = true23 -1 include = ["xiio.py"]-1 20 include = ["xiio/**"]
diff --git a/tests/test_core.py b/tests/test_core.py
@@ -0,0 +1,146 @@
-1 1 import os
-1 2 import time
-1 3 from unittest import mock
-1 4
-1 5 import xiio
-1 6 from tests.utils import XiioTestCase
-1 7 from xiio.core import READ
-1 8 from xiio.core import WRITE
-1 9 from xiio.core import Condition
-1 10
-1 11
-1 12 def interrupt_select(self):
-1 13 timeout = self.time - time.monotonic()
-1 14 if timeout > 0:
-1 15 time.sleep(timeout / 2)
-1 16 raise KeyboardInterrupt
-1 17 return {}
-1 18
-1 19
-1 20 class TestConditionCombine(XiioTestCase):
-1 21 def test_time(self):
-1 22 result = Condition.combine([
-1 23 Condition(),
-1 24 Condition(time=1),
-1 25 Condition(time=3),
-1 26 Condition(time=-2),
-1 27 ])
-1 28 self.assertEqual(result.time, -2)
-1 29
-1 30 def test_futures(self):
-1 31 f1 = xiio.Future()
-1 32 f2 = xiio.Future()
-1 33 _f3 = xiio.Future()
-1 34
-1 35 result = Condition.combine([
-1 36 Condition(),
-1 37 Condition(futures={f1}),
-1 38 Condition(futures={f1, f2}),
-1 39 ])
-1 40
-1 41 self.assertEqual(result.futures, {f1, f2})
-1 42
-1 43 def test_files(self):
-1 44 result = Condition.combine([
-1 45 Condition(),
-1 46 Condition(files={1: READ, 2: READ}),
-1 47 Condition(files={1: WRITE}),
-1 48 ])
-1 49
-1 50 self.assertEqual(result.files, {
-1 51 1: READ|WRITE,
-1 52 2: READ,
-1 53 })
-1 54
-1 55
-1 56 class TestConditionFulfilled(XiioTestCase):
-1 57 def test_files(self):
-1 58 condition = Condition(files={1: READ})
-1 59 self.assertTrue(condition.fulfilled({1: READ, 2: READ}))
-1 60
-1 61 def test_files_wrong_mode(self):
-1 62 condition = Condition(files={1: READ})
-1 63 self.assertFalse(condition.fulfilled({1: WRITE, 2: READ}))
-1 64
-1 65 def test_future_not_done(self):
-1 66 future = xiio.Future()
-1 67 condition = Condition(futures={future})
-1 68 self.assertFalse(condition.fulfilled({}))
-1 69
-1 70 def test_future_result(self):
-1 71 future = xiio.Future()
-1 72 future.set_result(1)
-1 73 condition = Condition(futures={future})
-1 74 self.assertTrue(condition.fulfilled({}))
-1 75
-1 76 def test_future_exception(self):
-1 77 future = xiio.Future()
-1 78 future.set_exception(ValueError)
-1 79 condition = Condition(futures={future})
-1 80 self.assertTrue(condition.fulfilled({}))
-1 81
-1 82
-1 83 class TestFuture(XiioTestCase):
-1 84 async def test_set_result(self):
-1 85 future = xiio.Future()
-1 86 future.set_result('test')
-1 87 result = await future
-1 88 self.assertEqual(result, 'test')
-1 89
-1 90 async def test_set_exception(self):
-1 91 future = xiio.Future()
-1 92 future.set_exception(TypeError)
-1 93 with self.assertRaises(TypeError):
-1 94 await future
-1 95
-1 96
-1 97 class TestRun(XiioTestCase):
-1 98 def test_sleep(self):
-1 99 async def foo():
-1 100 await xiio.sleep(0.1)
-1 101 return 'Hello World'
-1 102
-1 103 with self.assert_duration(0.1):
-1 104 result = xiio.run(foo())
-1 105 self.assertEqual(result, 'Hello World')
-1 106
-1 107 def test_runs_cleanup_on_error_while_paused(self):
-1 108 stack = []
-1 109
-1 110 async def foo():
-1 111 try:
-1 112 await xiio.sleep(0.1)
-1 113 finally:
-1 114 stack.append(1)
-1 115
-1 116 with mock.patch('xiio.core.Condition.select', new=interrupt_select):
-1 117 with self.assertRaises(KeyboardInterrupt):
-1 118 xiio.run(foo())
-1 119 self.assertEqual(stack, [1])
-1 120
-1 121 def test_waits_for_cleanup(self):
-1 122 async def foo():
-1 123 try:
-1 124 raise ValueError
-1 125 finally:
-1 126 await xiio.sleep(0.1)
-1 127
-1 128 with self.assertRaises(ValueError):
-1 129 with self.assert_duration(0.1):
-1 130 xiio.run(foo())
-1 131
-1 132 def test_pipe(self):
-1 133 async def foo():
-1 134 r, w = os.pipe()
-1 135 try:
-1 136 return await xiio.gather([
-1 137 xiio.read(r, 32),
-1 138 xiio.writeall(w, b'Hello World'),
-1 139 ])
-1 140 finally:
-1 141 os.close(w)
-1 142 os.close(r)
-1 143
-1 144 with self.assert_duration(0):
-1 145 result = xiio.run(foo())
-1 146 self.assertEqual(result, [b'Hello World', None])
diff --git a/tests.py b/tests/test_multiplex.py
@@ -1,12 +1,8 @@1 -1 import contextlib2 -1 import functools3 -1 import inspect4 -1 import os5 1 import time6 -1 import unittest7 2 from unittest import mock 8 3 9 4 import xiio -1 5 from tests.utils import XiioTestCase 10 6 11 7 12 8 async def return_later(seconds, value): @@ -27,103 +23,6 @@ def interrupt_select(self): 27 23 return {} 28 24 29 2530 -1 class XiioTestCase(unittest.TestCase):31 -1 def _callTestMethod(self, method): # noqa32 -1 if not inspect.iscoroutinefunction(method):33 -1 return super()._callTestMethod(method)34 -135 -1 @functools.wraps(method)36 -1 def wrapper(*args, **kwargs):37 -1 xiio.run(method(*args, **kwargs))38 -1 return super()._callTestMethod(wrapper)39 -140 -1 @contextlib.contextmanager41 -1 def assert_duration(self, expected, *, places=2):42 -1 start = time.monotonic()43 -1 try:44 -1 yield45 -1 finally:46 -1 actual = time.monotonic() - start47 -1 self.assertAlmostEqual(actual, expected, places=places)48 -149 -150 -1 class TestConditionCombine(XiioTestCase):51 -1 def test_time(self):52 -1 result = xiio.Condition.combine([53 -1 xiio.Condition(),54 -1 xiio.Condition(time=1),55 -1 xiio.Condition(time=3),56 -1 xiio.Condition(time=-2),57 -1 ])58 -1 self.assertEqual(result.time, -2)59 -160 -1 def test_futures(self):61 -1 f1 = xiio.Future()62 -1 f2 = xiio.Future()63 -1 _f3 = xiio.Future()64 -165 -1 result = xiio.Condition.combine([66 -1 xiio.Condition(),67 -1 xiio.Condition(futures={f1}),68 -1 xiio.Condition(futures={f1, f2}),69 -1 ])70 -171 -1 self.assertEqual(result.futures, {f1, f2})72 -173 -1 def test_files(self):74 -1 result = xiio.Condition.combine([75 -1 xiio.Condition(),76 -1 xiio.Condition(files={1: xiio.READ, 2: xiio.READ}),77 -1 xiio.Condition(files={1: xiio.WRITE}),78 -1 ])79 -180 -1 self.assertEqual(result.files, {81 -1 1: xiio.READ|xiio.WRITE,82 -1 2: xiio.READ,83 -1 })84 -185 -186 -1 class TestConditionFulfilled(XiioTestCase):87 -1 def test_files(self):88 -1 condition = xiio.Condition(files={1: xiio.READ})89 -1 self.assertTrue(condition.fulfilled({1: xiio.READ, 2: xiio.READ}))90 -191 -1 def test_files_wrong_mode(self):92 -1 condition = xiio.Condition(files={1: xiio.READ})93 -1 self.assertFalse(condition.fulfilled({1: xiio.WRITE, 2: xiio.READ}))94 -195 -1 def test_future_not_done(self):96 -1 future = xiio.Future()97 -1 condition = xiio.Condition(futures={future})98 -1 self.assertFalse(condition.fulfilled({}))99 -1100 -1 def test_future_result(self):101 -1 future = xiio.Future()102 -1 future.set_result(1)103 -1 condition = xiio.Condition(futures={future})104 -1 self.assertTrue(condition.fulfilled({}))105 -1106 -1 def test_future_exception(self):107 -1 future = xiio.Future()108 -1 future.set_exception(ValueError)109 -1 condition = xiio.Condition(futures={future})110 -1 self.assertTrue(condition.fulfilled({}))111 -1112 -1113 -1 class TestFuture(XiioTestCase):114 -1 async def test_set_result(self):115 -1 future = xiio.Future()116 -1 future.set_result('test')117 -1 result = await future118 -1 self.assertEqual(result, 'test')119 -1120 -1 async def test_set_exception(self):121 -1 future = xiio.Future()122 -1 future.set_exception(TypeError)123 -1 with self.assertRaises(TypeError):124 -1 await future125 -1126 -1127 26 class TestTaskGroup(XiioTestCase): 128 27 async def test_add_tasks_while_running(self): 129 28 async def set_result_later(seconds, future): @@ -235,7 +134,7 @@ class TestGather(XiioTestCase): 235 134 finally: 236 135 stack.append(1) 237 136238 -1 with mock.patch('xiio.Condition.select', new=interrupt_select):-1 137 with mock.patch('xiio.core.Condition.select', new=interrupt_select): 239 138 with self.assertRaises(KeyboardInterrupt): 240 139 await xiio.gather([foo()]) 241 140 self.assertEqual(stack, [1]) @@ -267,55 +166,3 @@ class TestTimeout(XiioTestCase): 267 166 with self.assert_duration(0.1): 268 167 async with xiio.timeout(None): 269 168 await xiio.sleep(0.1)270 -1271 -1272 -1 class TestRun(XiioTestCase):273 -1 def test_sleep(self):274 -1 async def foo():275 -1 await xiio.sleep(0.1)276 -1 return 'Hello World'277 -1278 -1 with self.assert_duration(0.1):279 -1 result = xiio.run(foo())280 -1 self.assertEqual(result, 'Hello World')281 -1282 -1 def test_runs_cleanup_on_error_while_paused(self):283 -1 stack = []284 -1285 -1 async def foo():286 -1 try:287 -1 await xiio.sleep(0.1)288 -1 finally:289 -1 stack.append(1)290 -1291 -1 with mock.patch('xiio.Condition.select', new=interrupt_select):292 -1 with self.assertRaises(KeyboardInterrupt):293 -1 xiio.run(foo())294 -1 self.assertEqual(stack, [1])295 -1296 -1 def test_waits_for_cleanup(self):297 -1 async def foo():298 -1 try:299 -1 raise ValueError300 -1 finally:301 -1 await xiio.sleep(0.1)302 -1303 -1 with self.assertRaises(ValueError):304 -1 with self.assert_duration(0.1):305 -1 xiio.run(foo())306 -1307 -1 def test_pipe(self):308 -1 async def foo():309 -1 r, w = os.pipe()310 -1 try:311 -1 return await xiio.gather([312 -1 xiio.read(r, 32),313 -1 xiio.writeall(w, b'Hello World'),314 -1 ])315 -1 finally:316 -1 os.close(w)317 -1 os.close(r)318 -1319 -1 with self.assert_duration(0):320 -1 result = xiio.run(foo())321 -1 self.assertEqual(result, [b'Hello World', None])
diff --git a/tests/utils.py b/tests/utils.py
@@ -0,0 +1,28 @@ -1 1 import contextlib -1 2 import functools -1 3 import inspect -1 4 import time -1 5 import unittest -1 6 -1 7 import xiio -1 8 -1 9 -1 10 class XiioTestCase(unittest.TestCase): -1 11 def _callTestMethod(self, method): # noqa -1 12 if inspect.iscoroutinefunction(method): -1 13 @functools.wraps(method) -1 14 def wrapper(*args, **kwargs): -1 15 xiio.run(method(*args, **kwargs)) -1 16 else: -1 17 wrapper = method -1 18 -1 19 return super()._callTestMethod(wrapper) # ty: ignore[unresolved-attribute] -1 20 -1 21 @contextlib.contextmanager -1 22 def assert_duration(self, expected, *, places=2): -1 23 start = time.monotonic() -1 24 try: -1 25 yield -1 26 finally: -1 27 actual = time.monotonic() - start -1 28 self.assertAlmostEqual(actual, expected, places=places)
diff --git a/xiio/__init__.py b/xiio/__init__.py
@@ -0,0 +1,10 @@ -1 1 from .core import run # noqa -1 2 from .core import sleep # noqa -1 3 from .core import read # noqa -1 4 from .core import write # noqa -1 5 from .core import writeall # noqa -1 6 from .core import Future # noqa -1 7 from .core import CancelledError # noqa -1 8 from .multiplex import TaskGroup # noqa -1 9 from .multiplex import gather # noqa -1 10 from .multiplex import timeout # noqa
diff --git a/xiio.py b/xiio/core.py
@@ -1,10 +1,8 @@1 -1 import contextlib2 1 import math 3 2 import os 4 3 import selectors 5 4 import time 6 5 import typing7 -1 from collections.abc import AsyncGenerator8 6 from collections.abc import Coroutine 9 7 from collections.abc import Generator 10 8 from selectors import EVENT_READ as READ @@ -157,94 +155,6 @@ class Task(typing.Generic[T]): 157 155 self._condition = None 158 156 159 157160 -1 class TaskGroup(typing.Generic[T]):161 -1 def __init__(self) -> None:162 -1 self.tasks: list[Task[T]] = []163 -1 self.exc: BaseException | None = None164 -1165 -1 def add_task(self, coro: Coro[T]) -> Task[T]:166 -1 task = Task(coro.__await__())167 -1 self.tasks.append(task)168 -1 return task169 -1170 -1 def cancel(self, exc: BaseException) -> None:171 -1 if not self.exc:172 -1 self.exc = exc173 -1 for task in self.tasks:174 -1 task.cancel()175 -1176 -1 def __await__(self) -> Gen[None]:177 -1 while self.tasks:178 -1 try:179 -1 state = yield Condition.combine(180 -1 [task.condition for task in self.tasks]181 -1 )182 -1 except BaseException as e:183 -1 state = e184 -1185 -1 for task in self.tasks[:]:186 -1 try:187 -1 task.resume(state)188 -1 except StopIteration as e:189 -1 self.tasks.remove(task)190 -1 task.result = e.value191 -1 except CancelledError:192 -1 self.tasks.remove(task)193 -1 except BaseException as e:194 -1 self.tasks.remove(task)195 -1 self.cancel(e)196 -1197 -1 async def __aenter__(self) -> 'TaskGroup[T]':198 -1 parent_task = typing.cast(Task[T], await GetTaskCondition())199 -1 gen = parent_task.gen200 -1201 -1 async def wrapper():202 -1 await Condition(time=-math.inf)203 -1 await self204 -1 parent_task.gen = gen205 -1 parent_task._condition = None206 -1 await Condition(time=-math.inf)207 -1208 -1 self.tasks.append(Task(gen))209 -1 parent_task.gen = typing.cast(typing.Any, wrapper().__await__())210 -1 next(parent_task.gen)211 -1 await Condition(time=-math.inf)212 -1213 -1 return self214 -1215 -1 async def __aexit__(self, exc_type, exc: BaseException | None, traceback) -> None:216 -1 await ThrowCondition(exc or StopIteration())217 -1 if self.exc:218 -1 raise self.exc219 -1220 -1221 -1 async def gather(coros: list[Coro[T]]) -> list[T]:222 -1 async with TaskGroup() as tg:223 -1 tasks = [tg.add_task(coro) for coro in coros]224 -1 return [typing.cast(T, task.result) for task in tasks]225 -1226 -1227 -1 @contextlib.asynccontextmanager228 -1 async def timeout(229 -1 seconds: float | None, *, throw: bool = True230 -1 ) -> AsyncGenerator[None, None]:231 -1 if seconds is None:232 -1 yield233 -1 else:234 -1 async def _timeout() -> typing.NoReturn:235 -1 await sleep(seconds)236 -1 raise TimeoutError237 -1238 -1 try:239 -1 async with TaskGroup() as tg:240 -1 task = tg.add_task(_timeout())241 -1 yield242 -1 task.cancel()243 -1 except TimeoutError:244 -1 if throw:245 -1 raise246 -1247 -1248 158 def run(coro: Coro[T]) -> T: 249 159 task = Task(coro.__await__()) 250 160 try:
diff --git a/xiio/multiplex.py b/xiio/multiplex.py
@@ -0,0 +1,103 @@
-1 1 import contextlib
-1 2 import math
-1 3 import typing
-1 4 from collections.abc import AsyncGenerator
-1 5
-1 6 from .core import CancelledError
-1 7 from .core import Condition
-1 8 from .core import Coro
-1 9 from .core import Gen
-1 10 from .core import GetTaskCondition
-1 11 from .core import Task
-1 12 from .core import ThrowCondition
-1 13 from .core import sleep
-1 14
-1 15 T = typing.TypeVar('T')
-1 16
-1 17
-1 18 class TaskGroup(typing.Generic[T]):
-1 19 def __init__(self) -> None:
-1 20 self.tasks: list[Task[T]] = []
-1 21 self.exc: BaseException | None = None
-1 22
-1 23 def add_task(self, coro: Coro[T]) -> Task[T]:
-1 24 task = Task(coro.__await__())
-1 25 self.tasks.append(task)
-1 26 return task
-1 27
-1 28 def cancel(self, exc: BaseException) -> None:
-1 29 if not self.exc:
-1 30 self.exc = exc
-1 31 for task in self.tasks:
-1 32 task.cancel()
-1 33
-1 34 def __await__(self) -> Gen[None]:
-1 35 while self.tasks:
-1 36 try:
-1 37 state = yield Condition.combine(
-1 38 [task.condition for task in self.tasks]
-1 39 )
-1 40 except BaseException as e:
-1 41 state = e
-1 42
-1 43 for task in self.tasks[:]:
-1 44 try:
-1 45 task.resume(state)
-1 46 except StopIteration as e:
-1 47 self.tasks.remove(task)
-1 48 task.result = e.value
-1 49 except CancelledError:
-1 50 self.tasks.remove(task)
-1 51 except BaseException as e:
-1 52 self.tasks.remove(task)
-1 53 self.cancel(e)
-1 54
-1 55 async def __aenter__(self) -> 'TaskGroup[T]':
-1 56 parent_task = typing.cast(Task[T], await GetTaskCondition())
-1 57 gen = parent_task.gen
-1 58
-1 59 async def wrapper():
-1 60 await Condition(time=-math.inf)
-1 61 await self
-1 62 parent_task.gen = gen
-1 63 parent_task._condition = None
-1 64 await Condition(time=-math.inf)
-1 65
-1 66 self.tasks.append(Task(gen))
-1 67 parent_task.gen = typing.cast(typing.Any, wrapper().__await__())
-1 68 next(parent_task.gen)
-1 69 await Condition(time=-math.inf)
-1 70
-1 71 return self
-1 72
-1 73 async def __aexit__(self, exc_type, exc: BaseException | None, traceback) -> None:
-1 74 await ThrowCondition(exc or StopIteration())
-1 75 if self.exc:
-1 76 raise self.exc
-1 77
-1 78
-1 79 async def gather(coros: list[Coro[T]]) -> list[T]:
-1 80 async with TaskGroup() as tg:
-1 81 tasks = [tg.add_task(coro) for coro in coros]
-1 82 return [typing.cast(T, task.result) for task in tasks]
-1 83
-1 84
-1 85 @contextlib.asynccontextmanager
-1 86 async def timeout(
-1 87 seconds: float | None, *, throw: bool = True
-1 88 ) -> AsyncGenerator[None, None]:
-1 89 if seconds is None:
-1 90 yield
-1 91 else:
-1 92 async def _timeout() -> typing.NoReturn:
-1 93 await sleep(seconds)
-1 94 raise TimeoutError
-1 95
-1 96 try:
-1 97 async with TaskGroup() as tg:
-1 98 task = tg.add_task(_timeout())
-1 99 yield
-1 100 task.cancel()
-1 101 except TimeoutError:
-1 102 if throw:
-1 103 raise