asyncscope


Nameasyncscope JSON
Version 0.11.2 PyPI version JSON
download
home_pagehttp://github.com/M-o-a-T/asyncscope
SummaryTask scopes for AnyIO
upload_time2024-04-08 13:48:18
maintainerNone
docs_urlNone
authorMatthias Urlichs
requires_python>=3.6
licenseGPLv3 or later
keywords async
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            ==========
AsyncScope
==========

This library implements scoped taskgroups / nurseries.


Rationale
=========

Composability
+++++++++++++

Large programs often consist of building blocks which depend on each other.
Those dependencies may be non-trivial, aren't always linear, and generally
form some sort of directed acyclic graph instead of a nice linear or
hierarchical set of relationships.

Let's invent an example.

Your server contains some admin module, which requires a support library,
which connects to a database. Halfway through it encounters an error, thus
loads an error handler, which also uses the database.

Next, a client of your server does some shady stuff so you want to log
that, and since loading the error handler and connecting to the database is
expensive you want to re-use the handler you already have.

Later the admin code terminates. However, it shouldn't unload the error
handler, because that other code still needs it.

This is a problem because you like to use Structured Programming
principles, which state that if you started it you need to stop it.
Thus, you need to jump through some hoops getting all of this connected up,
keeping track of each module's users, and shutting things down in the
correct order.

Worse: let's say that your code dies with a fatal exception. That exception
typically propagates through all of your code and thus tends to cancel the
database connection before the error handler has a chance to log the
problem. Worse, if the error from the logger occurs in a ``finally:`` block
it basically replaces the original exception, so you'll have a lot of fun
trying to debug all of this.

AsyncScope can help you.

AsyncScope keeps track of your program's building blocks. It remembers
which parts depend on which other parts, prevents cyclic dependencies,
and terminates a scope as soon as nobody uses it any more.

Now your error handler stays around exactly as long as you need it, your
database connection won't die while the error handler (or any other code)
requires it, your error gets logged correctly, and you find the problem.


Multiple services
+++++++++++++++++

Some programs need any number of async contexts, e.g. a client that talks
to any number of servers within the same method. Creating server contexts
is often awkward under these circumstances; you need a subtask or an
`contextlib.AsyncExitStack` to act as the contexts' "keeper". However,
setting up a subtask is a lot of boilerplate; an exit stack doesn't help
when you need to dynamically remove servers.

`AsyncScope` helps by decoupling code structure from service usage, while
ensuring that external connections and other subtasks are not duplicated
*and* ended cleanly when their last user terminates.


Usage
=====

Main code
+++++++++

Wrap your main code in an ``async with asyncscope.main_scope('NAME'): ...``
block. (The name defaults to ``_main`` if omitted.)

This call initializes the global `AsyncScope.scope` object. It always
refers to the current service (i.e. initially, your main code).


Wrapping services
+++++++++++++++++

A "service" is defined as any nontrivial object that might be used from
multiple contexts within your program. That can be a HTTP session, or a
database connection, any object that's non-trivial to create, …

`AsyncScope` requires wrapping your service in a function with common
calling conventions because it doesn't know (indeed doesn't want to know)
the details of how to set up and tear down your service objects.

Here are some examples.

If the service uses a ``run`` method, you'd do this::

   from asyncscope import scope

   async def some_service(*p, **kw):
      srv = your_service(*p, **kw)
      async with anyio.create_task_group() as tg:
         await tg.start(srv.run)

         scope.register(srv)
         await scope.no_more_dependents()

         await srv.stop_running()
         tg.cancel_scope.cancel()

Alternately, if the service runs as an async context manager::

   from asyncscope import scope

   async def some_service(*p, **kw):
      async with your_service(*p, **kw) as srv:
         # NB: some services use "async with await …"
         scope.register(srv)
         await scope.no_more_dependents()

