├── .gitignore ├── LICENSE ├── README.md ├── miniloop.py └── server.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Roee Nizan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # miniloop 2 | A minimal event loop implementation 3 | -------------------------------------------------------------------------------- /miniloop.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | from dataclasses import dataclass, field 5 | from socket import socket 6 | from typing import ( 7 | Any, 8 | Callable, 9 | Coroutine, 10 | Literal, 11 | ClassVar, 12 | TypeVar, 13 | Awaitable, 14 | Generic, 15 | ) 16 | 17 | from select import select 18 | 19 | 20 | def setup_logging(log_: logging.Logger): 21 | formatter = logging.Formatter( 22 | "{asctime} - {levelname} - {name}:{lineno}.{funcName}() - {message}", style="{" 23 | ) 24 | handler = logging.StreamHandler() 25 | handler.setFormatter(formatter) 26 | log_.addHandler(handler) 27 | for o in log_, handler: 28 | o.setLevel(logging.DEBUG) 29 | 30 | 31 | log = logging.getLogger("loop") 32 | setup_logging(log) 33 | 34 | 35 | @dataclass 36 | class Result: 37 | """ 38 | Wrap task results in Ok() for returned value 39 | or Exc() for raised exception 40 | """ 41 | 42 | value: Any 43 | 44 | 45 | class Ok(Result): 46 | pass 47 | 48 | 49 | class Exc(Result): 50 | pass 51 | 52 | 53 | Target = Literal["read", "write"] 54 | T = TypeVar("T") 55 | 56 | 57 | @dataclass 58 | class Future(Awaitable[T]): 59 | """ 60 | A callback that will produce a value once ready. 61 | 62 | what: anything that can be used with select() 63 | target: reading or writing 64 | callback: the callback that will produce the value when present 65 | result: the callback's result 66 | """ 67 | 68 | what: Any 69 | target: Target 70 | callback: Callable[[], T] 71 | result: Result | None = None 72 | 73 | def __await__(self): 74 | log.debug("awaiting %s", self) 75 | # yield control to the event loop to check whether result is ready with select() 76 | yield self 77 | # yield should only return when the event loop has decided that the callback 78 | # is ready to produce a result and resumed the future's coroutine with .send() 79 | log.debug(f"returning future result {self.result=}") 80 | # An error can only be assigned in Task.step(). Here the values are always Ok() 81 | assert isinstance(self.result, Ok) 82 | # return the result 83 | return self.result.value 84 | 85 | def fileno(self): 86 | """ 87 | Make the future selectable with select() 88 | """ 89 | return self.what.fileno() 90 | 91 | 92 | @dataclass 93 | class Task(Generic[T]): 94 | """ 95 | A place for holding values passed from futures to coroutines 96 | co: the entry point coroutine 97 | index: an arbitrary identifier (shorter than id()) 98 | counter: global counter for setting self.index 99 | """ 100 | 101 | co: Coroutine[Future, ..., T] = field() 102 | counter: ClassVar[int] = 0 103 | index: int = field(init=False) 104 | 105 | def __repr__(self): 106 | return f"Task<{self.co.__name__}#{self.index}>" 107 | 108 | def __post_init__(self): 109 | self.index = self.counter 110 | type(self).counter += 1 111 | log.debug("created %s", self) 112 | self.result: Result | None = None 113 | self.next_value = None 114 | self.future = None 115 | 116 | def fileno(self): 117 | """ 118 | for select() 119 | """ 120 | assert self.future, self 121 | return self.future.fileno() 122 | 123 | def send(self, value: Any): 124 | log.debug("sending %s", value) 125 | return self.co.send(value) 126 | 127 | @property 128 | def done(self): 129 | """ 130 | None means that the task has not finished. 131 | The result of None is represented with Ok(None) 132 | """ 133 | return self.result is not None 134 | 135 | def step(self): 136 | """ 137 | An awaited value has become ready. Update self.next_value 138 | """ 139 | try: 140 | result = Ok(self.future.callback()) 141 | except Exception as ex: 142 | result = Exc(ex) 143 | self.next_value = self.future.result = result 144 | 145 | def throw(self, error: Exception): 146 | return self.co.throw(error) 147 | 148 | 149 | class UniqueList(list[T]): 150 | """ 151 | A list with no duplicates. Used to detect faulty event loop implementations 152 | that add a value to a list which is already present. 153 | """ 154 | 155 | def append(self, value: T): 156 | if value in self: 157 | raise Exception(f"value {value} already in self {self}") 158 | super().append(value) 159 | 160 | 161 | class Loop(asyncio.AbstractEventLoop): 162 | """ 163 | A minimal, incomplete implementation of a Python event loop. 164 | Enough to run a basic client or server. 165 | Not all AbstractEventLoop methods are implemented. 166 | """ 167 | 168 | def __init__(self): 169 | # tasks that do not currently wait on anything or that their awaited value is ready 170 | self.tasks: list[Task] = UniqueList() 171 | # tasks that wait to read something 172 | self.read: list[Task] = UniqueList() 173 | # tasks that wait to write something 174 | self.write: list[Task] = UniqueList() 175 | # the user's entry point or "main thread" supplied to Loop.run_until_complete() 176 | self.entry_point: Task | None = None 177 | 178 | def _run_once(self): 179 | """ 180 | Advance a single task one step forward 181 | """ 182 | log.debug("_run_once()") 183 | try: 184 | task = self.tasks.pop(0) 185 | except IndexError: 186 | log.debug("tasks empty") 187 | return 188 | value = task.next_value 189 | match value: 190 | # a future should be awaited for in .select() 191 | case Future(_what, target): 192 | self._handle_future(task, target, value) 193 | case _: 194 | ok, value = self._handle_value(task, value) 195 | if not ok: 196 | return 197 | log.debug("received value %s", value) 198 | # if we're here, a future has produced a result which is 199 | # ready to be handled by the task. mark the task as ready for next iteration 200 | log.debug("adding to tasks: %s", task) 201 | self.tasks.append(task) 202 | log.debug("setting value %s", value) 203 | # store the value for the next iteration 204 | task.next_value = value 205 | 206 | def _handle_future(self, task: Task, target: Target, value: Any): 207 | if target == "read": 208 | log.debug("adding to read: %s", task) 209 | self.read.append(task) 210 | elif target == "write": 211 | log.debug("adding to write: %s", task) 212 | self.write.append(task) 213 | else: 214 | unreachable() 215 | log.debug(f"setting future of {task} to {value}") 216 | # enables selecting on task 217 | task.future = value 218 | 219 | def _handle_value(self, task: Task, value: Any): 220 | """ 221 | Returns is_ok, value 222 | """ 223 | try: 224 | match value: 225 | case None: 226 | # coroutine has not started. start it 227 | value = task.send(None) 228 | case Ok(ok_value): 229 | # a future produced a result 230 | log.debug(f"{ok_value=}") 231 | value = task.send(ok_value) 232 | case Exc(error): 233 | # a future produced an error 234 | log.debug(f"{error=}") 235 | # let the task handle the error 236 | value = task.throw(error) 237 | case _: 238 | unreachable() 239 | return True, value 240 | except StopIteration as ex: 241 | # task has finished and returned a value 242 | result = ex.value 243 | log.debug(f"{task=} terminated with {result=}") 244 | task.result = Ok(result) 245 | return False, None 246 | except Exception as exception: 247 | # task has thrown an error 248 | log.debug(f"setting task.result={exception!r}") 249 | task.result = Exc(exception) 250 | # if this is the entry point supplied by the user (the "main thread"), propagate the error 251 | if task is self.entry_point: 252 | log.debug("reraising exception") 253 | raise 254 | # else, this is a spawned "thread". just note the error has not been handled 255 | log.error(f"Task exception was never retrieved, {task=}, {exception=!r}") 256 | return False, None 257 | 258 | def select(self): 259 | """ 260 | Wait until at least one of the waiting tasks' value is ready 261 | """ 262 | log.debug("selecting %s, %s", self.read, self.write) 263 | if self.read or self.write: 264 | read_ready, write_ready, _ = select(self.read, self.write, []) 265 | log.debug("removing from .read, .write: %s, %s", read_ready, write_ready) 266 | # tasks that are returned by select are no longer waiting, remove them from the lists 267 | for to_remove, lst in [ 268 | (read_ready, self.read), 269 | (write_ready, self.write), 270 | ]: 271 | for task in to_remove: 272 | lst.remove(task) 273 | # mark all selected tasks as ready for advancement 274 | all_ready = read_ready + write_ready 275 | log.debug("adding to tasks: %s", all_ready) 276 | self.tasks.extend(all_ready) 277 | for task in all_ready: 278 | log.debug("stepping %s", task) 279 | # update the task's next_value 280 | task.step() 281 | else: 282 | # nothing to wait on, this should not happen 283 | log.debug("no read") 284 | # just in case, avoid endless spamming 285 | time.sleep(0.5) 286 | 287 | def create_task( 288 | self, co: Coroutine[Future, ..., T], _name: str | None = None 289 | ) -> Task[T]: 290 | """ 291 | Put a coroutine in the event loop 292 | """ 293 | log.debug("creating task for %s", co) 294 | task: Task[T] = Task(co) 295 | log.debug("adding to tasks: %s", task) 296 | self.tasks.append(task) 297 | return task 298 | 299 | def run_forever(self) -> None: 300 | while True: 301 | if self.tasks: 302 | self._run_once() 303 | else: 304 | self.select() 305 | 306 | def run_until_complete(self, co: Coroutine[Future, ..., T]) -> T: 307 | """ 308 | Run a task and its futures until it's complete. 309 | A task may spawn additional tasks, which are only advanced until the main task is done 310 | """ 311 | # mark the task as the entry point 312 | task: Task[T] = self.create_task(co) 313 | self.entry_point = task 314 | log.debug("adding to tasks: %s", task) 315 | while not task.done: 316 | # select() blocks. so don't block if there's any task ready for advancing 317 | if self.tasks: 318 | self._run_once() 319 | else: 320 | # no ready tasks, wait for futures 321 | self.select() 322 | # task result should have been set by now 323 | assert task.result is not None 324 | # an error would have been propagated, therefore we have an Ok() value 325 | assert isinstance(task.result, Ok) 326 | # return the inner value 327 | return task.result.value 328 | 329 | def sock_accept(self, server: socket) -> Future[tuple[socket, tuple[str, int]]]: 330 | """ 331 | Accept a new client from a listening server socket asynchronously 332 | """ 333 | return Future(server, "read", server.accept) 334 | 335 | def sock_recv(self, socket_: socket, size: int) -> Future[bytes]: 336 | """ 337 | Receive data from socket asynchronously 338 | """ 339 | return Future(socket_, "read", lambda: socket_.recv(size)) 340 | 341 | def sock_sendall(self, socket_: socket, payload: bytes) -> Future[None]: 342 | """ 343 | Send data asynchronously. 344 | Note that this is "cheating" because socket.sendall() can block for large payloads, 345 | which will block the entire event loop 346 | """ 347 | return Future(socket_, "write", lambda: socket_.sendall(payload)) 348 | 349 | 350 | loop = Loop() 351 | 352 | 353 | def run(co: Coroutine[Future, ..., T]) -> T: 354 | return loop.run_until_complete(co) 355 | 356 | 357 | def get_event_loop(): 358 | return loop 359 | 360 | 361 | def unreachable(): 362 | assert False, "unreachable" 363 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | # import asyncio 2 | import logging 3 | import socket 4 | 5 | import miniloop as asyncio 6 | 7 | log = logging.getLogger("loop.server") 8 | 9 | 10 | async def handle_client(client): 11 | loop = asyncio.get_event_loop() 12 | request = None 13 | while request != "quit": 14 | request = (await loop.sock_recv(client, 255)).decode("utf8") 15 | response = str(eval(request)) + "\n" 16 | await loop.sock_sendall(client, response.encode("utf8")) 17 | log.info("goodbye") 18 | client.close() 19 | return "client handling complete" 20 | 21 | 22 | async def divide(a, b): 23 | return a / b 24 | 25 | 26 | async def run_server(): 27 | # check that the loop handles tasks that don't yield futures 28 | result = await divide(1, 2) 29 | log.info("got: %s", result) 30 | try: 31 | await divide(1, 0) 32 | except ZeroDivisionError: 33 | log.info("caught expected exception") 34 | else: 35 | assert False, "unreachable" 36 | 37 | server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 38 | server.bind(("localhost", 8888)) 39 | server.listen(8) 40 | server.setblocking(False) 41 | 42 | loop = asyncio.get_event_loop() 43 | 44 | while True: 45 | log.debug("accepting") 46 | result = await loop.sock_accept(server) 47 | assert result, result 48 | client, _address = result 49 | log.debug("accepted") 50 | log.debug("handling client") 51 | loop.create_task(handle_client(client)) 52 | 53 | 54 | asyncio.run(run_server()) 55 | --------------------------------------------------------------------------------