diff --git a/external/devlib/devlib/module/cgroups.py b/external/devlib/devlib/module/cgroups.py index ece52c278916ffeb5d0f83569e261c32e5551403..a7edf879c935bb6953c2a4ef2a79d4010a9fc9de 100644 --- a/external/devlib/devlib/module/cgroups.py +++ b/external/devlib/devlib/module/cgroups.py @@ -19,13 +19,12 @@ from collections import namedtuple from shlex import quote import itertools import warnings -import asyncio from devlib.module import Module from devlib.exception import TargetStableError from devlib.utils.misc import list_to_ranges, isiterable from devlib.utils.types import boolean -from devlib.utils.asyn import asyncf +from devlib.utils.asyn import asyncf, run class Controller(object): @@ -410,7 +409,7 @@ class CgroupsModule(Module): controller.mount_point) self.controllers[ss.name] = controller - asyncio.run( + run( target.async_manager.map_concurrently( register_controller, subsys, diff --git a/external/devlib/devlib/target.py b/external/devlib/devlib/target.py index 753fb96a3620dcee19d32b3dc3b31d8a1858dabf..96958b5f8cc5f4302499584c0f63675f176b89b1 100644 --- a/external/devlib/devlib/target.py +++ b/external/devlib/devlib/target.py @@ -103,7 +103,8 @@ def call_conn(f): @functools.wraps(f) def wrapper(self, *args, **kwargs): - reentered = self.conn.is_in_use + conn = self.conn + reentered = conn.is_in_use disconnect = False try: # If the connection was already in use we need to use a different @@ -113,8 +114,9 @@ def call_conn(f): if reentered: # Shallow copy so we can use another connection instance _self = copy.copy(self) - _self.conn = _self.get_connection() - assert self.conn is not _self.conn + new_conn = _self.get_connection() + assert conn is not new_conn + _self.conn = new_conn disconnect = True else: _self = self @@ -122,6 +124,13 @@ def call_conn(f): finally: if disconnect: _self.disconnect() + elif not reentered: + # Return the connection to the pool, so if we end up exiting + # the thread the connection can then be reused by another + # thread. + del self.conn + with self._lock: + self._unused_conns.add(conn) return wrapper @@ -284,7 +293,8 @@ class Target(object): @tls_property def _conn(self): try: - return self._unused_conns.pop() + with self._lock: + return self._unused_conns.pop() except KeyError: return self.get_connection() @@ -311,6 +321,8 @@ class Target(object): is_container=False, max_async=50, ): + + self._lock = threading.RLock() self._async_pool = None self._async_pool_size = None self._unused_conns = set() @@ -435,6 +447,7 @@ class Target(object): ignored.update(( '_async_pool', '_unused_conns', + '_lock', )) return { k: v @@ -450,6 +463,7 @@ class Target(object): else: self._async_pool = ThreadPoolExecutor(pool_size) self._unused_conns = set() + self._lock = threading.RLock() # connection and initialization @@ -542,6 +556,13 @@ class Target(object): if pool is not None: pool.__exit__(None, None, None) + with self._lock: + connections = self._conn.get_all_values() + for conn in itertools.chain(connections, self._unused_conns): + conn.close() + if self._async_pool is not None: + self._async_pool.__exit__(None, None, None) + def __enter__(self): return self diff --git a/external/devlib/devlib/utils/asyn.py b/external/devlib/devlib/utils/asyn.py index a993077d4d2bd97660a24b8e95ca48feb4bbb508..7b209f7de333b3a5929fcca15d01fcf258c82351 100644 --- a/external/devlib/devlib/utils/asyn.py +++ b/external/devlib/devlib/utils/asyn.py @@ -20,23 +20,19 @@ Async-related utilities import abc import asyncio +import asyncio.events import functools import itertools import contextlib import pathlib import os.path import inspect +import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from weakref import WeakSet, WeakKeyDictionary -# Allow nesting asyncio loops, which is necessary for: -# * Being able to call the blocking variant of a function from an async -# function for backward compat -# * Critically, run the blocking variant of a function in a Jupyter notebook -# environment, since it also uses asyncio. -# -# Maybe there is still hope for future versions of Python though: -# https://bugs.python.org/issue22239 -import nest_asyncio -nest_asyncio.apply() +from greenlet import greenlet def create_task(awaitable, name=None): @@ -292,6 +288,294 @@ class memoized_method: self.name = name +class _Genlet(greenlet): + """ + Generator-like object based on ``greenlets``. It allows nested :class:`_Genlet` + to make their parent yield on their behalf, as if callees could decide to + be annotated ``yield from`` without modifying the caller. + """ + @classmethod + def from_coro(cls, coro): + """ + Create a :class:`_Genlet` from a given coroutine, treating it as a + generator. + """ + f = lambda value: self.consume_coro(coro, value) + self = cls(f) + return self + + def consume_coro(self, coro, value): + """ + Send ``value`` to ``coro`` then consume the coroutine, passing all its + yielded actions to the enclosing :class:`_Genlet`. This allows crossing + blocking calls layers as if they were async calls with `await`. + """ + excep = None + while True: + try: + if excep is None: + future = coro.send(value) + else: + future = coro.throw(excep) + + except StopIteration as e: + return e.value + else: + # Switch back to the consumer that returns the values via + # send() + try: + value = self.consumer_genlet.switch(future) + except BaseException as e: + excep = e + value = None + else: + excep = None + + + @classmethod + def get_enclosing(cls): + """ + Get the immediately enclosing :class:`_Genlet` in the callstack or + ``None``. + """ + g = greenlet.getcurrent() + while not (isinstance(g, cls) or g is None): + g = g.parent + return g + + def _send_throw(self, value, excep): + self.consumer_genlet = greenlet.getcurrent() + + # Switch back to the function yielding values + if excep is None: + result = self.switch(value) + else: + result = self.throw(excep) + + if self: + return result + else: + raise StopIteration(result) + + def gen_send(self, x): + """ + Similar to generators' ``send`` method. + """ + return self._send_throw(x, None) + + def gen_throw(self, x): + """ + Similar to generators' ``throw`` method. + """ + return self._send_throw(None, x) + + +class _AwaitableGenlet: + """ + Wrap a coroutine with a :class:`_Genlet` and wrap that to be awaitable. + """ + + @classmethod + def wrap_coro(cls, coro): + if _Genlet.get_enclosing() is None: + # Create a top-level _Genlet that all nested runs will use to yield + # their futures + aw = cls(coro) + async def coro_f(): + return await aw + return coro_f() + else: + return coro + + def __init__(self, coro): + self._coro = coro + + def __await__(self): + coro = self._coro + is_started = coro.cr_running + + def genf(): + gen = _Genlet.from_coro(coro) + value = None + excep = None + + # The coroutine is already started, so we need to dispatch the + # value from the upcoming send() to the gen without running + # gen first. + if is_started: + try: + value = yield + except BaseException as e: + excep = e + + while True: + try: + if excep is None: + future = gen.gen_send(value) + else: + future = gen.gen_throw(excep) + except StopIteration as e: + return e.value + + try: + value = yield future + except BaseException as e: + excep = e + value = None + else: + excep = None + + gen = genf() + if is_started: + # Start the generator so it waits at the first yield point + gen.gen_send(None) + + return gen + + +def allow_nested_run(coro): + """ + Wrap the coroutine ``coro`` such that nested calls to :func:`run` will be + allowed. + + .. warning:: The coroutine needs to be consumed in the same OS thread it + was created in. + """ + return _allow_nested_run(coro, loop=None) + + +def _allow_nested_run(coro, loop=None): + return _do_allow_nested_run(coro) + + +def _do_allow_nested_run(coro): + return _AwaitableGenlet.wrap_coro(coro) + + +# This thread runs coroutines that cannot be ran on the event loop in the +# current thread. Instead, they are scheduled in a separate thread where +# another event loop has been setup, so we can wrap coroutines before +# dispatching them there. +_CORO_THREAD_EXECUTOR = ThreadPoolExecutor(max_workers=1) +def _coro_thread_f(coro): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + _install_task_factory(loop) + # The coroutine needs to be wrapped in the same thread that will consume it, + coro = _allow_nested_run(coro, loop) + return loop.run_until_complete(coro) + + +def _run_in_thread(coro): + # This is a truly blocking operation, which will block the caller's event + # loop. However, this also prevents most thread safety issues as the + # calling code will not run concurrently with the coroutine. We also don't + # have a choice anyway. + future = _CORO_THREAD_EXECUTOR.submit(_coro_thread_f, coro) + return future.result() + + +_PATCHED_LOOP_LOCK = threading.Lock() +_PATCHED_LOOP = WeakSet() + +def _install_task_factory(loop): + """ + Install a task factory on the given event ``loop`` so that top-level + coroutines are wrapped using :func:`allow_nested_run`. This ensures that + the nested :func:`run` infrastructure will be available. + """ + def install(loop): + if sys.version_info >= (3, 11): + def default_factory(loop, coro, context=None): + return asyncio.Task(coro, loop=loop, context=context) + else: + def default_factory(loop, coro, context=None): + return asyncio.Task(coro, loop=loop) + + make_task = loop.get_task_factory() or default_factory + def factory(loop, coro, context=None): + coro = _allow_nested_run(coro, loop) + return make_task(loop, coro, context=context) + + loop.set_task_factory(factory) + + with _PATCHED_LOOP_LOCK: + if loop in _PATCHED_LOOP: + return + else: + install(loop) + _PATCHED_LOOP.add(loop) + + +def _patch_current_loop(): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + pass + else: + _install_task_factory(loop) + + +# Patch the currently running event loop if any, to increase the chances of not +# having to use the _CORO_THREAD_EXECUTOR +_patch_current_loop() + + +def run(coro): + """ + Similar to :func:`asyncio.run` but can be called while an event loop is + running if a coroutine higher in the callstack has been wrapped using + :func:`allow_nested_run`. + """ + + # Ensure we have a fresh coroutine. inspect.getcoroutinestate() does not + # work on all objects that asyncio creates on some version of Python, such + # as iterable_coroutine + assert not coro.cr_running + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + # We are not currently running an event loop, so it's ok to just use + # asyncio.run() and let it create one. + # Once the coroutine is wrapped, we will be able to yield across + # blocking function boundaries thanks to _Genlet + return asyncio.run(_do_allow_nested_run(coro)) + else: + return _run_in_loop(loop, coro) + + +def _run_in_loop(loop, coro): + # Increase the odds that in the future, we have a wrapped coroutine in + # our callstack to avoid the _run_in_thread() path. + _install_task_factory(loop) + + if loop.is_running(): + g = _Genlet.get_enclosing() + if g is None: + # If we are not running under a wrapped coroutine, we don't + # have a choice and we need to run in a separate event loop. We + # cannot just create another event loop and install it, as + # asyncio forbids that, so the only choice is doing this in a + # separate thread that we fully control. + return _run_in_thread(coro) + else: + # This requires that we have an coroutine wrapped with + # allow_nested_run() higher in the callstack, that we will be + # able to use as a conduit to yield the futures. + return g.consume_coro(coro, None) + else: + # In the odd case a loop was installed but is not running, we just + # use it. With _install_task_factory(), we should have the + # top-level Task run an instrumented coroutine (wrapped with + # allow_nested_run()) + return loop.run_until_complete(coro) + + def asyncf(f): """ Decorator used to turn a coroutine into a blocking function, with an @@ -328,14 +612,14 @@ def asyncf(f): asyncgen = x.__aiter__() while True: try: - yield asyncio.run(asyncgen.__anext__()) + yield run(asyncgen.__anext__()) except StopAsyncIteration: return return genf() else: return await x - return asyncio.run(wrapper()) + return run(wrapper()) return _AsyncPolymorphicFunction( asyn=f, @@ -348,8 +632,35 @@ class _AsyncPolymorphicCM: Wrap an async context manager such that it exposes a synchronous API as well for backward compatibility. """ + _nested = threading.local() + + def _get_nesting(self): + try: + return self._nested.x + except AttributeError: + self._nested.x = 0 + return 0 + + def _update_nesting(self, n): + x = self._get_nesting() + n + self._nested.x = x + return bool(x) + def __init__(self, async_cm): self.cm = async_cm + self._loop = None + + def _close_loop(self): + reentered = self._update_nesting(0) + if not reentered: + loop = self._loop + self._loop = None + if loop is not None: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.run_until_complete( + loop.shutdown_default_executor() + ) + loop.close() def __aenter__(self, *args, **kwargs): return self.cm.__aenter__(*args, **kwargs) @@ -358,10 +669,37 @@ class _AsyncPolymorphicCM: return self.cm.__aexit__(*args, **kwargs) def __enter__(self, *args, **kwargs): - return asyncio.run(self.cm.__aenter__(*args, **kwargs)) + self._update_nesting(1) + coro = self.cm.__aenter__(*args, **kwargs) + # If there is already a running loop, no need to create a new one + try: + asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + self._loop = loop + try: + asyncio.set_event_loop(loop) + return _run_in_loop(loop, coro) + except BaseException: + self._close_loop() + raise + else: + return run(coro) def __exit__(self, *args, **kwargs): - return asyncio.run(self.cm.__aexit__(*args, **kwargs)) + try: + self._update_nesting(-1) + coro = self.cm.__aexit__(*args, **kwargs) + loop = self._loop + if loop is None: + return run(coro) + else: + return _run_in_loop(loop, coro) + finally: + self._close_loop() + + def __del__(self): + self._close_loop() def asynccontextmanager(f): diff --git a/external/devlib/setup.py b/external/devlib/setup.py index e8b7d0fbea147e42ec93ddd71775de3e62421f1b..cba25a26b1512dc2cb3b35da357123992e03911b 100644 --- a/external/devlib/setup.py +++ b/external/devlib/setup.py @@ -105,6 +105,7 @@ params = dict( 'pytest', 'lxml', # More robust xml parsing 'nest_asyncio', # Allows running nested asyncio loops + 'greenlet', # Allows running nested asyncio loops 'future', # for the "past" Python package 'ruamel.yaml >= 0.15.72', # YAML formatted config parsing ], @@ -113,6 +114,9 @@ params = dict( 'doc': ['sphinx'], 'monsoon': ['python-gflags'], 'acme': ['pandas', 'numpy'], + 'dev': [ + 'uvloop', # Test async features under uvloop + ] }, # https://pypi.python.org/pypi?%3Aaction=list_classifiers classifiers=[ diff --git a/external/devlib/tests/test_asyn.py b/external/devlib/tests/test_asyn.py new file mode 100644 index 0000000000000000000000000000000000000000..9e10941e2902f46c21f25ac74f168bcbdcd297f6 --- /dev/null +++ b/external/devlib/tests/test_asyn.py @@ -0,0 +1,535 @@ +# +# Copyright 2024 ARM Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import asyncio +from functools import partial +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager + +from pytest import skip, raises + +from devlib.utils.asyn import run, asynccontextmanager + + +class AsynTestExcep(Exception): + pass + + +class Awaitable: + def __await__(self): + return (yield self) + + +@contextmanager +def raises_and_bubble(cls): + try: + yield + except BaseException as e: + if isinstance(e, cls): + raise + else: + raise AssertionError(f'Did not raise instance of {cls}') + else: + raise AssertionError(f'Did not raise any exception') + + +@contextmanager +def coro_stop_iteration(x): + try: + yield + except StopIteration as e: + assert e.value == x + except BaseException: + raise + else: + raise AssertionError('Coroutine did not finish') + + +def _do_test_run(top_run): + + async def test_run_basic(): + + async def f(): + return 42 + + assert run(f()) == 42 + + top_run(test_run_basic()) + + + async def test_run_basic_raise(): + + async def f(): + raise AsynTestExcep + + with raises(AsynTestExcep): + run(f()) + + top_run(test_run_basic_raise()) + + + async def test_run_basic_await(): + async def nested(): + return 42 + + async def f(): + return await nested() + + assert run(f()) == 42 + + top_run(test_run_basic_await()) + + + async def test_run_basic_await_raise(): + async def nested(): + raise AsynTestExcep + + async def f(): + with raises_and_bubble(AsynTestExcep): + return await nested() + + with raises(AsynTestExcep): + run(f()) + + top_run(test_run_basic_await_raise()) + + + async def test_run_nested1(): + async def nested(): + return 42 + + async def f(): + return run(nested()) + + assert run(f()) == 42 + + top_run(test_run_nested1()) + + + async def test_run_nested1_raise(): + async def nested(): + raise AsynTestExcep + + async def f(): + with raises_and_bubble(AsynTestExcep): + return run(nested()) + + with raises(AsynTestExcep): + run(f()) + + top_run(test_run_nested1_raise()) + + + async def test_run_nested2(): + async def nested2(): + return 42 + + async def nested1(): + return run(nested2()) + + async def f(): + return run(nested1()) + + assert run(f()) == 42 + + top_run(test_run_nested2()) + + + async def test_run_nested2_raise(): + async def nested2(): + raise AsynTestExcep + + async def nested1(): + with raises_and_bubble(AsynTestExcep): + return run(nested2()) + + async def f(): + with raises_and_bubble(AsynTestExcep): + return run(nested1()) + + with raises(AsynTestExcep): + run(f()) + + top_run(test_run_nested2_raise()) + + + async def test_run_nested2_block(): + async def nested2(): + return 42 + + def nested1(): + return run(nested2()) + + async def f(): + return nested1() + + assert run(f()) == 42 + + top_run(test_run_nested2_block()) + + + async def test_run_nested2_block_raise(): + async def nested2(): + raise AsynTestExcep + + def nested1(): + with raises_and_bubble(AsynTestExcep): + return run(nested2()) + + async def f(): + with raises_and_bubble(AsynTestExcep): + return nested1() + + with raises(AsynTestExcep): + run(f()) + + top_run(test_run_nested2_block_raise()) + + + + async def test_coro_send(): + async def f(): + return await Awaitable() + + coro = f() + coro.send(None) + + with coro_stop_iteration(42): + coro.send(42) + + top_run(test_coro_send()) + + + async def test_coro_nested_send(): + async def nested(): + return await Awaitable() + + async def f(): + return await nested() + + coro = f() + coro.send(None) + + with coro_stop_iteration(42): + coro.send(42) + + top_run(test_coro_nested_send()) + + + async def test_coro_nested_send2(): + future = asyncio.Future() + future.set_result(42) + + async def nested(): + return await future + + async def f(): + return run(nested()) + + assert run(f()) == 42 + + top_run(test_coro_nested_send2()) + + + async def test_coro_nested_send3(): + future = asyncio.Future() + future.set_result(42) + + async def nested2(): + return await future + + async def nested(): + return run(nested2()) + + async def f(): + return run(nested()) + + assert run(f()) == 42 + + top_run(test_coro_nested_send3()) + + + async def test_coro_throw(): + async def f(): + try: + await Awaitable() + except AsynTestExcep: + return 42 + + coro = f() + coro.send(None) + + with coro_stop_iteration(42): + coro.throw(AsynTestExcep) + + top_run(test_coro_throw()) + + + async def test_coro_throw2(): + async def f(): + await Awaitable() + + coro = f() + coro.send(None) + + with raises(AsynTestExcep): + coro.throw(AsynTestExcep) + + top_run(test_coro_throw2()) + + + async def test_coro_nested_throw(): + async def nested(): + try: + await Awaitable() + except AsynTestExcep: + return 42 + + async def f(): + return await nested() + + coro = f() + coro.send(None) + + with coro_stop_iteration(42): + coro.throw(AsynTestExcep) + + top_run(test_coro_nested_throw()) + + + async def test_coro_nested_throw2(): + async def nested(): + await Awaitable() + + async def f(): + with raises_and_bubble(AsynTestExcep): + await nested() + + coro = f() + coro.send(None) + + with raises(AsynTestExcep): + coro.throw(AsynTestExcep) + + top_run(test_coro_nested_throw2()) + + + async def test_coro_nested_throw3(): + future = asyncio.Future() + future.set_exception(AsynTestExcep()) + + async def nested(): + await future + + async def f(): + with raises_and_bubble(AsynTestExcep): + run(nested()) + + with raises(AsynTestExcep): + run(f()) + + top_run(test_coro_nested_throw3()) + + + async def test_coro_nested_throw4(): + future = asyncio.Future() + future.set_exception(AsynTestExcep()) + + async def nested2(): + await future + + async def nested(): + return run(nested2()) + + async def f(): + with raises_and_bubble(AsynTestExcep): + run(nested()) + + with raises(AsynTestExcep): + run(f()) + + top_run(test_coro_nested_throw4()) + + async def test_async_cm(): + state = None + + async def f(): + return 43 + + @asynccontextmanager + async def cm(): + nonlocal state + state = 'started' + await f() + try: + yield 42 + finally: + await f() + state = 'finished' + + async with cm() as x: + assert state == 'started' + assert x == 42 + + assert state == 'finished' + + top_run(test_async_cm()) + + async def test_async_cm2(): + state = None + + async def f(): + return 43 + + @asynccontextmanager + async def cm(): + nonlocal state + state = 'started' + await f() + try: + await f() + yield 42 + await f() + except AsynTestExcep: + await f() + # Swallow the exception + pass + finally: + await f() + state = 'finished' + + async with cm() as x: + assert state == 'started' + raise AsynTestExcep() + + assert state == 'finished' + + top_run(test_async_cm2()) + + async def test_async_cm3(): + state = None + + async def f(): + return 43 + + @asynccontextmanager + async def cm(): + nonlocal state + state = 'started' + await f() + try: + yield 42 + finally: + await f() + state = 'finished' + + with cm() as x: + assert state == 'started' + assert x == 42 + + assert state == 'finished' + + top_run(test_async_cm3()) + + def test_async_cm4(): + state = None + + async def f(): + return 43 + + @asynccontextmanager + async def cm(): + nonlocal state + state = 'started' + await f() + try: + yield 42 + finally: + await f() + state = 'finished' + + with cm() as x: + assert state == 'started' + assert x == 42 + + assert state == 'finished' + + test_async_cm4() + + +def _test_in_thread(setup, test): + def f(): + with setup() as run: + return test() + + with ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(f).result() + + +def _test_run_with_setup(setup): + def run_with_existing_loop(coro): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + # Simulate case where devlib is ran in a context where the main app has + # set an event loop at some point + return asyncio.run(coro) + + def run_with_existing_loop2(coro): + # This is similar to how things are executed on IPython/jupyterlab + loop = asyncio.new_event_loop() + return loop.run_until_complete(coro) + + runners = [ + run, + asyncio.run, + run_with_existing_loop, + run_with_existing_loop2, + ] + + for top_run in runners: + _test_in_thread( + setup, + partial(_do_test_run, top_run), + ) + + +def test_run_stdlib(): + @contextmanager + def setup(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield asyncio.run + + _test_run_with_setup(setup) + + +def test_run_uvloop(): + try: + import uvloop + except ImportError: + skip('uvloop not installed') + else: + @contextmanager + def setup(): + if sys.version_info >= (3, 11): + with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner: + yield runner.run + else: + uvloop.install() + yield asyncio.run + + _test_run_with_setup(setup) diff --git a/external/subtrees.conf b/external/subtrees.conf index b734e4dcf23fc918be3f25b7ab241a384449ffb9..6bbe1587e3d0f6990586e294afcc34bc6e58f682 100644 --- a/external/subtrees.conf +++ b/external/subtrees.conf @@ -1,7 +1,12 @@ [devlib] path = external/devlib -url = https://github.com/ARM-Software/devlib.git -ref = master +# url = https://github.com/ARM-Software/devlib.git +# ref = master + +url = https://github.com/douglas-raillard-arm/devlib.git +# Dogfooding on our PR +ref = fix_nest_asyncio + # # See external/devlib.manifest.yaml for instructions on how to build this # branch: @@ -11,4 +16,4 @@ ref = master [workload-automation] path = external/workload-automation url = https://github.com/ARM-Software/workload-automation.git -ref = master \ No newline at end of file +ref = master