Alternately², it might run as a context-free background service::

   from asyncscope import scope

   async def some_service(*p, **kw):
      srv = your_service(*p, **kw)
      srv = await srv.start()

      scope.register(srv)
      await scope.no_more_dependents()

      await srv.aclose()

Alternately³, if the service is an annoying-to-set-up object::

   from asyncscope import scope

   async def some_service(*p, **kw):
      srv = SomeObject(*p, **kw)
      await SomeObject.costly_setup()

      scope.register(srv)
      try:
         await scope.no_more_dependents()
      finally:
         srv.teardown()
      # use this to e.g. clean up circular references within your object


Next, we'll see how to use these objects.


Using services
++++++++++++++

Using `AsyncScope`, a service is used in one of two ways.

* within a context::

    from asyncscope import scope

    async with scope.using_scope():
        srv = await scope.service(name, some_service, *p, **kw)
        ...

* until the caller's scope ends *or* you explicitly release it::

    from asyncscope import scope

    srv = await scope.service(name, some_service, *p, **kw)
    ...
    del srv  # don't hog the memory!
    scope.release(name)

You can also check whether a named service exists::

    from asyncscope import scope

    try:
        srv = scope.lookup(name)
    except KeyError:
        pass  # no it does not
    else:
        ...
        del srv
        scope.release(name)

In all three cases ``srv`` is the object that your ``some_service`` code has
passed to `AsyncScope.Scope.register`.

.. note::

    `Scope.lookup` raises `KeyError` if the scope is currently being
    set up. The other methods wait for the service's call to `Scope.register`.


Service naming
++++++++++++++

AsyncScope uses ``name`` to discover whether the service is already up and
running. If so, it records that the current scope is also using this named
service and simply returns it.

Names must be globally unique. To avoid collisions, add your object class,
an idenifier like ``id(YourServiceClass)``, or ``id(container_object)``
to it, depending on usage.

`AsyncScope` does not try to derive uniqueness from its parameters, because
arbitrary naming conventions are unlikely to work for everybody. One easy
way to disambiguate potential collisions is to include
``id(some_service)`` in the name.

Implications
++++++++++++

Calling `Scope.service` or `Scope.using_service` does not guarantee that
the service in question will start when you do: it might have been running
already. Likewise, leaving the ``async with`` block or exiting the caller's
scope may not stop the service: there might be other users, or some caching
mechanism that delays closing it.

Calling these functions twice / nesting `Scope.using_service` calls is OK.
Usage cycles (service A starts service B which later requires A) are
forbidden and will be detected.

Every scope contains a taskgroup which you can access using the usual
``start`` and ``start_soon`` methods. You can also call ``scope.spawn()``.
This function returns a ``CancelScope`` that wraps the new tasks, so you
can cancel it if you need to. All tasks started this way are also
auto-cancelled when the scope exits.

