├── .gitignore ├── LICENSE ├── README.rst ├── setup.py ├── tasklocals └── __init__.py └── tests └── test_local.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | *.egg-info 3 | *.egg 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2021, Vladimir Kryachko 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Task locals support for tulip/asyncio 2 | ~~~~~~~~~~~~~~~~~~~~~ 3 | 4 | It provides Task local storage similar to python's threading.local 5 | but for Tasks in asyncio. 6 | 7 | Using task locals has some caveats: 8 | 9 | * Unlike thread locals, where you are always sure that at least one thread is running(namely main thread), Task locals are available only in the context of a running Task. So if you try to access a task local from outside a Task you will get a RuntimeError. 10 | * Be aware that using asyncio.async, asyncio.wait, asyncio.gather, asyncio.shield and friends launches a new task, so these coroutines will have its own local storage. 11 | 12 | For more information on using locals see the docs for threading.local in python's standard library 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from setuptools import setup, find_packages 3 | 4 | install_requires = ['asyncio'] if sys.version_info[:2] < (3, 4) else [] 5 | tests_require = install_requires + ['nose'] 6 | 7 | setup( 8 | name="tasklocals", 9 | version="0.2", 10 | author = "Vladimir Kryachko", 11 | author_email = "v.kryachko@gmail.com", 12 | description = "Task locals for Tulip/asyncio", 13 | classifiers=[ 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.3", 17 | "Programming Language :: Python :: 3.4", 18 | ], 19 | packages=['tasklocals'], 20 | install_requires = install_requires, 21 | tests_require = tests_require, 22 | test_suite = 'nose.collector', 23 | ) 24 | -------------------------------------------------------------------------------- /tasklocals/__init__.py: -------------------------------------------------------------------------------- 1 | "Based on python's _threading_local" 2 | 3 | from contextlib import contextmanager 4 | from weakref import ref 5 | import asyncio 6 | 7 | __all__ = ['local'] 8 | 9 | class _localimpl: 10 | """A class managing task-local dicts""" 11 | __slots__ = 'key', 'dicts', 'localargs', '__weakref__', '_loop' 12 | 13 | def __init__(self, loop): 14 | if loop is None: 15 | loop = asyncio.get_event_loop() 16 | self._loop = loop 17 | # The key used in the Thread objects' attribute dicts. 18 | # We keep it a string for speed but make it unlikely to clash with 19 | # a "real" attribute. 20 | self.key = '_task_local._localimpl.' + str(id(self)) 21 | # { id(Task) -> (ref(Task), task-local dict) } 22 | self.dicts = {} 23 | 24 | def get_dict(self): 25 | """Return the dict for the current task. Raises KeyError if none 26 | defined.""" 27 | task = asyncio.Task.current_task(loop=self._loop) 28 | if task is None: 29 | raise RuntimeError("No task is currently running") 30 | return self.dicts[id(task)][1] 31 | 32 | def create_dict(self): 33 | """Create a new dict for the current task, and return it.""" 34 | localdict = {} 35 | key = self.key 36 | task = asyncio.Task.current_task(loop=self._loop) 37 | if task is None: 38 | raise RuntimeError("No task is currently running") 39 | idt = id(task) 40 | def local_deleted(_, key=key): 41 | # When the localimpl is deleted, remove the task attribute. 42 | task = wrtask() 43 | if task is not None: 44 | del task.__dict__[key] 45 | def task_deleted(_, idt=idt): 46 | # When the task is deleted, remove the local dict. 47 | # Note that this is suboptimal if the task object gets 48 | # caught in a reference loop. We would like to be called 49 | # as soon as the task ends instead. 50 | local = wrlocal() 51 | if local is not None: 52 | dct = local.dicts.pop(idt) 53 | wrlocal = ref(self, local_deleted) 54 | wrtask = ref(task, task_deleted) 55 | task.__dict__[key] = wrlocal 56 | self.dicts[idt] = wrtask, localdict 57 | return localdict 58 | 59 | 60 | @contextmanager 61 | def _patch(self): 62 | impl = object.__getattribute__(self, '_local__impl') 63 | try: 64 | dct = impl.get_dict() 65 | except KeyError: 66 | dct = impl.create_dict() 67 | args, kw = impl.localargs 68 | self.__init__(*args, **kw) 69 | object.__setattr__(self, '__dict__', dct) 70 | yield 71 | 72 | class local: 73 | __slots__ = '_local__impl', '__dict__' 74 | 75 | def __new__(cls, *args, loop=None, **kw): 76 | if (args or kw) and (cls.__init__ is object.__init__): 77 | raise TypeError("Initialization arguments are not supported") 78 | self = object.__new__(cls) 79 | impl = _localimpl(loop=loop) 80 | impl.localargs = (args, kw) 81 | object.__setattr__(self, '_local__impl', impl) 82 | return self 83 | 84 | def __getattribute__(self, name): 85 | with _patch(self): 86 | return object.__getattribute__(self, name) 87 | 88 | def __setattr__(self, name, value): 89 | if name == '__dict__': 90 | raise AttributeError( 91 | "%r object attribute '__dict__' is read-only" 92 | % self.__class__.__name__) 93 | with _patch(self): 94 | return object.__setattr__(self, name, value) 95 | 96 | def __delattr__(self, name): 97 | if name == '__dict__': 98 | raise AttributeError( 99 | "%r object attribute '__dict__' is read-only" 100 | % self.__class__.__name__) 101 | with _patch(self): 102 | return object.__delattr__(self, name) 103 | -------------------------------------------------------------------------------- /tests/test_local.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import gc 3 | import asyncio 4 | import asyncio.test_utils 5 | from tasklocals import local 6 | 7 | 8 | class LocalTests(unittest.TestCase): 9 | def setUp(self): 10 | self.loop = asyncio.new_event_loop() 11 | asyncio.set_event_loop(None) 12 | 13 | def tearDown(self): 14 | asyncio.test_utils.run_briefly(self.loop) 15 | 16 | self.loop.close() 17 | gc.collect() 18 | 19 | def test_local(self): 20 | mylocal = local(loop=self.loop) 21 | 22 | log1 = [] 23 | log2 = [] 24 | 25 | fut1 = asyncio.Future(loop=self.loop) 26 | fut2 = asyncio.Future(loop=self.loop) 27 | 28 | @asyncio.coroutine 29 | def task1(): 30 | # get attributes of the local, must be empty at first 31 | items = list(mylocal.__dict__.items()) 32 | log1.append(items) 33 | yield from fut1 34 | # when the fut1 completes, we have already set mylocal.value to "Task 2" in task2 35 | # it must not be visible in task1, so the __dict__ should still be empty 36 | items = list(mylocal.__dict__.items()) 37 | log1.append(items) 38 | mylocal.value = "Task 1" 39 | items = list(mylocal.__dict__.items()) 40 | log1.append(items) 41 | # wake up task2 to ensure that value "Task 1" is not visible in task2 42 | fut2.set_result(True) 43 | 44 | @asyncio.coroutine 45 | def task2(): 46 | # get attributes of the local, must be empty at first 47 | items = list(mylocal.__dict__.items()) 48 | log2.append(items) 49 | mylocal.value = "Task 2" 50 | # wake up task1 51 | fut1.set_result(True) 52 | # wait for task1 to complete 53 | yield from fut2 54 | # value "Task 1" must not be visible in this task 55 | items = list(mylocal.__dict__.items()) 56 | log2.append(items) 57 | 58 | self.loop.run_until_complete(asyncio.wait((task1(), task2()), loop=self.loop)) 59 | # ensure that the values logged are as expected 60 | self.assertEqual(log1, [[], [], [('value', 'Task 1')]]) 61 | self.assertEqual(log2, [[], [('value', 'Task 2')]]) 62 | # ensure all task local values have been properly cleaned up 63 | self.assertEqual(object.__getattribute__(mylocal, '_local__impl').dicts, {}) 64 | 65 | def test_local_outside_of_task(self): 66 | mylocal = local(loop=self.loop) 67 | try: 68 | mylocal.foo = 1 69 | self.fail("RuntimeError has not been raised when tryint to use local object outside of a Task") 70 | except RuntimeError: 71 | pass 72 | 73 | --------------------------------------------------------------------------------