Your ``some_service`` code **must** call ``scope.register()`` exactly once,
otherwise the scopes waiting for it to start will wait forever. (They'll
get cancelled if your scope's main task exits before doing so.)

The current scope is available as the ``scope`` context variable.

The ``examples`` directory contains some sample code.


Loggging
++++++++

``scope.logger`` is a standard `logging.Logger` object, named ``scope.NAME``.


Multithreading
++++++++++++++

`AsyncScope` is **not** compatible with multithreading. Using a single main
scope from multiple threads *will* cause inconsistent data, deadlocks,
and/or other hard-to-find bugs.

If you start a separate async mainloop in a new thread, you must call
``scope.thread_reset()`` before entering the thread's main scope. You also
should pass a thread-specific name to `main_scope`.

Do not share services between threads. They are typically not
multithreading-aware and `AsyncScope` might terminate them at any time.


Exception handling
==================

This section describes the effects of an exception that escapes from a
service's main task, causing it to terminate.

Errors that are subclasses of `BaseException` but not `Exception` are
never caught. If the service did not yet call `Scope.register` they may
receive either a `concurrent.Futures.CancelledError`, or a cancellation
exception from the async framework.

`Exception`\s raised after the service called `Scope.register` are not
handled. They will ultimately propagate out of the `AsyncScope.main_scope`
block.

Otherwise the error are propagated to the caller(s) that are waiting
for its `Scope.register` call.

Otherwise the exception is left unhandled; the effects are described in the
nest section.

Cancellation semantics
======================

When a scope exits (either cleanly or when it raises an error that escapes
its taskgroup), the scopes depending on it are cancelled immediately, in
parallel. Then, those it itself depends on are terminated cleanly and
in-order, assuming they're not used by some other scope.

This also happens when a scope's main task ends.

"Clean termination" means that the scope's call to ``no_more_dependents()``
returns. If there is no such call open, the scope's tasks are cancelled.


TODO: write a service which your code can use to keep another service alive
for a bit.


Code structure
==============

A scope's main code typically looks like this:

* do whatever you need to start the service. This code may start other
  scopes it depends on. Note that if the scope is already running,
  ``service`` simply returns its existing service object.

* call ``scope.register(service_object)``

* call ``await scope.no_more_dependents()`` (subordinate task) or wait for SIGTERM (daemon main task)
  or terminate (main task's job is done)

* cleanly stop your service.

If ``no_more_dependents`` is not used, your code will be cancelled instead.

Scopes typically don't need to access their own scope object. It's stored in
a contextvar and can be retrieved via ``scope.get()`` if you need it.
For most uses, however, ``asyncscope``'s global ``scope`` object accesses
the current scope transparently.

Temporary services
++++++++++++++++++

Some services don't need to be running all the time. To release a service
early, use ``async with scope.using_scope():``. This creates an embedded scope.
Services started within an embedded scope are auto-released when its
context exits, assuming (as usual) that no other code uses them.

If a scope handler that's used by an embedded scope exits, the code running
in the embedded scope is cancelled as usual. Leaving the embedded scope
then triggers a `ScopeDied` exception.

            

Raw data

            {
    "_id": null,
    "home_page": "http://github.com/M-o-a-T/asyncscope",
    "name": "asyncscope",
    "maintainer": null,
    "docs_url": null,
    "requires_python": ">=3.6",
    "maintainer_email": null,
    "keywords": "async",
    "author": "Matthias Urlichs",
    "author_email": "matthias@urlichs.de",
    "download_url": "https://files.pythonhosted.org/packages/c8/71/7e3049937fb572d743aec96d24b8a33c73a76857aedde0e3fa0f1caac3b6/asyncscope-0.11.2.tar.gz",
    "platform": null,
    "description": "==========\nAsyncScope\n==========\n\nThis library implements scoped taskgroups / nurseries.\n\n\nRationale\n=========\n\nComposability\n+++++++++++++\n\nLarge programs often consist of building blocks which depend on each other.\nThose dependencies may be non-trivial, aren't always linear, and generally\nform some sort of directed acyclic graph instead of a nice linear or\nhierarchical set of relationships.\n\nLet's invent an example.\n\nYour server contains some admin module, which requires a support library,\nwhich connects to a database. Halfway through it encounters an error, thus\nloads an error handler, which also uses the database.\n\nNext, a client of your server does some shady stuff so you want to log\nthat, and since loading the error handler and connecting to the database is\nexpensive you want to re-use the handler you already have.\n\nLater the admin code terminates. However, it shouldn't unload the error\nhandler, because that other code still needs it.\n\nThis is a problem because you like to use Structured Programming\nprinciples, which state that if you started it you need to stop it.\nThus, you need to jump through some hoops getting all of this connected up,\nkeeping track of each module's users, and shutting things down in the\ncorrect order.\n\nWorse: let's say that your code dies with a fatal exception. That exception\ntypically propagates through all of your code and thus tends to cancel the\ndatabase connection before the error handler has a chance to log the\nproblem. Worse, if the error from the logger occurs in a ``finally:`` block\nit basically replaces the original exception, so you'll have a lot of fun\ntrying to debug all of this.\n\nAsyncScope can help you.\n\nAsyncScope keeps track of your program's building blocks. It remembers\nwhich parts depend on which other parts, prevents cyclic dependencies,\nand terminates a scope as soon as nobody uses it any more.\n\nNow your error handler stays around exactly as long as you need it, your\ndatabase connection won't die while the error handler (or any other code)\nrequires it, your error gets logged correctly, and you find the problem.\n\n\nMultiple services\n+++++++++++++++++\n\nSome programs need any number of async contexts, e.g. a client that talks\nto any number of servers within the same method. Creating server contexts\nis often awkward under these circumstances; you need a subtask or an\n`contextlib.AsyncExitStack` to act as the contexts' \"keeper\". However,\nsetting up a subtask is a lot of boilerplate; an exit stack doesn't help\nwhen you need to dynamically remove servers.\n\n`AsyncScope` helps by decoupling code structure from service usage, while\nensuring that external connections and other subtasks are not duplicated\n*and* ended cleanly when their last user terminates.\n\n\nUsage\n=====\n\nMain code\n+++++++++\n\nWrap your main code in an ``async with asyncscope.main_scope('NAME'): ...``\nblock. (The name defaults to ``_main`` if omitted.)\n\nThis call initializes the global `AsyncScope.scope` object. It always\nrefers to the current service (i.e. initially, your main code).\n\n\nWrapping services\n+++++++++++++++++\n\nA \"service\" is defined as any nontrivial object that might be used from\nmultiple contexts within your program. That can be a HTTP session, or a\ndatabase connection, any object that's non-trivial to create, \u2026\n\n`AsyncScope` requires wrapping your service in a function with common\ncalling conventions because it doesn't know (indeed doesn't want to know)\nthe details of how to set up and tear down your service objects.\n\nHere are some examples.\n\nIf the service uses a ``run`` method, you'd do this::\n\n   from asyncscope import scope\n\n   async def some_service(*p, **kw):\n      srv = your_service(*p, **kw)\n      async with anyio.create_task_group() as tg:\n         await tg.start(srv.run)\n\n         scope.register(srv)\n         await scope.no_more_dependents()\n\n         await srv.stop_running()\n         tg.cancel_scope.cancel()\n\nAlternately, if the service runs as an async context manager::\n\n   from asyncscope import scope\n\n   async def some_service(*p, **kw):\n      async with your_service(*p, **kw) as srv:\n         # NB: some services use \"async with await \u2026\"\n         scope.register(srv)\n         await scope.no_more_dependents()\n\nAlternately\u00b2, it might run as a context-free background service::\n\n   from asyncscope import scope\n\n   async def some_service(*p, **kw):\n      srv = your_service(*p, **kw)\n      srv = await srv.start()\n\n      scope.register(srv)\n      await scope.no_more_dependents()\n\n      await srv.aclose()\n\nAlternately\u00b3, if the service is an annoying-to-set-up object::\n\n   from asyncscope import scope\n\n   async def some_service(*p, **kw):\n      srv = SomeObject(*p, **kw)\n      await SomeObject.costly_setup()\n\n      scope.register(srv)\n      try:\n         await scope.no_more_dependents()\n      finally:\n         srv.teardown()\n      # use this to e.g. clean up circular references within your object\n\n\nNext, we'll see how to use these objects.\n\n\nUsing services\n++++++++++++++\n\nUsing `AsyncScope`, a service is used in one of two ways.\n\n* within a context::\n\n    from asyncscope import scope\n\n    async with scope.using_scope():\n        srv = await scope.service(name, some_service, *p, **kw)\n        ...\n\n* until the caller's scope ends *or* you explicitly release it::\n\n    from asyncscope import scope\n\n    srv = await scope.service(name, some_service, *p, **kw)\n    ...\n    del srv  # don't hog the memory!\n    scope.release(name)\n\nYou can also check whether a named service exists::\n\n    from asyncscope import scope\n\n    try:\n        srv = scope.lookup(name)\n    except KeyError:\n        pass  # no it does not\n    else:\n        ...\n        del srv\n        scope.release(name)\n\nIn all three cases ``srv`` is the object that your ``some_service`` code has\npassed to `AsyncScope.Scope.register`.\n\n.. note::\n\n    `Scope.lookup` raises `KeyError` if the scope is currently being\n    set up. The other methods wait for the service's call to `Scope.register`.\n\n\nService naming\n++++++++++++++\n\nAsyncScope uses ``name`` to discover whether the service is already up and\nrunning. If so, it records that the current scope is also using this named\nservice and simply returns it.\n\nNames must be globally unique. To avoid collisions, add your object class,\nan idenifier like ``id(YourServiceClass)``, or ``id(container_object)``\nto it, depending on usage.\n\n`AsyncScope` does not try to derive uniqueness from its parameters, because\narbitrary naming conventions are unlikely to work for everybody. One easy\nway to disambiguate potential collisions is to include\n``id(some_service)`` in the name.\n\nImplications\n++++++++++++\n\nCalling `Scope.service` or `Scope.using_service` does not guarantee that\nthe service in question will start when you do: it might have been running\nalready. Likewise, leaving the ``async with`` block or exiting the caller's\nscope may not stop the service: there might be other users, or some caching\nmechanism that delays closing it.\n\nCalling these functions twice / nesting `Scope.using_service` calls is OK.\nUsage cycles (service A starts service B which later requires A) are\nforbidden and will be detected.\n\nEvery scope contains a taskgroup which you can access using the usual\n``start`` and ``start_soon`` methods. You can also call ``scope.spawn()``.\nThis function returns a ``CancelScope`` that wraps the new tasks, so you\ncan cancel it if you need to. All tasks started this way are also\nauto-cancelled when the scope exits.\n\nYour ``some_service`` code **must** call ``scope.register()`` exactly once,\notherwise the scopes waiting for it to start will wait forever. (They'll\nget cancelled if your scope's main task exits before doing so.)\n\nThe current scope is available as the ``scope`` context variable.\n\nThe ``examples`` directory contains some sample code.\n\n\nLoggging\n++++++++\n\n``scope.logger`` is a standard `logging.Logger` object, named ``scope.NAME``.\n\n\nMultithreading\n++++++++++++++\n\n`AsyncScope` is **not** compatible with multithreading. Using a single main\nscope from multiple threads *will* cause inconsistent data, deadlocks,\nand/or other hard-to-find bugs.\n\nIf you start a separate async mainloop in a new thread, you must call\n``scope.thread_reset()`` before entering the thread's main scope. You also\nshould pass a thread-specific name to `main_scope`.\n\nDo not share services between threads. They are typically not\nmultithreading-aware and `AsyncScope` might terminate them at any time.\n\n\nException handling\n==================\n\nThis section describes the effects of an exception that escapes from a\nservice's main task, causing it to terminate.\n\nErrors that are subclasses of `BaseException` but not `Exception` are\nnever caught. If the service did not yet call `Scope.register` they may\nreceive either a `concurrent.Futures.CancelledError`, or a cancellation\nexception from the async framework.\n\n`Exception`\\s raised after the service called `Scope.register` are not\nhandled. They will ultimately propagate out of the `AsyncScope.main_scope`\nblock.\n\nOtherwise the error are propagated to the caller(s) that are waiting\nfor its `Scope.register` call.\n\nOtherwise the exception is left unhandled; the effects are described in the\nnest section.\n\nCancellation semantics\n======================\n\nWhen a scope exits (either cleanly or when it raises an error that escapes\nits taskgroup), the scopes depending on it are cancelled immediately, in\nparallel. Then, those it itself depends on are terminated cleanly and\nin-order, assuming they're not used by some other scope.\n\nThis also happens when a scope's main task ends.\n\n\"Clean termination\" means that the scope's call to ``no_more_dependents()``\nreturns. If there is no such call open, the scope's tasks are cancelled.\n\n\nTODO: write a service which your code can use to keep another service alive\nfor a bit.\n\n\nCode structure\n==============\n\nA scope's main code typically looks like this:\n\n* do whatever you need to start the service. This code may start other\n  scopes it depends on. Note that if the scope is already running,\n  ``service`` simply returns its existing service object.\n\n* call ``scope.register(service_object)``\n\n* call ``await scope.no_more_dependents()`` (subordinate task) or wait for SIGTERM (daemon main task)\n  or terminate (main task's job is done)\n\n* cleanly stop your service.\n\nIf ``no_more_dependents`` is not used, your code will be cancelled instead.\n\nScopes typically don't need to access their own scope object. It's stored in\na contextvar and can be retrieved via ``scope.get()`` if you need it.\nFor most uses, however, ``asyncscope``'s global ``scope`` object accesses\nthe current scope transparently.\n\nTemporary services\n++++++++++++++++++\n\nSome services don't need to be running all the time. To release a service\nearly, use ``async with scope.using_scope():``. This creates an embedded scope.\nServices started within an embedded scope are auto-released when its\ncontext exits, assuming (as usual) that no other code uses them.\n\nIf a scope handler that's used by an embedded scope exits, the code running\nin the embedded scope is cancelled as usual. Leaving the embedded scope\nthen triggers a `ScopeDied` exception.\n",
    "bugtrack_url": null,
    "license": "GPLv3 or later",
    "summary": "Task scopes for AnyIO",
    "version": "0.11.2",
    "project_urls": {
        "Homepage": "http://github.com/M-o-a-T/asyncscope"
    },
    "split_keywords": [
        "async"
    ],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "71671803021d9c9882f8d6cb614a7c60a4f7d3771bb5e8743937408bcce7addb",
                "md5": "664a352f8e6ed3aed7daf87b6a7e2877",
                "sha256": "f387ca6aacb420ee84c99495c14498e5ad23bb2f65a36b2ea891d76843f4c835"
            },
            "downloads": -1,
            "filename": "asyncscope-0.11.2-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "664a352f8e6ed3aed7daf87b6a7e2877",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.6",
            "size": 13667,
            "upload_time": "2024-04-08T13:48:16",
            "upload_time_iso_8601": "2024-04-08T13:48:16.115464Z",
            "url": "https://files.pythonhosted.org/packages/71/67/1803021d9c9882f8d6cb614a7c60a4f7d3771bb5e8743937408bcce7addb/asyncscope-0.11.2-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "c8717e3049937fb572d743aec96d24b8a33c73a76857aedde0e3fa0f1caac3b6",
                "md5": "c65f77cb1a096de855caa6f3cb121244",
                "sha256": "b9d18521b74dae9d30ed44b158d543e9826473a06840b378cb53cf9825bd3013"
            },
            "downloads": -1,
            "filename": "asyncscope-0.11.2.tar.gz",
            "has_sig": false,
            "md5_digest": "c65f77cb1a096de855caa6f3cb121244",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.6",
            "size": 18151,
            "upload_time": "2024-04-08T13:48:18",
            "upload_time_iso_8601": "2024-04-08T13:48:18.172467Z",
            "url": "https://files.pythonhosted.org/packages/c8/71/7e3049937fb572d743aec96d24b8a33c73a76857aedde0e3fa0f1caac3b6/asyncscope-0.11.2.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2024-04-08 13:48:18",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "M-o-a-T",
    "github_project": "asyncscope",
    "travis_ci": false,
    "coveralls": false,
    "github_actions": false,
    "lcname": "asyncscope"
}
        
Elapsed time: 0.21972s