Repository: Textualize/textual
Pull request: fix object leak
Fixes https://github.com/Textualize/textual/issues/4959

This turned out to be quite a rabbit hole. I fixed a class of problems where something was holding on to references to DOM nodes, and preventing garbage collection.

One culprit was `count_parameters`. The `@lru_cache` was holding on to a reference chain. I refactored that method to cache in a different way.

Caching or cache-like structures were also holding on to references. I've added clearing of these "caches" to make garbage collection more prompt.

I've made some references weak references, to prevent potential reference cycles.

But the largest part of this work was related to context vars. These weren't being managed well, and it was leaving references around to the app or widgets, which would prevent entire branches from being collected.

The context var issue I fixed by writing a context var context manager, which resets the context var. DOMNodes and App have grown a `_context()` which sets and restores the active app and message pump.

Incidentally, I think this is the cause of some of the weirdness in tests where tests work in a run but fail in isolation. Errors in managing the context vars could result in an app being shared across tests.

Many of the tests had to be updated after the context var changes. For some reason the `pilot` fixture broke. I think because of the way that pytest runs async tests.

I'm pretty sure these changes improve shutdown time. Although I haven't profiled.

Problem statement:
`clear_panes` and memory leak
Hello! Thanks for this wonderful framework, having a blast with it.
I seem to have a memory leak with my app. I suspect the problem to be caused by my usage of the `TabbedContent`'s method `clear_panes()`. I have a `TabbedContent` which i want to refresh every time a user provides a file input. The widget contains tabs which reference large tables. I was expecting `clear_panes()` to remove all references to the stale tabs, letting the stale tables to the garbage collector.  This doesn't seem to be happening. I have reproduced this behavior with the following MRE:

```python
from textual.app import App
from textual.widgets import Header, TabbedContent, Footer, TabPane
from textual import work


class MyTabPane(TabPane):
    def __init__(self):
        super().__init__("table")
        self.df = [1] * 100_000_000


class Explorer(App):
    BINDINGS = [
        ("ctrl+o", "add_table", "add table"),
    ]

    def __init__(self):
        super().__init__()

    def compose(self):
        yield Header(show_clock=False)
        yield TabbedContent()
        yield Footer()

    @work
    async def action_add_table(self):
        tabs = self.query_one(TabbedContent)
        tabs.loading = True
        await tabs.clear_panes()
        await tabs.add_pane(MyTabPane())
        tabs.loading = False


if __name__ == "__main__":
    app = Explorer()
    app.run()
```

Pressing `ctrl+o` repeatedly I can see an increase in the process's memory usage. I am on MacOS Ventura, iterm2.
What can I do to solve this problem with my program?
Thank you.

Hints from issue discussion:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://textual.textualize.io/FAQ/) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Will take a look later. In the meantime you could do `del self.df` in `on_unmount`

Commit message: docstring

Patch to review:
diff --git a/poetry.lock b/poetry.lock
index b840e36ff8..4294a0ad21 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,99 +1,114 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
 
 [[package]]
 name = "aiohappyeyeballs"
-version = "2.3.5"
+version = "2.4.0"
 description = "Happy Eyeballs for asyncio"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "aiohappyeyeballs-2.3.5-py3-none-any.whl", hash = "sha256:4d6dea59215537dbc746e93e779caea8178c866856a721c9c660d7a5a7b8be03"},
-    {file = "aiohappyeyeballs-2.3.5.tar.gz", hash = "sha256:6fa48b9f1317254f122a07a131a86b71ca6946ca989ce6326fff54a99a920105"},
+    {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"},
+    {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"},
 ]
 
 [[package]]
 name = "aiohttp"
-version = "3.10.3"
+version = "3.10.5"
 description = "Async http client/server framework (asyncio)"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "aiohttp-3.10.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc36cbdedf6f259371dbbbcaae5bb0e95b879bc501668ab6306af867577eb5db"},
-    {file = "aiohttp-3.10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85466b5a695c2a7db13eb2c200af552d13e6a9313d7fa92e4ffe04a2c0ea74c1"},
-    {file = "aiohttp-3.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:71bb1d97bfe7e6726267cea169fdf5df7658831bb68ec02c9c6b9f3511e108bb"},
-    {file = "aiohttp-3.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baec1eb274f78b2de54471fc4c69ecbea4275965eab4b556ef7a7698dee18bf2"},
-    {file = "aiohttp-3.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13031e7ec1188274bad243255c328cc3019e36a5a907978501256000d57a7201"},
-    {file = "aiohttp-3.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2bbc55a964b8eecb341e492ae91c3bd0848324d313e1e71a27e3d96e6ee7e8e8"},
-    {file = "aiohttp-3.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8cc0564b286b625e673a2615ede60a1704d0cbbf1b24604e28c31ed37dc62aa"},
-    {file = "aiohttp-3.10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f817a54059a4cfbc385a7f51696359c642088710e731e8df80d0607193ed2b73"},
-    {file = "aiohttp-3.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8542c9e5bcb2bd3115acdf5adc41cda394e7360916197805e7e32b93d821ef93"},
-    {file = "aiohttp-3.10.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:671efce3a4a0281060edf9a07a2f7e6230dca3a1cbc61d110eee7753d28405f7"},
-    {file = "aiohttp-3.10.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0974f3b5b0132edcec92c3306f858ad4356a63d26b18021d859c9927616ebf27"},
-    {file = "aiohttp-3.10.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:44bb159b55926b57812dca1b21c34528e800963ffe130d08b049b2d6b994ada7"},
-    {file = "aiohttp-3.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6ae9ae382d1c9617a91647575255ad55a48bfdde34cc2185dd558ce476bf16e9"},
-    {file = "aiohttp-3.10.3-cp310-cp310-win32.whl", hash = "sha256:aed12a54d4e1ee647376fa541e1b7621505001f9f939debf51397b9329fd88b9"},
-    {file = "aiohttp-3.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:b51aef59370baf7444de1572f7830f59ddbabd04e5292fa4218d02f085f8d299"},
-    {file = "aiohttp-3.10.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e021c4c778644e8cdc09487d65564265e6b149896a17d7c0f52e9a088cc44e1b"},
-    {file = "aiohttp-3.10.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:24fade6dae446b183e2410a8628b80df9b7a42205c6bfc2eff783cbeedc224a2"},
-    {file = "aiohttp-3.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bc8e9f15939dacb0e1f2d15f9c41b786051c10472c7a926f5771e99b49a5957f"},
-    {file = "aiohttp-3.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5a9ec959b5381271c8ec9310aae1713b2aec29efa32e232e5ef7dcca0df0279"},
-    {file = "aiohttp-3.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a5d0ea8a6467b15d53b00c4e8ea8811e47c3cc1bdbc62b1aceb3076403d551f"},
-    {file = "aiohttp-3.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9ed607dbbdd0d4d39b597e5bf6b0d40d844dfb0ac6a123ed79042ef08c1f87e"},
-    {file = "aiohttp-3.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3e66d5b506832e56add66af88c288c1d5ba0c38b535a1a59e436b300b57b23e"},
-    {file = "aiohttp-3.10.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fda91ad797e4914cca0afa8b6cccd5d2b3569ccc88731be202f6adce39503189"},
-    {file = "aiohttp-3.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:61ccb867b2f2f53df6598eb2a93329b5eee0b00646ee79ea67d68844747a418e"},
-    {file = "aiohttp-3.10.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d881353264e6156f215b3cb778c9ac3184f5465c2ece5e6fce82e68946868ef"},
-    {file = "aiohttp-3.10.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b031ce229114825f49cec4434fa844ccb5225e266c3e146cb4bdd025a6da52f1"},
-    {file = "aiohttp-3.10.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5337cc742a03f9e3213b097abff8781f79de7190bbfaa987bd2b7ceb5bb0bdec"},
-    {file = "aiohttp-3.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ab3361159fd3dcd0e48bbe804006d5cfb074b382666e6c064112056eb234f1a9"},
-    {file = "aiohttp-3.10.3-cp311-cp311-win32.whl", hash = "sha256:05d66203a530209cbe40f102ebaac0b2214aba2a33c075d0bf825987c36f1f0b"},
-    {file = "aiohttp-3.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:70b4a4984a70a2322b70e088d654528129783ac1ebbf7dd76627b3bd22db2f17"},
-    {file = "aiohttp-3.10.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:166de65e2e4e63357cfa8417cf952a519ac42f1654cb2d43ed76899e2319b1ee"},
-    {file = "aiohttp-3.10.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7084876352ba3833d5d214e02b32d794e3fd9cf21fdba99cff5acabeb90d9806"},
-    {file = "aiohttp-3.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d98c604c93403288591d7d6d7d6cc8a63459168f8846aeffd5b3a7f3b3e5e09"},
-    {file = "aiohttp-3.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d73b073a25a0bb8bf014345374fe2d0f63681ab5da4c22f9d2025ca3e3ea54fc"},
-    {file = "aiohttp-3.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8da6b48c20ce78f5721068f383e0e113dde034e868f1b2f5ee7cb1e95f91db57"},
-    {file = "aiohttp-3.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a9dcdccf50284b1b0dc72bc57e5bbd3cc9bf019060dfa0668f63241ccc16aa7"},
-    {file = "aiohttp-3.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56fb94bae2be58f68d000d046172d8b8e6b1b571eb02ceee5535e9633dcd559c"},
-    {file = "aiohttp-3.10.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bf75716377aad2c718cdf66451c5cf02042085d84522aec1f9246d3e4b8641a6"},
-    {file = "aiohttp-3.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c51ed03e19c885c8e91f574e4bbe7381793f56f93229731597e4a499ffef2a5"},
-    {file = "aiohttp-3.10.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b84857b66fa6510a163bb083c1199d1ee091a40163cfcbbd0642495fed096204"},
-    {file = "aiohttp-3.10.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c124b9206b1befe0491f48185fd30a0dd51b0f4e0e7e43ac1236066215aff272"},
-    {file = "aiohttp-3.10.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3461d9294941937f07bbbaa6227ba799bc71cc3b22c40222568dc1cca5118f68"},
-    {file = "aiohttp-3.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08bd0754d257b2db27d6bab208c74601df6f21bfe4cb2ec7b258ba691aac64b3"},
-    {file = "aiohttp-3.10.3-cp312-cp312-win32.whl", hash = "sha256:7f9159ae530297f61a00116771e57516f89a3de6ba33f314402e41560872b50a"},
-    {file = "aiohttp-3.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:e1128c5d3a466279cb23c4aa32a0f6cb0e7d2961e74e9e421f90e74f75ec1edf"},
-    {file = "aiohttp-3.10.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:d1100e68e70eb72eadba2b932b185ebf0f28fd2f0dbfe576cfa9d9894ef49752"},
-    {file = "aiohttp-3.10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a541414578ff47c0a9b0b8b77381ea86b0c8531ab37fc587572cb662ccd80b88"},
-    {file = "aiohttp-3.10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d5548444ef60bf4c7b19ace21f032fa42d822e516a6940d36579f7bfa8513f9c"},
-    {file = "aiohttp-3.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ba2e838b5e6a8755ac8297275c9460e729dc1522b6454aee1766c6de6d56e5e"},
-    {file = "aiohttp-3.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48665433bb59144aaf502c324694bec25867eb6630fcd831f7a893ca473fcde4"},
-    {file = "aiohttp-3.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bac352fceed158620ce2d701ad39d4c1c76d114255a7c530e057e2b9f55bdf9f"},
-    {file = "aiohttp-3.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0f670502100cdc567188c49415bebba947eb3edaa2028e1a50dd81bd13363f"},
-    {file = "aiohttp-3.10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43b09f38a67679e32d380fe512189ccb0b25e15afc79b23fbd5b5e48e4fc8fd9"},
-    {file = "aiohttp-3.10.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:cd788602e239ace64f257d1c9d39898ca65525583f0fbf0988bcba19418fe93f"},
-    {file = "aiohttp-3.10.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:214277dcb07ab3875f17ee1c777d446dcce75bea85846849cc9d139ab8f5081f"},
-    {file = "aiohttp-3.10.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:32007fdcaab789689c2ecaaf4b71f8e37bf012a15cd02c0a9db8c4d0e7989fa8"},
-    {file = "aiohttp-3.10.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:123e5819bfe1b87204575515cf448ab3bf1489cdeb3b61012bde716cda5853e7"},
-    {file = "aiohttp-3.10.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:812121a201f0c02491a5db335a737b4113151926a79ae9ed1a9f41ea225c0e3f"},
-    {file = "aiohttp-3.10.3-cp38-cp38-win32.whl", hash = "sha256:b97dc9a17a59f350c0caa453a3cb35671a2ffa3a29a6ef3568b523b9113d84e5"},
-    {file = "aiohttp-3.10.3-cp38-cp38-win_amd64.whl", hash = "sha256:3731a73ddc26969d65f90471c635abd4e1546a25299b687e654ea6d2fc052394"},
-    {file = "aiohttp-3.10.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:38d91b98b4320ffe66efa56cb0f614a05af53b675ce1b8607cdb2ac826a8d58e"},
-    {file = "aiohttp-3.10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9743fa34a10a36ddd448bba8a3adc2a66a1c575c3c2940301bacd6cc896c6bf1"},
-    {file = "aiohttp-3.10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7c126f532caf238031c19d169cfae3c6a59129452c990a6e84d6e7b198a001dc"},
-    {file = "aiohttp-3.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:926e68438f05703e500b06fe7148ef3013dd6f276de65c68558fa9974eeb59ad"},
-    {file = "aiohttp-3.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:434b3ab75833accd0b931d11874e206e816f6e6626fd69f643d6a8269cd9166a"},
-    {file = "aiohttp-3.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d35235a44ec38109b811c3600d15d8383297a8fab8e3dec6147477ec8636712a"},
-    {file = "aiohttp-3.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59c489661edbd863edb30a8bd69ecb044bd381d1818022bc698ba1b6f80e5dd1"},
-    {file = "aiohttp-3.10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50544fe498c81cb98912afabfc4e4d9d85e89f86238348e3712f7ca6a2f01dab"},
-    {file = "aiohttp-3.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:09bc79275737d4dc066e0ae2951866bb36d9c6b460cb7564f111cc0427f14844"},
-    {file = "aiohttp-3.10.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:af4dbec58e37f5afff4f91cdf235e8e4b0bd0127a2a4fd1040e2cad3369d2f06"},
-    {file = "aiohttp-3.10.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b22cae3c9dd55a6b4c48c63081d31c00fc11fa9db1a20c8a50ee38c1a29539d2"},
-    {file = "aiohttp-3.10.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ba562736d3fbfe9241dad46c1a8994478d4a0e50796d80e29d50cabe8fbfcc3f"},
-    {file = "aiohttp-3.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f25d6c4e82d7489be84f2b1c8212fafc021b3731abdb61a563c90e37cced3a21"},
-    {file = "aiohttp-3.10.3-cp39-cp39-win32.whl", hash = "sha256:b69d832e5f5fa15b1b6b2c8eb6a9fd2c0ec1fd7729cb4322ed27771afc9fc2ac"},
-    {file = "aiohttp-3.10.3-cp39-cp39-win_amd64.whl", hash = "sha256:673bb6e3249dc8825df1105f6ef74e2eab779b7ff78e96c15cadb78b04a83752"},
-    {file = "aiohttp-3.10.3.tar.gz", hash = "sha256:21650e7032cc2d31fc23d353d7123e771354f2a3d5b05a5647fc30fea214e696"},
+    {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"},
+    {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"},
+    {file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"},
+    {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"},
+    {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"},
+    {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"},
+    {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"},
+    {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"},
+    {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"},
+    {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"},
+    {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"},
+    {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"},
+    {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"},
+    {file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"},
+    {file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"},
+    {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"},
+    {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"},
+    {file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"},
+    {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"},
+    {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"},
+    {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"},
+    {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"},
+    {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"},
+    {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"},
+    {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"},
+    {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"},
+    {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"},
+    {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"},
+    {file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"},
+    {file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"},
+    {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"},
+    {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"},
+    {file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"},
+    {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"},
+    {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"},
+    {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"},
+    {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"},
+    {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"},
+    {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"},
+    {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"},
+    {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"},
+    {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"},
+    {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"},
+    {file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"},
+    {file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"},
+    {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"},
+    {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"},
+    {file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"},
+    {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"},
+    {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"},
+    {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"},
+    {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"},
+    {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"},
+    {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"},
+    {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"},
+    {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"},
+    {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"},
+    {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"},
+    {file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"},
+    {file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"},
+    {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"},
+    {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"},
+    {file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"},
+    {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"},
+    {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"},
+    {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"},
+    {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"},
+    {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"},
+    {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"},
+    {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"},
+    {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"},
+    {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"},
+    {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"},
+    {file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"},
+    {file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"},
+    {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"},
+    {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"},
+    {file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"},
+    {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"},
+    {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"},
+    {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"},
+    {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"},
+    {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"},
+    {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"},
+    {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"},
+    {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"},
+    {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"},
+    {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"},
+    {file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"},
+    {file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"},
+    {file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"},
 ]
 
 [package.dependencies]
@@ -108,6 +123,21 @@ yarl = ">=1.0,<2.0"
 [package.extras]
 speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
 
+[[package]]
+name = "aiohttp-jinja2"
+version = "1.6"
+description = "jinja2 template renderer for aiohttp.web (http server for asyncio)"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2"},
+    {file = "aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7"},
+]
+
+[package.dependencies]
+aiohttp = ">=3.9.0"
+jinja2 = ">=3.0.0"
+
 [[package]]
 name = "aiosignal"
 version = "1.3.1"
@@ -260,13 +290,13 @@ redis = ["redis (>=2.10.5)"]
 
 [[package]]
 name = "certifi"
-version = "2024.7.4"
+version = "2024.8.30"
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
-    {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
+    {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
+    {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
 ]
 
 [[package]]
@@ -532,19 +562,19 @@ testing = ["hatch", "pre-commit", "pytest", "tox"]
 
 [[package]]
 name = "filelock"
-version = "3.15.4"
+version = "3.16.0"
 description = "A platform independent file lock."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
-    {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
+    {file = "filelock-3.16.0-py3-none-any.whl", hash = "sha256:f6ed4c963184f4c84dd5557ce8fece759a3724b37b80c6c4f20a2f63a4dc6609"},
+    {file = "filelock-3.16.0.tar.gz", hash = "sha256:81de9eb8453c769b63369f87f11131a7ab04e367f8d97ad39dc230daa07e3bec"},
 ]
 
 [package.extras]
-docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
-typing = ["typing-extensions (>=4.8)"]
+docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.1.1)", "pytest (>=8.3.2)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.3)"]
+typing = ["typing-extensions (>=4.12.2)"]
 
 [[package]]
 name = "frozenlist"
@@ -766,24 +796,24 @@ license = ["ukkonen"]
 
 [[package]]
 name = "idna"
-version = "3.7"
+version = "3.8"
 description = "Internationalized Domain Names in Applications (IDNA)"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 files = [
-    {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
-    {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
+    {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"},
+    {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"},
 ]
 
 [[package]]
 name = "importlib-metadata"
-version = "8.2.0"
+version = "8.4.0"
 description = "Read metadata from Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"},
-    {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"},
+    {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"},
+    {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"},
 ]
 
 [package.dependencies]
@@ -858,13 +888,13 @@ test = ["coverage", "pytest", "pytest-cov"]
 
 [[package]]
 name = "markdown"
-version = "3.6"
+version = "3.7"
 description = "Python implementation of John Gruber's Markdown."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f"},
-    {file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"},
+    {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"},
+    {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"},
 ]
 
 [package.dependencies]
@@ -1012,13 +1042,13 @@ files = [
 
 [[package]]
 name = "mkdocs"
-version = "1.6.0"
+version = "1.6.1"
 description = "Project documentation with Markdown."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocs-1.6.0-py3-none-any.whl", hash = "sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7"},
-    {file = "mkdocs-1.6.0.tar.gz", hash = "sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512"},
+    {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"},
+    {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"},
 ]
 
 [package.dependencies]
@@ -1043,13 +1073,13 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp
 
 [[package]]
 name = "mkdocs-autorefs"
-version = "1.0.1"
+version = "1.2.0"
 description = "Automatically link across pages in MkDocs."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocs_autorefs-1.0.1-py3-none-any.whl", hash = "sha256:aacdfae1ab197780fb7a2dac92ad8a3d8f7ca8049a9cbe56a4218cd52e8da570"},
-    {file = "mkdocs_autorefs-1.0.1.tar.gz", hash = "sha256:f684edf847eced40b570b57846b15f0bf57fb93ac2c510450775dcf16accb971"},
+    {file = "mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f"},
+    {file = "mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f"},
 ]
 
 [package.dependencies]
@@ -1089,13 +1119,13 @@ pyyaml = ">=5.1"
 
 [[package]]
 name = "mkdocs-git-revision-date-localized-plugin"
-version = "1.2.6"
+version = "1.2.7"
 description = "Mkdocs plugin that enables displaying the localized date of the last git modification of a markdown file."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocs_git_revision_date_localized_plugin-1.2.6-py3-none-any.whl", hash = "sha256:f015cb0f3894a39b33447b18e270ae391c4e25275cac5a626e80b243784e2692"},
-    {file = "mkdocs_git_revision_date_localized_plugin-1.2.6.tar.gz", hash = "sha256:e432942ce4ee8aa9b9f4493e993dee9d2cc08b3ea2b40a3d6b03ca0f2a4bcaa2"},
+    {file = "mkdocs_git_revision_date_localized_plugin-1.2.7-py3-none-any.whl", hash = "sha256:d2b30ccb74ec8e118298758d75ae4b4f02c620daf776a6c92fcbb58f2b78f19f"},
+    {file = "mkdocs_git_revision_date_localized_plugin-1.2.7.tar.gz", hash = "sha256:2f83b52b4dad642751a79465f80394672cbad022129286f40d36b03aebee490f"},
 ]
 
 [package.dependencies]
@@ -1106,13 +1136,13 @@ pytz = "*"
 
 [[package]]
 name = "mkdocs-material"
-version = "9.5.31"
+version = "9.5.34"
 description = "Documentation that simply works"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mkdocs_material-9.5.31-py3-none-any.whl", hash = "sha256:1b1f49066fdb3824c1e96d6bacd2d4375de4ac74580b47e79ff44c4d835c5fcb"},
-    {file = "mkdocs_material-9.5.31.tar.gz", hash = "sha256:31833ec664772669f5856f4f276bf3fdf0e642a445e64491eda459249c3a1ca8"},
+    {file = "mkdocs_material-9.5.34-py3-none-any.whl", hash = "sha256:54caa8be708de2b75167fd4d3b9f3d949579294f49cb242515d4653dbee9227e"},
+    {file = "mkdocs_material-9.5.34.tar.gz", hash = "sha256:1e60ddf716cfb5679dfd65900b8a25d277064ed82d9a53cd5190e3f894df7840"},
 ]
 
 [package.dependencies]
@@ -1270,7 +1300,7 @@ files = [
     {file = "msgpack-1.0.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fbb160554e319f7b22ecf530a80a3ff496d38e8e07ae763b9e82fadfe96f273"},
     {file = "msgpack-1.0.8-cp39-cp39-win32.whl", hash = "sha256:f9af38a89b6a5c04b7d18c492c8ccf2aee7048aff1ce8437c4683bb5a1df893d"},
     {file = "msgpack-1.0.8-cp39-cp39-win_amd64.whl", hash = "sha256:ed59dd52075f8fc91da6053b12e8c89e37aa043f8986efd89e61fae69dc1b011"},
-    {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"},
+    {file = "msgpack-1.0.8-py3-none-any.whl", hash = "sha256:24f727df1e20b9876fa6e95f840a2a2651e34c0ad147676356f4bf5fbb0206ca"},
 ]
 
 [[package]]
@@ -1374,38 +1404,38 @@ files = [
 
 [[package]]
 name = "mypy"
-version = "1.11.1"
+version = "1.11.2"
 description = "Optional static typing for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "mypy-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c"},
-    {file = "mypy-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411"},
-    {file = "mypy-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03"},
-    {file = "mypy-1.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4"},
-    {file = "mypy-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58"},
-    {file = "mypy-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5"},
-    {file = "mypy-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca"},
-    {file = "mypy-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de"},
-    {file = "mypy-1.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"},
-    {file = "mypy-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72"},
-    {file = "mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8"},
-    {file = "mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a"},
-    {file = "mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417"},
-    {file = "mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e"},
-    {file = "mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525"},
-    {file = "mypy-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2"},
-    {file = "mypy-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b"},
-    {file = "mypy-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0"},
-    {file = "mypy-1.11.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd"},
-    {file = "mypy-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb"},
-    {file = "mypy-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe"},
-    {file = "mypy-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c"},
-    {file = "mypy-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69"},
-    {file = "mypy-1.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74"},
-    {file = "mypy-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b"},
-    {file = "mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54"},
-    {file = "mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08"},
+    {file = "mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a"},
+    {file = "mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef"},
+    {file = "mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383"},
+    {file = "mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8"},
+    {file = "mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7"},
+    {file = "mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385"},
+    {file = "mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca"},
+    {file = "mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104"},
+    {file = "mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4"},
+    {file = "mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6"},
+    {file = "mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318"},
+    {file = "mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36"},
+    {file = "mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987"},
+    {file = "mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca"},
+    {file = "mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70"},
+    {file = "mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b"},
+    {file = "mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86"},
+    {file = "mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce"},
+    {file = "mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1"},
+    {file = "mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b"},
+    {file = "mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6"},
+    {file = "mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70"},
+    {file = "mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d"},
+    {file = "mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d"},
+    {file = "mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24"},
+    {file = "mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12"},
+    {file = "mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79"},
 ]
 
 [package.dependencies]
@@ -1454,14 +1484,19 @@ files = [
 
 [[package]]
 name = "paginate"
-version = "0.5.6"
+version = "0.5.7"
 description = "Divides large result sets into pages for easier browsing"
 optional = false
 python-versions = "*"
 files = [
-    {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"},
+    {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"},
+    {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"},
 ]
 
+[package.extras]
+dev = ["pytest", "tox"]
+lint = ["black"]
+
 [[package]]
 name = "pathspec"
 version = "0.12.1"
@@ -1475,13 +1510,13 @@ files = [
 
 [[package]]
 name = "platformdirs"
-version = "4.2.2"
+version = "4.3.1"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
-    {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
+    {file = "platformdirs-4.3.1-py3-none-any.whl", hash = "sha256:facaa5a3c57aa1e053e3da7b49e0cc31fe0113ca42a4659d5c2e98e545624afe"},
+    {file = "platformdirs-4.3.1.tar.gz", hash = "sha256:63b79589009fa8159973601dd4563143396b35c5f93a58b36f9049ff046949b1"},
 ]
 
 [package.extras]
@@ -1578,17 +1613,17 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
 
 [[package]]
 name = "pytest-asyncio"
-version = "0.23.8"
+version = "0.24.0"
 description = "Pytest support for asyncio"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"},
-    {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"},
+    {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"},
+    {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"},
 ]
 
 [package.dependencies]
-pytest = ">=7.0.0,<9"
+pytest = ">=8.2,<9"
 
 [package.extras]
 docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
@@ -1879,13 +1914,13 @@ idna2008 = ["idna"]
 
 [[package]]
 name = "rich"
-version = "13.7.1"
+version = "13.8.0"
 description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
 optional = false
 python-versions = ">=3.7.0"
 files = [
-    {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
-    {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
+    {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"},
+    {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"},
 ]
 
 [package.dependencies]
@@ -1898,19 +1933,23 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
 
 [[package]]
 name = "setuptools"
-version = "72.1.0"
+version = "74.1.2"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"},
-    {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"},
+    {file = "setuptools-74.1.2-py3-none-any.whl", hash = "sha256:5f4c08aa4d3ebcb57a50c33b1b07e94315d7fc7230f7115e47fc99776c8ce308"},
+    {file = "setuptools-74.1.2.tar.gz", hash = "sha256:95b40ed940a1c67eb70fc099094bd6e99c6ee7c23aa2306f4d2697ba7916f9c6"},
 ]
 
 [package.extras]
-core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
-doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
-test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"]
+core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
+type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"]
 
 [[package]]
 name = "six"
@@ -1947,13 +1986,13 @@ files = [
 
 [[package]]
 name = "syrupy"
-version = "4.6.1"
+version = "4.7.1"
 description = "Pytest Snapshot Test Utility"
 optional = false
-python-versions = ">=3.8.1,<4"
+python-versions = ">=3.8.1"
 files = [
-    {file = "syrupy-4.6.1-py3-none-any.whl", hash = "sha256:203e52f9cb9fa749cf683f29bd68f02c16c3bc7e7e5fe8f2fc59bdfe488ce133"},
-    {file = "syrupy-4.6.1.tar.gz", hash = "sha256:37a835c9ce7857eeef86d62145885e10b3cb9615bc6abeb4ce404b3f18e1bb36"},
+    {file = "syrupy-4.7.1-py3-none-any.whl", hash = "sha256:be002267a512a4bedddfae2e026c93df1ea928ae10baadc09640516923376d41"},
+    {file = "syrupy-4.7.1.tar.gz", hash = "sha256:f9d4485f3f27d0e5df6ed299cac6fa32eb40a441915d988e82be5a4bdda335c8"},
 ]
 
 [package.dependencies]
@@ -1961,13 +2000,13 @@ pytest = ">=7.0.0,<9.0.0"
 
 [[package]]
 name = "textual-dev"
-version = "1.5.1"
+version = "1.6.1"
 description = "Development tools for working with Textual"
 optional = false
-python-versions = ">=3.8,<4.0"
+python-versions = "<4.0,>=3.8"
 files = [
-    {file = "textual_dev-1.5.1-py3-none-any.whl", hash = "sha256:bb37dd769ae6b67e1422aa97f6d6ef952e0a6d2aafe08327449e8bdd70474776"},
-    {file = "textual_dev-1.5.1.tar.gz", hash = "sha256:e0366ab6f42c128d7daa37a7c418e61fe7aa83731983da990808e4bf2de922a1"},
+    {file = "textual_dev-1.6.1-py3-none-any.whl", hash = "sha256:de93279da6dd0772be88a83e494be1bc895df0a0c3e47bcd48fa1acb1a83a34b"},
+    {file = "textual_dev-1.6.1.tar.gz", hash = "sha256:0d0d4523a09566bae56eb9ebc4fcbb09069d0f335448e6b9b10dd2d805606bd8"},
 ]
 
 [package.dependencies]
@@ -1975,8 +2014,27 @@ aiohttp = ">=3.8.1"
 click = ">=8.1.2"
 msgpack = ">=1.0.3"
 textual = ">=0.36.0"
+textual_serve = ">=1.0.3"
 typing-extensions = ">=4.4.0,<5.0.0"
 
+[[package]]
+name = "textual-serve"
+version = "1.1.0"
+description = "Turn your Textual TUIs in to web applications"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "textual_serve-1.1.0-py3-none-any.whl", hash = "sha256:aaa40bc4b75f6a1e73a2d7f6e667f5555a552d8040129a95e52789a3de5d9154"},
+    {file = "textual_serve-1.1.0.tar.gz", hash = "sha256:9332716464a1fca38bb5421ef29e38bdc5d304e5a15faa3ae1f8dcb185a7885c"},
+]
+
+[package.dependencies]
+aiohttp = ">=3.9.5"
+aiohttp-jinja2 = ">=1.6"
+jinja2 = ">=3.1.4"
+rich = "*"
+textual = ">=0.66.0"
+
 [[package]]
 name = "tomli"
 version = "2.0.1"
@@ -2305,101 +2363,103 @@ watchmedo = ["PyYAML (>=3.10)"]
 
 [[package]]
 name = "yarl"
-version = "1.9.4"
+version = "1.10.0"
 description = "Yet another URL library"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 files = [
-    {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"},
-    {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"},
-    {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"},
-    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"},
-    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"},
-    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"},
-    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"},
-    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"},
-    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"},
-    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"},
-    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"},
-    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"},
-    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"},
-    {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"},
-    {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"},
-    {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"},
-    {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"},
-    {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"},
-    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"},
-    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"},
-    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"},
-    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"},
-    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"},
-    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"},
-    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"},
-    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"},
-    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"},
-    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"},
-    {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"},
-    {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"},
-    {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"},
-    {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"},
-    {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"},
-    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"},
-    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"},
-    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"},
-    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"},
-    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"},
-    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"},
-    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"},
-    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"},
-    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"},
-    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"},
-    {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"},
-    {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"},
-    {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"},
-    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"},
-    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"},
-    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"},
-    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"},
-    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"},
-    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"},
-    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"},
-    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"},
-    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"},
-    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"},
-    {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"},
-    {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"},
-    {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"},
-    {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"},
-    {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"},
-    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"},
-    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"},
-    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"},
-    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"},
-    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"},
-    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"},
-    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"},
-    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"},
-    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"},
-    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"},
-    {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"},
-    {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"},
-    {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"},
-    {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"},
-    {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"},
-    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"},
-    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"},
-    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"},
-    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"},
-    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"},
-    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"},
-    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"},
-    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"},
-    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"},
-    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"},
-    {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"},
-    {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"},
-    {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"},
-    {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"},
+    {file = "yarl-1.10.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1718c0bca5a61edac7a57dcc11856cb01bde13a9360a3cb6baf384b89cfc0b40"},
+    {file = "yarl-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4657fd290d556a5f3018d07c7b7deadcb622760c0125277d10a11471c340054"},
+    {file = "yarl-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:044b76d069e69c6b0246f071ebac0576f89c772f806d66ef51e662bd015d03c7"},
+    {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5527d32506c11150ca87f33820057dc284e2a01a87f0238555cada247a8b278"},
+    {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36d12d78b8b0d46099d413c8689b5510ad9ce5e443363d1c37b6ac5b3d7cbdfb"},
+    {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11f7f8a72b3e26c533fa7ffa7a8068f4e3aad7b67c5cf7b17ea8c79fc81d9830"},
+    {file = "yarl-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88173836a25b7e5dce989eeee3b92d8ef5cdf512830d4155c6212de98e616f70"},
+    {file = "yarl-1.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c382e189af10070bcb39caa9406b9cc47b26c1d2257979f11fe03a38be09fea9"},
+    {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:534b8bc181dca1691cf491c263e084af678a8fb6b6181687c788027d8c317026"},
+    {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f3372f9ae1d1f001826b77d0b29d4220e84f6c5f53915e71a825cdd02600065"},
+    {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4cca9ba00be4bb8a051c4007b60fc91d6c9728c8b70c86cee4c24be9d641002f"},
+    {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a9d8c4be5658834dc688072239d220631ad4b71ff79a5f3d17fb653f16d10759"},
+    {file = "yarl-1.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff45a655ca51e1cb778abbb586083fddb7d896332f47bb3b03bc75e30c25649f"},
+    {file = "yarl-1.10.0-cp310-cp310-win32.whl", hash = "sha256:9ef7ce61958b3c7b2e2e0927c52d35cf367c5ee410e06e1337ecc83a90c23b95"},
+    {file = "yarl-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:48a48261f8d610b0e15fed033e74798763bc2f8f2c0d769a2a0732511af71f1e"},
+    {file = "yarl-1.10.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:308d1cce071b5b500e3d95636bbf15dfdb8e87ed081b893555658a7f9869a156"},
+    {file = "yarl-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc66927f6362ed613a483c22618f88f014994ccbd0b7a25ec1ebc8c472d4b40a"},
+    {file = "yarl-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c4d13071c5b99974cfe2f94c749ecc4baf882f7c4b6e4c40ca3d15d1b7e81f24"},
+    {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:348ad53acd41caa489df7db352d620c982ab069855d9635dda73d685bbbc3636"},
+    {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:293f7c2b30d015de3f1441c4ee764963b86636fde881b4d6093498d1e8711f69"},
+    {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:315e8853d0ea46aabdce01f1f248fff7b9743de89b555c5f0487f54ac84beae8"},
+    {file = "yarl-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:012c506b2c23be4500fb97509aa7e6a575996fb317b80667fa26899d456e2aaf"},
+    {file = "yarl-1.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f769c2708c31227c5349c3e4c668c8b4b2e25af3e7263723f2ef33e8e3906a0"},
+    {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f6ac063a4e9bbd4f6cc88cc621516a44d6aec66862ea8399ba063374e4b12c7"},
+    {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:18b7ce6d8c35da8e16dcc8de124a80e250fc8c73f8c02663acf2485c874f1972"},
+    {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b80246bdee036381636e73ef0f19b032912064622b0e5ee44f6960fd11df12aa"},
+    {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:183dd37bb5471e8017ab8a998c1ea070b4a0b08a97a7c4e20e0c7ccbe8ebb999"},
+    {file = "yarl-1.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b6d0d7522b514f054b359409817af4c5ed76fa4fe42d8bd1ed12956804cf595"},
+    {file = "yarl-1.10.0-cp311-cp311-win32.whl", hash = "sha256:6026a6ef14d038a38ca9d81422db4b6bb7d5da94f9d08f21e0ad9ebd9c4bc3bb"},
+    {file = "yarl-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:190e70d2f9f16f1c9d666c103d635c9ed4bf8de7803e9fa0495eec405a3e96a8"},
+    {file = "yarl-1.10.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6bc602c7413e1b5223bc988947125998cb54d6184de45a871985daacc23e6c8c"},
+    {file = "yarl-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bf733c835ebbd52bd78a52b919205e0f06d8571f71976a0259e5bcc20d0a2f44"},
+    {file = "yarl-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6e91ed5f6818e1e3806eaeb7b14d9e17b90340f23089451ea59a89a29499d760"},
+    {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23057a004bc9735008eb2a04b6ce94c6c06219cdf2b193997fd3ae6039eb3196"},
+    {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b922c32a1cff62bc43d408d1a8745abeed0a705793f2253c622bf3521922198"},
+    {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be199fed28861d72df917e355287ad6835555d8210e7f8203060561f24d7d842"},
+    {file = "yarl-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cece693380c1c4a606cdcaa0c54eda8f72cfe1ba83f5149b9023bb955e8fa8e"},
+    {file = "yarl-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff8e803d8ca170e632fb3b4df1bfd29ba29be8edc3e9306c5ffa5fadea234a4f"},
+    {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:30dde3a8b88c80a4f049eb4dd240d2a02e89174da6be2525541f949bf9fa38ab"},
+    {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dff84623e7098cf9bfbb5187f9883051af652b0ce08b9f7084cc8630b87b6457"},
+    {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e69b55965a47dd6c79e578abd7d85637b1bb4a7565436630826bdb28aa9b7ad"},
+    {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5d0c9e1dcc92d46ca89608fe4763fc2362f1e81c19a922c67dbc0f20951466e4"},
+    {file = "yarl-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32e79d5ae975f7c2cc29f7104691fc9be5ee3724f24e1a7254d72f6219672108"},
+    {file = "yarl-1.10.0-cp312-cp312-win32.whl", hash = "sha256:762a196612c2aba4197cd271da65fe08308f7ddf130dc63842c7a76d774b6a2c"},
+    {file = "yarl-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:8c6214071f653d21bb7b43f7ee519afcbf7084263bb43408f4939d14558290db"},
+    {file = "yarl-1.10.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0e0aea8319fdc1ac340236e58b0b7dc763621bce6ce98124a9d58104cafd0aaa"},
+    {file = "yarl-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b3bf343b4ef9ec600d75363eb9b48ab3bd53b53d4e1c5a9fbf0cfe7ba73a47f"},
+    {file = "yarl-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:05b07e6e0f715eaae9d927a302d9220724392f3c0b4e7f8dfa174bf2e1b8433e"},
+    {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7bd531d7eec4aa7ef8a99fef91962eeea5158a53af0ec507c476ddf8ebc29c"},
+    {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:183136dc5d5411872e7529c924189a2e26fac5a7f9769cf13ef854d1d653ad36"},
+    {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c77a3c10af4aaf8891578fe492ef0990c65cf7005dd371f5ea8007b420958bf6"},
+    {file = "yarl-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:030d41d48217b180c5a176e59c49d212d54d89f6f53640fa4c1a1766492aec27"},
+    {file = "yarl-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4f43ba30d604ba391bc7fe2dd104d6b87b62b0de4bbde79e362524b8a1eb75"},
+    {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:637dd0f55d1781d4634c23994101c509e455b5ab61af9086b5763b7eca9359aa"},
+    {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:99e7459ee86a3b81e57777afd3825b8b1acaac8a99f9c0bd02415d80eb3c371b"},
+    {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a80cdb3c15c15b33ecdb080546dcb022789b0084ca66ad41ffa0fe09857fca11"},
+    {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1824bfb932d8100e5c94f4f98c078f23ebc6f6fa93acc3d95408762089c54a06"},
+    {file = "yarl-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:90fd64ce00f594db02f603efa502521c440fa1afcf6266be82eb31f19d2d9561"},
+    {file = "yarl-1.10.0-cp313-cp313-win32.whl", hash = "sha256:687131ee4d045f3d58128ca28f5047ec902f7760545c39bbe003cc737c5a02b5"},
+    {file = "yarl-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:493ad061ee025c5ed3a60893cd70204eead1b3f60ccc90682e752f95b845bd46"},
+    {file = "yarl-1.10.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cd65588273d19f8483bc8f32a6fcf602e94a9a7ba287a1725977bd9527cd6c0c"},
+    {file = "yarl-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6f64f8681671624f539eea5564518bc924524c25eb90ab24a7eddc2d872e668e"},
+    {file = "yarl-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3576ed2c51f8525d4ff5c3279247aacff9540bb43b292c4a37a8e6c6e1691adb"},
+    {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca42a9281807fdf8fba86e671d8fdd76f92e9302a6d332957f2bae51c774f8a7"},
+    {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54a4b5e6a060d46cad6a3cf340f4cb268e6fbc89c589d82a2da58f7db47c47c8"},
+    {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6eec21d8c3aa932c5a89480b58fa877e9c48092ab838ccc76788cbc917ceec0d"},
+    {file = "yarl-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:273baee8a8af5989d5aab51c740e65bc2b1fc6619b9dd192cd16a3fae51100be"},
+    {file = "yarl-1.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1bf63ba496cd4f12d30e916d9a52daa6c91433fedd9cd0d99fef3e13232836f"},
+    {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:f8e24b9a4afdffab399191a9f0b0e80eabc7b7fdb9f2dbccdeb8e4d28e5c57bb"},
+    {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4c46454fafa31f7241083a0dd21814f63e0fcb4ae49662dc7e286fd6a5160ea1"},
+    {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:beda87b63c08fb4df8cc5353eeefe68efe12aa4f5284958bd1466b14c85e508e"},
+    {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:9a8d6a0e2b5617b5c15c59db25f20ba429f1fea810f2c09fbf93067cb21ab085"},
+    {file = "yarl-1.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b453b3dbc1ed4c2907632d05b378123f3fb411cad05d8d96de7d95104ef11c70"},
+    {file = "yarl-1.10.0-cp38-cp38-win32.whl", hash = "sha256:1ea30675fbf0ad6795c100da677ef6a8960a7db05ac5293f02a23c2230203c89"},
+    {file = "yarl-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:347011ad09a8f9be3d41fe2d7d611c3a4de4d49aa77bcb9a8c03c7a82fc45248"},
+    {file = "yarl-1.10.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:18bc4600eed1907762c1816bb16ac63bc52912e53b5e9a353eb0935a78e95496"},
+    {file = "yarl-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eeb6a40c5ae2616fd38c1e039c6dd50031bbfbc2acacfd7b70a5d64fafc70901"},
+    {file = "yarl-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc544248b5263e1c0f61332ccf35e37404b54213f77ed17457f857f40af51452"},
+    {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3352c69dc235850d6bf8ddad915931f00dcab208ac4248b9af46175204c2f5f9"},
+    {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af5b52bfbbd5eb208cf1afe23c5ada443929e9b9d79e9fbc66cacc07e4e39748"},
+    {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eafa7317063de4bc310716cdd9026c13f00b1629e649079a6908c3aafdf5046"},
+    {file = "yarl-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a162cf04fd1e8d81025ec651d14cac4f6e0ca73a3c0a9482de8691b944e3098a"},
+    {file = "yarl-1.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:179b1df5e9cd99234ea65e63d5bfc6dd524b2c3b6cf68a14b94ccbe01ab37ddd"},
+    {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:32d2e46848dea122484317485129f080220aa84aeb6a9572ad9015107cebeb07"},
+    {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aa1aeb99408be0ca774c5126977eb085fedda6dd7d9198ce4ceb2d06a44325c7"},
+    {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d2366e2f987f69752f0588d2035321aaf24272693d75f7f6bb7e8a0f48f7ccdd"},
+    {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:e8da33665ecc64cd3e593098adb449f9c65b4e3bc6338e75ad592da15453d898"},
+    {file = "yarl-1.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b46c603bee1f2dd407b8358c2afc9b0472a22ccca528f114e1f4cd30dfecd22"},
+    {file = "yarl-1.10.0-cp39-cp39-win32.whl", hash = "sha256:96422a3322b4d954f4c52403a2fc129ad118c151ee60a717847fb46a8480d1e1"},
+    {file = "yarl-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:52d1ae09b0764017e330bb5bf9af760c0168c564225085bb806f687bccffda8a"},
+    {file = "yarl-1.10.0-py3-none-any.whl", hash = "sha256:99eaa7d53f509ba1c2fea8fdfec15ba3cd36caca31d57ec6665073b148b5f260"},
+    {file = "yarl-1.10.0.tar.gz", hash = "sha256:3bf10a395adac62177ba8ea738617e8de6cbb1cea6aa5d5dd2accde704fc8195"},
 ]
 
 [package.dependencies]
@@ -2408,18 +2468,22 @@ multidict = ">=4.0"
 
 [[package]]
 name = "zipp"
-version = "3.20.0"
+version = "3.20.1"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"},
-    {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"},
+    {file = "zipp-3.20.1-py3-none-any.whl", hash = "sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064"},
+    {file = "zipp-3.20.1.tar.gz", hash = "sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b"},
 ]
 
 [package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
+cover = ["pytest-cov"]
 doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
+type = ["pytest-mypy"]
 
 [extras]
 syntax = ["tree-sitter", "tree-sitter-languages"]
diff --git a/pyproject.toml b/pyproject.toml
index c704952ac4..71a51de9c9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -83,6 +83,7 @@ addopts = "--strict-markers"
 markers = [
     "syntax: marks tests that require syntax highlighting (deselect with '-m \"not syntax\"')",
 ]
+asyncio_default_fixture_loop_scope = "function"
 
 [build-system]
 requires = ["poetry-core>=1.2.0"]
diff --git a/src/textual/_callback.py b/src/textual/_callback.py
index d2fd2403c2..ae808f80a3 100644
--- a/src/textual/_callback.py
+++ b/src/textual/_callback.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import asyncio
-from functools import lru_cache, partial
+from functools import partial
 from inspect import isawaitable, signature
 from typing import TYPE_CHECKING, Any, Callable
 
@@ -16,16 +16,24 @@
 
 def count_parameters(func: Callable) -> int:
     """Count the number of parameters in a callable"""
+    try:
+        return func._param_count
+    except AttributeError:
+        pass
     if isinstance(func, partial):
-        return _count_parameters(func.func) + len(func.args)
-    if hasattr(func, "__self__"):
+        param_count = _count_parameters(func.func) - (
+            len(func.args) + len(func.keywords)
+        )
+    elif hasattr(func, "__self__"):
         # Bound method
         func = func.__func__  # type: ignore
-        return _count_parameters(func) - 1
-    return _count_parameters(func)
+        param_count = _count_parameters(func) - 1
+    else:
+        param_count = _count_parameters(func)
+    func._param_count = param_count
+    return param_count
 
 
-@lru_cache(maxsize=2048)
 def _count_parameters(func: Callable) -> int:
     """Count the number of parameters in a callable"""
     return len(signature(func).parameters)
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index f18417d001..0639945b5a 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -309,6 +309,15 @@ def __init__(self) -> None:
         # Mapping of line numbers on to lists of widget and regions
         self._layers_visible: list[list[tuple[Widget, Region, Region]]] | None = None
 
+    def clear(self) -> None:
+        """Remove all references to widgets (used when the screen closes)."""
+        self._full_map.clear()
+        self._visible_map = None
+        self._layers = None
+        self.widgets.clear()
+        self._visible_widgets = None
+        self._layers_visible = None
+
     @classmethod
     def _regions_to_spans(
         cls, regions: Iterable[Region]
diff --git a/src/textual/_context.py b/src/textual/_context.py
index b1b20b4d29..039bb7b4ef 100644
--- a/src/textual/_context.py
+++ b/src/textual/_context.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from contextvars import ContextVar
-from typing import TYPE_CHECKING, Callable
+from typing import TYPE_CHECKING, Any, Callable
 
 if TYPE_CHECKING:
     from .app import App
@@ -14,8 +14,9 @@ class NoActiveAppError(RuntimeError):
     """Runtime error raised if we try to retrieve the active app when there is none."""
 
 
-active_app: ContextVar["App[object]"] = ContextVar("active_app")
+active_app: ContextVar["App[Any]"] = ContextVar("active_app")
 active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump")
+
 prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar(
     "prevent_message_types_stack"
 )
diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py
index 52555f9d61..abae4c298f 100644
--- a/src/textual/_node_list.py
+++ b/src/textual/_node_list.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import sys
+import weakref
 from operator import attrgetter
 from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, overload
 
@@ -26,7 +27,13 @@ class NodeList(Sequence["Widget"]):
     """
 
     def __init__(self, parent: DOMNode | None = None) -> None:
-        self._parent = parent
+        """Initialize a node list.
+
+        Args:
+            parent: The parent node which holds a reference to this object, or `None` if
+                there is no parent.
+        """
+        self._parent = None if parent is None else weakref.ref(parent)
         # The nodes in the list
         self._nodes: list[Widget] = []
         self._nodes_set: set[Widget] = set()
@@ -57,7 +64,9 @@ def __contains__(self, widget: object) -> bool:
     def updated(self) -> None:
         """Mark the nodes as having been updated."""
         self._updates += 1
-        node = self._parent
+        node = None if self._parent is None else self._parent()
+        if node is None:
+            return
         while node is not None and (node := node._parent) is not None:
             node._nodes._updates += 1
 
diff --git a/src/textual/app.py b/src/textual/app.py
index b721507249..e01f6842cf 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -809,6 +809,17 @@ def _end_batch(self) -> None:
         if not self._batch_count:
             self.check_idle()
 
+    @contextmanager
+    def _context(self) -> Generator[None, None, None]:
+        """Context manager to set ContextVars."""
+        app_reset_token = active_app.set(self)
+        message_pump_reset_token = active_message_pump.set(self)
+        try:
+            yield
+        finally:
+            active_message_pump.reset(message_pump_reset_token)
+            active_app.reset(app_reset_token)
+
     def animate(
         self,
         attribute: str,
@@ -1046,10 +1057,6 @@ def get_default_screen(self) -> Screen:
         """
         return Screen(id="_default")
 
-    def _set_active(self) -> None:
-        """Set this app to be the currently active app."""
-        active_app.set(self)
-
     def compose(self) -> ComposeResult:
         """Yield child widgets for a container.
 
@@ -1355,8 +1362,8 @@ def call_from_thread(
 
         async def run_callback() -> CallThreadReturnType:
             """Run the callback, set the result or error on the future."""
-            self._set_active()
-            return await invoke(callback_with_args)
+            with self._context():
+                return await invoke(callback_with_args)
 
         # Post the message to the main loop
         future: Future[CallThreadReturnType] = asyncio.run_coroutine_threadsafe(
@@ -1667,41 +1674,39 @@ async def run_app(app: App) -> None:
                 app: App to run.
             """
 
-            try:
-                if message_hook is not None:
-                    message_hook_context_var.set(message_hook)
-                app._loop = asyncio.get_running_loop()
-                app._thread_id = threading.get_ident()
-                await app._process_messages(
-                    ready_callback=on_app_ready,
-                    headless=headless,
-                    terminal_size=size,
-                )
-            finally:
-                app_ready_event.set()
+            with app._context():
+                try:
+                    if message_hook is not None:
+                        message_hook_context_var.set(message_hook)
+                    app._loop = asyncio.get_running_loop()
+                    app._thread_id = threading.get_ident()
+                    await app._process_messages(
+                        ready_callback=on_app_ready,
+                        headless=headless,
+                        terminal_size=size,
+                    )
+                finally:
+                    app_ready_event.set()
 
         # Launch the app in the "background"
-        active_message_pump.set(app)
+
         app_task = create_task(run_app(app), name=f"run_test {app}")
 
         # Wait until the app has performed all startup routines.
         await app_ready_event.wait()
-
-        # Get the app in an active state.
-        app._set_active()
-
-        # Context manager returns pilot object to manipulate the app
-        try:
-            pilot = Pilot(app)
-            await pilot._wait_for_screen()
-            yield pilot
-        finally:
-            # Shutdown the app cleanly
-            await app._shutdown()
-            await app_task
-            # Re-raise the exception which caused panic so test frameworks are aware
-            if self._exception:
-                raise self._exception
+        with app._context():
+            # Context manager returns pilot object to manipulate the app
+            try:
+                pilot = Pilot(app)
+                await pilot._wait_for_screen()
+                yield pilot
+            finally:
+                # Shutdown the app cleanly
+                await app._shutdown()
+                await app_task
+                # Re-raise the exception which caused panic so test frameworks are aware
+                if self._exception:
+                    raise self._exception
 
     async def run_async(
         self,
@@ -1751,14 +1756,14 @@ async def app_ready() -> None:
                 async def run_auto_pilot(
                     auto_pilot: AutopilotCallbackType, pilot: Pilot
                 ) -> None:
-                    try:
-                        await auto_pilot(pilot)
-                    except Exception:
-                        app.exit()
-                        raise
+                    with self._context():
+                        try:
+                            await auto_pilot(pilot)
+                        except Exception:
+                            app.exit()
+                            raise
 
                 pilot = Pilot(app)
-                active_message_pump.set(self)
                 auto_pilot_task = create_task(
                     run_auto_pilot(auto_pilot, pilot), name=repr(pilot)
                 )
@@ -1816,18 +1821,19 @@ async def run_app() -> None:
             """Run the app."""
             self._loop = asyncio.get_running_loop()
             self._thread_id = threading.get_ident()
-            try:
-                await self.run_async(
-                    headless=headless,
-                    inline=inline,
-                    inline_no_clear=inline_no_clear,
-                    mouse=mouse,
-                    size=size,
-                    auto_pilot=auto_pilot,
-                )
-            finally:
-                self._loop = None
-                self._thread_id = 0
+            with self._context():
+                try:
+                    await self.run_async(
+                        headless=headless,
+                        inline=inline,
+                        inline_no_clear=inline_no_clear,
+                        mouse=mouse,
+                        size=size,
+                        auto_pilot=auto_pilot,
+                    )
+                finally:
+                    self._loop = None
+                    self._thread_id = 0
 
         if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
             # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
@@ -2680,6 +2686,17 @@ def _build_driver(
         )
         return driver
 
+    async def _init_devtools(self):
+        """Initialize developer tools."""
+        if self.devtools is not None:
+            from textual_dev.client import DevtoolsConnectionError
+
+            try:
+                await self.devtools.connect()
+                self.log.system(f"Connected to devtools ( {self.devtools.url} )")
+            except DevtoolsConnectionError:
+                self.log.system(f"Couldn't connect to devtools ( {self.devtools.url} )")
+
     async def _process_messages(
         self,
         ready_callback: CallbackType | None = None,
@@ -2690,54 +2707,49 @@ async def _process_messages(
         terminal_size: tuple[int, int] | None = None,
         message_hook: Callable[[Message], None] | None = None,
     ) -> None:
-        self._set_active()
-        active_message_pump.set(self)
+        async def app_prelude() -> bool:
+            """Work required before running the app.
 
-        if self.devtools is not None:
-            from textual_dev.client import DevtoolsConnectionError
+            Returns:
+                `True` if the app should continue, or `False` if there was a problem starting.
+            """
+            await self._init_devtools()
+            self.log.system("---")
+            self.log.system(loop=asyncio.get_running_loop())
+            self.log.system(features=self.features)
+            if constants.LOG_FILE is not None:
+                _log_path = os.path.abspath(constants.LOG_FILE)
+                self.log.system(f"Writing logs to {_log_path!r}")
 
             try:
-                await self.devtools.connect()
-                self.log.system(f"Connected to devtools ( {self.devtools.url} )")
-            except DevtoolsConnectionError:
-                self.log.system(f"Couldn't connect to devtools ( {self.devtools.url} )")
-
-        self.log.system("---")
-
-        self.log.system(loop=asyncio.get_running_loop())
-        self.log.system(features=self.features)
-        if constants.LOG_FILE is not None:
-            _log_path = os.path.abspath(constants.LOG_FILE)
-            self.log.system(f"Writing logs to {_log_path!r}")
-
-        try:
-            if self.css_path:
-                self.stylesheet.read_all(self.css_path)
-            for read_from, css, tie_breaker, scope in self._get_default_css():
-                self.stylesheet.add_source(
-                    css,
-                    read_from=read_from,
-                    is_default_css=True,
-                    tie_breaker=tie_breaker,
-                    scope=scope,
-                )
-            if self.CSS:
-                try:
-                    app_path = inspect.getfile(self.__class__)
-                except (TypeError, OSError):
-                    app_path = ""
-                read_from = (app_path, f"{self.__class__.__name__}.CSS")
-                self.stylesheet.add_source(
-                    self.CSS, read_from=read_from, is_default_css=False
-                )
-        except Exception as error:
-            self._handle_exception(error)
-            self._print_error_renderables()
-            return
+                if self.css_path:
+                    self.stylesheet.read_all(self.css_path)
+                for read_from, css, tie_breaker, scope in self._get_default_css():
+                    self.stylesheet.add_source(
+                        css,
+                        read_from=read_from,
+                        is_default_css=True,
+                        tie_breaker=tie_breaker,
+                        scope=scope,
+                    )
+                if self.CSS:
+                    try:
+                        app_path = inspect.getfile(self.__class__)
+                    except (TypeError, OSError):
+                        app_path = ""
+                    read_from = (app_path, f"{self.__class__.__name__}.CSS")
+                    self.stylesheet.add_source(
+                        self.CSS, read_from=read_from, is_default_css=False
+                    )
+            except Exception as error:
+                self._handle_exception(error)
+                self._print_error_renderables()
+                return False
 
-        if self.css_monitor:
-            self.set_interval(0.25, self.css_monitor, name="css monitor")
-            self.log.system("STARTED", self.css_monitor)
+            if self.css_monitor:
+                self.set_interval(0.25, self.css_monitor, name="css monitor")
+                self.log.system("STARTED", self.css_monitor)
+            return True
 
         async def run_process_messages():
             """The main message loop, invoke below."""
@@ -2788,40 +2800,45 @@ async def invoke_ready_callback() -> None:
                 finally:
                     await Timer._stop_all(self._timers)
 
-        self._running = True
-        try:
-            load_event = events.Load()
-            await self._dispatch_message(load_event)
+        with self._context():
+            if not await app_prelude():
+                return
+            self._running = True
+            try:
+                load_event = events.Load()
+                await self._dispatch_message(load_event)
 
-            driver = self._driver = self._build_driver(
-                headless=headless,
-                inline=inline,
-                mouse=mouse,
-                size=terminal_size,
-            )
-            self.log(driver=driver)
+                driver = self._driver = self._build_driver(
+                    headless=headless,
+                    inline=inline,
+                    mouse=mouse,
+                    size=terminal_size,
+                )
+                self.log(driver=driver)
 
-            if not self._exit:
-                driver.start_application_mode()
-                try:
-                    with redirect_stdout(self._capture_stdout):
-                        with redirect_stderr(self._capture_stderr):
-                            await run_process_messages()
+                if not self._exit:
+                    driver.start_application_mode()
+                    try:
+                        with redirect_stdout(self._capture_stdout):
+                            with redirect_stderr(self._capture_stderr):
+                                await run_process_messages()
 
-                finally:
-                    if self._driver.is_inline:
-                        cursor_x, cursor_y = self._previous_cursor_position
-                        self._driver.write(
-                            Control.move(-cursor_x, -cursor_y + 1).segment.text
-                        )
-                        if inline_no_clear and not not self.app._exit_renderables:
-                            console = Console()
-                            console.print(self.screen._compositor)
-                            console.print()
+                    finally:
+                        if hasattr(self, "_watchers"):
+                            self._watchers.clear()
+                        if self._driver.is_inline:
+                            cursor_x, cursor_y = self._previous_cursor_position
+                            self._driver.write(
+                                Control.move(-cursor_x, -cursor_y + 1).segment.text
+                            )
+                            if inline_no_clear and not not self.app._exit_renderables:
+                                console = Console()
+                                console.print(self.screen._compositor)
+                                console.print()
 
-                    driver.stop_application_mode()
-        except Exception as error:
-            self._handle_exception(error)
+                        driver.stop_application_mode()
+            except Exception as error:
+                self._handle_exception(error)
 
     async def _pre_process(self) -> bool:
         """Special case for the app, which doesn't need the functionality in MessagePump."""
@@ -3029,6 +3046,8 @@ async def _close_all(self) -> None:
                 if stack_screen._running:
                     await self._prune(stack_screen)
             stack.clear()
+        self._installed_screens.clear()
+        self._modes.clear()
 
         # Close any remaining nodes
         # Should be empty by now
@@ -3045,12 +3064,13 @@ async def _shutdown(self) -> None:
 
         await self._close_all()
         await self._close_messages()
-
         await self._dispatch_message(events.Unmount())
 
         if self._driver is not None:
             self._driver.close()
 
+        self._nodes._clear()
+
         if self.devtools is not None and self.devtools.is_connected:
             await self._disconnect_devtools()
 
diff --git a/src/textual/command.py b/src/textual/command.py
index 54bc5a4c73..55789e0bb5 100644
--- a/src/textual/command.py
+++ b/src/textual/command.py
@@ -241,6 +241,7 @@ async def _wait_init(self) -> None:
         """Wait for initialization."""
         if self._init_task is not None:
             await self._init_task
+        self._init_task = None
 
     async def startup(self) -> None:
         """Called after the Provider is initialized, but before any calls to `search`."""
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index 3107375a83..96cc8e2b9a 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -1,7 +1,6 @@
 from __future__ import annotations
 
-from functools import lru_cache
-from typing import Iterable, NoReturn, Sequence, cast
+from typing import Iterable, NoReturn, cast
 
 import rich.repr
 
@@ -137,19 +136,6 @@ def add_declaration(self, declaration: Declaration) -> None:
         except Exception as error:
             self.error(declaration.name, declaration.token, str(error))
 
-    @lru_cache(maxsize=None)
-    def _get_processable_rule_names(self) -> Sequence[str]:
-        """
-        Returns the list of CSS properties we can manage -
-        i.e. the ones for which we have a `process_[property name]` method
-
-        Returns:
-            All the "Python-ised" CSS property names this class can handle.
-
-        Example: ("width", "background", "offset_x", ...)
-        """
-        return [attr[8:] for attr in dir(self) if attr.startswith("process_")]
-
     def _process_enum_multiple(
         self, name: str, tokens: list[Token], valid_values: set[str], count: int
     ) -> tuple[str, ...]:
@@ -1154,4 +1140,7 @@ def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
 
         Example: returns "background" for rule_name "bkgrund", "offset_x" for "ofset_x"
         """
-        return get_suggestion(rule_name, self._get_processable_rule_names())
+        processable_rules_name = [
+            attr[8:] for attr in dir(self) if attr.startswith("process_")
+        ]
+        return get_suggestion(rule_name, processable_rules_name)
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index c2d14ea02b..8fe230bc75 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field
-from functools import lru_cache, partial
+from functools import partial
 from operator import attrgetter
 from typing import TYPE_CHECKING, Any, Callable, Iterable, cast
 
@@ -554,7 +554,6 @@ def is_animatable(cls, rule: str) -> bool:
         return rule in cls.ANIMATABLE
 
     @classmethod
-    @lru_cache(maxsize=1024)
     def parse(
         cls, css: str, read_from: CSSLocation, *, node: DOMNode | None = None
     ) -> Styles:
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 317fd0fcd3..2e0316bb3b 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -229,7 +229,7 @@ def app(self) -> "App[object]":
                 if node is None:
                     raise NoActiveAppError()
                 node = node._parent
-            active_app.set(node)
+
             return node
 
     @property
@@ -501,24 +501,27 @@ def _start_messages(self) -> None:
 
     async def _process_messages(self) -> None:
         self._running = True
-        active_message_pump.set(self)
 
-        if not await self._pre_process():
-            self._running = False
-            return
+        with self._context():
+            if not await self._pre_process():
+                self._running = False
+                return
 
-        try:
-            await self._process_messages_loop()
-        except CancelledError:
-            pass
-        finally:
-            self._running = False
             try:
-                if self._timers:
-                    await Timer._stop_all(self._timers)
-                    self._timers.clear()
+                await self._process_messages_loop()
+            except CancelledError:
+                pass
             finally:
-                await self._message_loop_exit()
+                self._running = False
+                try:
+                    if self._timers:
+                        await Timer._stop_all(self._timers)
+                        self._timers.clear()
+                    if hasattr(self, "_watchers"):
+                        self._watchers.clear()
+                finally:
+                    await self._message_loop_exit()
+        self._task = None
 
     async def _message_loop_exit(self) -> None:
         """Called when the message loop has completed."""
@@ -558,6 +561,15 @@ def _close_messages_no_wait(self) -> None:
         """Request the message queue to immediately exit."""
         self._message_queue.put_nowait(messages.CloseMessages())
 
+    @contextmanager
+    def _context(self) -> Generator[None, None, None]:
+        """Context manager to set ContextVars."""
+        reset_token = active_message_pump.set(self)
+        try:
+            yield
+        finally:
+            active_message_pump.reset(reset_token)
+
     async def _on_close_messages(self, message: messages.CloseMessages) -> None:
         await self._close_messages()
 
diff --git a/src/textual/reactive.py b/src/textual/reactive.py
index 64b6e007ce..4fa8deb59d 100644
--- a/src/textual/reactive.py
+++ b/src/textual/reactive.py
@@ -24,7 +24,6 @@
 
 from . import events
 from ._callback import count_parameters
-from ._context import active_message_pump
 from ._types import (
     MessageTarget,
     WatchCallbackBothValuesType,
@@ -82,8 +81,8 @@ def invoke_watcher(
     _rich_traceback_omit = True
 
     param_count = count_parameters(watch_function)
-    reset_token = active_message_pump.set(watcher_object)
-    try:
+
+    with watcher_object._context():
         if param_count == 2:
             watch_result = cast(WatchCallbackBothValuesType, watch_function)(
                 old_value, value
@@ -97,8 +96,6 @@ def invoke_watcher(
             watcher_object.call_next(
                 partial(await_watcher, watcher_object, watch_result)
             )
-    finally:
-        active_message_pump.reset(reset_token)
 
 
 @rich.repr.auto
@@ -203,7 +200,7 @@ def _reset_object(cls, obj: object) -> None:
         Args:
             obj: A reactive object.
         """
-        getattr(obj, "__watchers", {}).clear()
+        getattr(obj, "_watchers", {}).clear()
         getattr(obj, "__computes", []).clear()
 
     def __set_name__(self, owner: Type[MessageTarget], name: str) -> None:
@@ -351,7 +348,7 @@ def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
 
         # Process "global" watchers
         watchers: list[tuple[Reactable, WatchCallbackType]]
-        watchers = getattr(obj, "__watchers", {}).get(name, [])
+        watchers = getattr(obj, "_watchers", {}).get(name, [])
         # Remove any watchers for reactables that have since closed
         if watchers:
             watchers[:] = [
@@ -466,10 +463,10 @@ def _watch(
         callback: A callable to call when the attribute changes.
         init: True to call watcher initialization.
     """
-    if not hasattr(obj, "__watchers"):
-        setattr(obj, "__watchers", {})
+    if not hasattr(obj, "_watchers"):
+        setattr(obj, "_watchers", {})
     watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] = getattr(
-        obj, "__watchers"
+        obj, "_watchers"
     )
     watcher_list = watchers.setdefault(attribute_name, [])
     if any(callback == callback_from_list for _, callback_from_list in watcher_list):
diff --git a/src/textual/screen.py b/src/textual/screen.py
index 7697e38f20..4ea8eaba26 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -892,7 +892,8 @@ async def _on_idle(self, event: events.Idle) -> None:
         finally:
             if self._bindings_updated:
                 self._bindings_updated = False
-                self.app.call_later(self.bindings_updated_signal.publish, self)
+                if self.is_attached and not self.app._exit:
+                    self.app.call_later(self.bindings_updated_signal.publish, self)
 
     def _compositor_refresh(self) -> None:
         """Perform a compositor refresh."""
@@ -978,11 +979,8 @@ async def _invoke_and_clear_callbacks(self) -> None:
             callbacks = self._callbacks[:]
             self._callbacks.clear()
             for callback, message_pump in callbacks:
-                reset_token = active_message_pump.set(message_pump)
-                try:
+                with message_pump._context():
                     await invoke(callback)
-                finally:
-                    active_message_pump.reset(reset_token)
 
     def _invoke_later(self, callback: CallbackType, sender: MessagePump) -> None:
         """Enqueue a callback to be invoked after the screen is repainted.
@@ -1012,6 +1010,16 @@ def _push_result_callback(
             ResultCallback[Optional[ScreenResultType]](requester, callback, future)
         )
 
+    async def _message_loop_exit(self) -> None:
+        await super()._message_loop_exit()
+        self._compositor.clear()
+        self._dirty_widgets.clear()
+        self._dirty_regions.clear()
+        self._arrangement_cache.clear()
+        self.screen_layout_refresh_signal.unsubscribe(self)
+        self._nodes._clear()
+        self._task = None
+
     def _pop_result_callback(self) -> None:
         """Remove the latest result callback from the stack."""
         self._result_callbacks.pop()
diff --git a/src/textual/signal.py b/src/textual/signal.py
index 5960ea687d..2e9a946e90 100644
--- a/src/textual/signal.py
+++ b/src/textual/signal.py
@@ -10,7 +10,7 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, TypeVar, Union
-from weakref import WeakKeyDictionary
+from weakref import WeakKeyDictionary, ref
 
 import rich.repr
 
@@ -41,17 +41,22 @@ def __init__(self, owner: DOMNode, name: str) -> None:
             owner: The owner of this signal.
             name: An identifier for debugging purposes.
         """
-        self._owner = owner
+        self._owner = ref(owner)
         self._name = name
         self._subscriptions: WeakKeyDictionary[
             MessagePump, list[SignalCallbackType]
         ] = WeakKeyDictionary()
 
     def __rich_repr__(self) -> rich.repr.Result:
-        yield "owner", self._owner
+        yield "owner", self.owner
         yield "name", self._name
         yield "subscriptions", list(self._subscriptions.keys())
 
+    @property
+    def owner(self) -> DOMNode | None:
+        """The owner of this Signal, or `None` if there is no owner."""
+        return self._owner()
+
     def subscribe(
         self,
         node: MessagePump,
@@ -108,9 +113,12 @@ def publish(self, data: SignalT) -> None:
 
         """
         # Don't publish if the DOM is not ready or shutting down
-        if not self._owner.is_attached or self._owner._pruning:
+        owner = self.owner
+        if owner is None:
+            return
+        if not owner.is_attached or owner._pruning:
             return
-        for ancestor_node in self._owner.ancestors_with_self:
+        for ancestor_node in owner.ancestors_with_self:
             if not ancestor_node.is_running:
                 return
 
diff --git a/src/textual/timer.py b/src/textual/timer.py
index d158962fd5..e593203ee3 100644
--- a/src/textual/timer.py
+++ b/src/textual/timer.py
@@ -84,7 +84,7 @@ def _start(self) -> None:
         """Start the timer."""
         self._task = create_task(self._run_timer(), name=self.name)
 
-    def stop(self) -> Task:
+    def stop(self) -> None:
         """Stop the timer.
 
         Returns:
@@ -92,15 +92,11 @@ def stop(self) -> Task:
 
         """
         if self._task is None:
-
-            async def noop() -> None:
-                """A dummy task."""
-
-            return create_task(noop())
+            return
 
         self._active.set()
         self._task.cancel()
-        return self._task
+        self._task = None
 
     @classmethod
     async def _stop_all(cls, timers: Iterable[Timer]) -> None:
@@ -123,6 +119,7 @@ async def stop_timer(timer: Timer) -> None:
                     await timer._task
                 except CancelledError:
                     pass
+                timer._task = None
 
         await gather(*[stop_timer(timer) for timer in list(timers)])
 
diff --git a/src/textual/widget.py b/src/textual/widget.py
index b71cc971ae..58305d0497 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -49,7 +49,7 @@
 from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
 from ._arrange import DockArrangeResult, arrange
 from ._compose import compose
-from ._context import NoActiveAppError, active_app
+from ._context import NoActiveAppError
 from ._debug import get_caller_file_and_line
 from ._dispatch_key import dispatch_key
 from ._easing import DEFAULT_SCROLL_EASING
@@ -1194,9 +1194,10 @@ async def recompose(self) -> None:
             return
 
         async with self.batch():
-            await self.query("*").exclude(".-textual-system").remove()
+            await self.query_children("*").exclude(".-textual-system").remove()
             if self.is_attached:
-                await self.mount_all(compose(self))
+                compose_nodes = compose(self)
+                await self.mount_all(compose_nodes)
 
     def _post_register(self, app: App) -> None:
         """Called when the instance is registered.
@@ -1883,7 +1884,7 @@ def _console(self) -> Console:
         Returns:
             A Rich console object.
         """
-        return active_app.get().console
+        return self.app.console
 
     @property
     def _has_relative_children_width(self) -> bool:
@@ -3676,6 +3677,11 @@ async def _message_loop_exit(self) -> None:
         parent._nodes._remove(self)
         self.app._registry.discard(self)
         self._detach()
+        self._arrangement_cache.clear()
+        self._nodes._clear()
+        self._render_cache = _RenderCache(NULL_SIZE, [])
+        self._component_styles.clear()
+        self._query_one_cache.clear()
 
     async def _on_idle(self, event: events.Idle) -> None:
         """Called when there are no more events on the queue.
diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py
index 0d51f9f76e..98214a56f7 100644
--- a/src/textual/widgets/_footer.py
+++ b/src/textual/widgets/_footer.py
@@ -227,15 +227,15 @@ def compose(self) -> ComposeResult:
                     )
                     break
 
+    async def bindings_changed(self, screen: Screen) -> None:
+        self._bindings_ready = True
+        if not screen.app.app_focus:
+            return
+        if self.is_attached and screen is self.screen:
+            await self.recompose()
+
     def on_mount(self) -> None:
-        async def bindings_changed(screen: Screen) -> None:
-            self._bindings_ready = True
-            if not screen.app.app_focus:
-                return
-            if self.is_attached and screen is self.screen:
-                await self.recompose()
-
-        self.screen.bindings_updated_signal.subscribe(self, bindings_changed)
+        self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed)
 
     def on_unmount(self) -> None:
         self.screen.bindings_updated_signal.unsubscribe(self)
diff --git a/src/textual/worker.py b/src/textual/worker.py
index 9ad60a64ac..c0b1cbbffd 100644
--- a/src/textual/worker.py
+++ b/src/textual/worker.py
@@ -359,30 +359,30 @@ async def _run(self, app: App) -> None:
         Args:
             app: App instance.
         """
-        app._set_active()
-        active_worker.set(self)
+        with app._context():
+            active_worker.set(self)
 
-        self.state = WorkerState.RUNNING
-        app.log.worker(self)
-        try:
-            self._result = await self.run()
-        except asyncio.CancelledError as error:
-            self.state = WorkerState.CANCELLED
-            self._error = error
-            app.log.worker(self)
-        except Exception as error:
-            self.state = WorkerState.ERROR
-            self._error = error
-            app.log.worker(self, "failed", repr(error))
-            from rich.traceback import Traceback
-
-            app.log.worker(Traceback())
-            if self.exit_on_error:
-                worker_failed = WorkerFailed(self._error)
-                app._handle_exception(worker_failed)
-        else:
-            self.state = WorkerState.SUCCESS
+            self.state = WorkerState.RUNNING
             app.log.worker(self)
+            try:
+                self._result = await self.run()
+            except asyncio.CancelledError as error:
+                self.state = WorkerState.CANCELLED
+                self._error = error
+                app.log.worker(self)
+            except Exception as error:
+                self.state = WorkerState.ERROR
+                self._error = error
+                app.log.worker(self, "failed", repr(error))
+                from rich.traceback import Traceback
+
+                app.log.worker(Traceback())
+                if self.exit_on_error:
+                    worker_failed = WorkerFailed(self._error)
+                    app._handle_exception(worker_failed)
+            else:
+                self.state = WorkerState.SUCCESS
+                app.log.worker(self)
 
     def _start(
         self, app: App, done_callback: Callable[[Worker], None] | None = None
diff --git a/tests/layouts/test_horizontal.py b/tests/layouts/test_horizontal.py
index 05ce4c8905..62d766aecb 100644
--- a/tests/layouts/test_horizontal.py
+++ b/tests/layouts/test_horizontal.py
@@ -19,11 +19,11 @@ def compose(self) -> ComposeResult:
             yield self.horizontal
 
     app = HorizontalAutoWidth()
-    async with app.run_test():
-        yield app
+    yield app
 
 
 async def test_horizontal_get_content_width(app):
-    size = app.screen.size
-    width = app.horizontal.get_content_width(size, size)
-    assert width == 15
+    async with app.run_test():
+        size = app.screen.size
+        width = app.horizontal.get_content_width(size, size)
+        assert width == 15
diff --git a/tests/test_count_parameters.py b/tests/test_count_parameters.py
new file mode 100644
index 0000000000..be3adda3b8
--- /dev/null
+++ b/tests/test_count_parameters.py
@@ -0,0 +1,57 @@
+from functools import partial
+
+from textual._callback import count_parameters
+
+
+def test_functions() -> None:
+    """Test count parameters of functions."""
+
+    def foo(): ...
+    def bar(a): ...
+    def baz(a, b): ...
+
+    # repeat to allow for caching
+    for _ in range(3):
+        assert count_parameters(foo) == 0
+        assert count_parameters(bar) == 1
+        assert count_parameters(baz) == 2
+
+
+def test_methods() -> None:
+    """Test count parameters of methods."""
+
+    class Foo:
+        def foo(self): ...
+        def bar(self, a): ...
+        def baz(self, a, b): ...
+
+    foo = Foo()
+
+    # repeat to allow for caching
+    for _ in range(3):
+        assert count_parameters(foo.foo) == 0
+        assert count_parameters(foo.bar) == 1
+        assert count_parameters(foo.baz) == 2
+
+
+def test_partials() -> None:
+    """Test count parameters of partials."""
+
+    class Foo:
+        def method(self, a, b, c, d): ...
+
+    foo = Foo()
+
+    partial0 = partial(foo.method)
+    partial1 = partial(foo.method, 10)
+    partial2 = partial(foo.method, b=10, c=20)
+
+    for _ in range(3):
+        assert count_parameters(partial0) == 4
+        assert count_parameters(partial0) == 4
+
+        assert count_parameters(partial1) == 3
+        assert count_parameters(partial1) == 3
+
+        assert count_parameters(partial2) == 2
+        assert count_parameters(partial2) == 2
diff --git a/tests/test_focus.py b/tests/test_focus.py
index ee955e23e5..a23046bc94 100644
--- a/tests/test_focus.py
+++ b/tests/test_focus.py
@@ -22,46 +22,47 @@ class ChildrenFocusableOnly(Widget, can_focus=False, can_focus_children=True):
 @pytest.fixture
 def screen() -> Screen:
     app = App()
-    app._set_active()
-    app.push_screen(Screen())
 
-    screen = app.screen
+    with app._context():
+        app.push_screen(Screen())
 
-    # The classes even/odd alternate along the focus chain.
-    # The classes in/out identify nested widgets.
-    screen._add_children(
-        Focusable(id="foo", classes="a"),
-        NonFocusable(id="bar"),
-        Focusable(Focusable(id="Paul", classes="c"), id="container1", classes="b"),
-        NonFocusable(Focusable(id="Jessica", classes="a"), id="container2"),
-        Focusable(id="baz", classes="b"),
-        ChildrenFocusableOnly(Focusable(id="child", classes="c")),
-    )
+        screen = app.screen
 
-    return screen
+        # The classes even/odd alternate along the focus chain.
+        # The classes in/out identify nested widgets.
+        screen._add_children(
+            Focusable(id="foo", classes="a"),
+            NonFocusable(id="bar"),
+            Focusable(Focusable(id="Paul", classes="c"), id="container1", classes="b"),
+            NonFocusable(Focusable(id="Jessica", classes="a"), id="container2"),
+            Focusable(id="baz", classes="b"),
+            ChildrenFocusableOnly(Focusable(id="child", classes="c")),
+        )
+
+        return screen
 
 
 def test_focus_chain():
     app = App()
-    app._set_active()
-    app.push_screen(Screen())
+    with app._context():
+        app.push_screen(Screen())
 
-    screen = app.screen
+        screen = app.screen
 
-    # Check empty focus chain
-    assert not screen.focus_chain
+        # Check empty focus chain
+        assert not screen.focus_chain
 
-    app.screen._add_children(
-        Focusable(id="foo"),
-        NonFocusable(id="bar"),
-        Focusable(Focusable(id="Paul"), id="container1"),
-        NonFocusable(Focusable(id="Jessica"), id="container2"),
-        Focusable(id="baz"),
-        ChildrenFocusableOnly(Focusable(id="child")),
-    )
+        app.screen._add_children(
+            Focusable(id="foo"),
+            NonFocusable(id="bar"),
+            Focusable(Focusable(id="Paul"), id="container1"),
+            NonFocusable(Focusable(id="Jessica"), id="container2"),
+            Focusable(id="baz"),
+            ChildrenFocusableOnly(Focusable(id="child")),
+        )
 
-    focus_chain = [widget.id for widget in screen.focus_chain]
-    assert focus_chain == ["foo", "container1", "Paul", "baz", "child"]
+        focus_chain = [widget.id for widget in screen.focus_chain]
+        assert focus_chain == ["foo", "container1", "Paul", "baz", "child"]
 
 
 def test_allow_focus():
@@ -90,18 +91,19 @@ def allow_focus_children(self) -> bool:
             return False
 
     app = App()
-    app._set_active()
-    app.push_screen(Screen())
 
-    app.screen._add_children(
-        Focusable(id="foo"),
-        NonFocusable(id="bar"),
-        FocusableContainer(Button("egg", id="egg")),
-        NonFocusableContainer(Button("EGG", id="qux")),
-    )
-    assert [widget.id for widget in app.screen.focus_chain] == ["foo", "egg"]
-    assert focusable_allow_focus_called
-    assert non_focusable_allow_focus_called
+    with app._context():
+        app.push_screen(Screen())
+
+        app.screen._add_children(
+            Focusable(id="foo"),
+            NonFocusable(id="bar"),
+            FocusableContainer(Button("egg", id="egg")),
+            NonFocusableContainer(Button("EGG", id="qux")),
+        )
+        assert [widget.id for widget in app.screen.focus_chain] == ["foo", "egg"]
+        assert focusable_allow_focus_called
+        assert non_focusable_allow_focus_called
 
 
 def test_focus_next_and_previous(screen: Screen):
@@ -188,47 +190,47 @@ def test_focus_next_and_previous_with_str_selector(screen: Screen):
 def test_focus_next_and_previous_with_type_selector_without_self():
     """Test moving the focus with a selector that does not match the currently focused node."""
     app = App()
-    app._set_active()
-    app.push_screen(Screen())
-
-    screen = app.screen
-
-    from textual.containers import Horizontal, VerticalScroll
-    from textual.widgets import Button, Input, Switch
-
-    screen._add_children(
-        VerticalScroll(
-            Horizontal(
-                Input(id="w3"),
-                Switch(id="w4"),
-                Input(id="w5"),
-                Button(id="w6"),
-                Switch(id="w7"),
-                id="w2",
-            ),
-            Horizontal(
-                Button(id="w9"),
-                Switch(id="w10"),
-                Button(id="w11"),
-                Input(id="w12"),
-                Input(id="w13"),
-                id="w8",
-            ),
-            id="w1",
+    with app._context():
+        app.push_screen(Screen())
+
+        screen = app.screen
+
+        from textual.containers import Horizontal, VerticalScroll
+        from textual.widgets import Button, Input, Switch
+
+        screen._add_children(
+            VerticalScroll(
+                Horizontal(
+                    Input(id="w3"),
+                    Switch(id="w4"),
+                    Input(id="w5"),
+                    Button(id="w6"),
+                    Switch(id="w7"),
+                    id="w2",
+                ),
+                Horizontal(
+                    Button(id="w9"),
+                    Switch(id="w10"),
+                    Button(id="w11"),
+                    Input(id="w12"),
+                    Input(id="w13"),
+                    id="w8",
+                ),
+                id="w1",
+            )
         )
-    )
 
-    screen.set_focus(screen.query_one("#w3"))
-    assert screen.focused.id == "w3"
+        screen.set_focus(screen.query_one("#w3"))
+        assert screen.focused.id == "w3"
 
-    assert screen.focus_next(Button).id == "w6"
-    assert screen.focus_next(Switch).id == "w7"
-    assert screen.focus_next(Input).id == "w12"
+        assert screen.focus_next(Button).id == "w6"
+        assert screen.focus_next(Switch).id == "w7"
+        assert screen.focus_next(Input).id == "w12"
 
-    assert screen.focus_previous(Button).id == "w11"
-    assert screen.focus_previous(Switch).id == "w10"
-    assert screen.focus_previous(Button).id == "w9"
-    assert screen.focus_previous(Input).id == "w5"
+        assert screen.focus_previous(Button).id == "w11"
+        assert screen.focus_previous(Switch).id == "w10"
+        assert screen.focus_previous(Button).id == "w9"
+        assert screen.focus_previous(Input).id == "w5"
 
 
 def test_focus_next_and_previous_with_str_selector_without_self(screen: Screen):
diff --git a/tests/test_gc.py b/tests/test_gc.py
new file mode 100644
index 0000000000..b21a7d9b72
--- /dev/null
+++ b/tests/test_gc.py
@@ -0,0 +1,87 @@
+import asyncio
+import gc
+
+import pytest
+
+from textual.app import App, ComposeResult
+from textual.containers import Vertical
+from textual.widgets import Footer, Header, Label
+
+
+def count_nodes() -> int:
+    """Count number of references to DOMNodes."""
+    dom_nodes = [
+        obj
+        for obj in gc.get_objects()
+        if any(cls.__name__ == "DOMNode" for cls in obj.__class__.__mro__)
+    ]
+    print(dom_nodes)
+    return len(dom_nodes)
+
+
+async def run_app() -> None:
+    """Run a dummy app."""
+
+    class DummyApp(App):
+        """Dummy app with a few widgets."""
+
+        def compose(self) -> ComposeResult:
+            yield Header()
+            with Vertical():
+                yield Label("foo")
+                yield Label("bar")
+            yield Footer()
+
+    app = DummyApp()
+
+    async with app.run_test() as pilot:
+        # We should have a bunch of DOMNodes while the test is running
+        assert count_nodes() > 0
+        await pilot.press("ctrl+c")
+
+    assert not app._running
+
+    # Force a GC collection
+    gc.collect()
+
+    # After the test, all DOMNodes will have been torn down
+    assert count_nodes() == 1
+
+
+async def _count_app_nodes() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/4959"""
+
+    # Should be no DOMNodes yet
+    assert count_nodes() == 0
+
+    await run_app()
+    await asyncio.sleep(0)
+
+    gc.collect()
+
+    nodes_remaining = count_nodes()
+
+    if nodes_remaining:
+        print("NODES REMAINING")
+
+        import objgraph
+
+        objgraph.show_backrefs(
+            [
+                obj
+                for obj in gc.get_objects()
+                if any(cls.__name__ == "App" for cls in obj.__class__.__mro__)
+            ],
+            filename="graph.png",
+            max_depth=15,
+        )
+
+    assert nodes_remaining == 0
+
+
+# It looks like PyTest holds on to references to DOMNodes
+# So this will only pass if ran in isolation
+@pytest.mark.xfail
+async def test_gc():
+    """Regression test for https://github.com/Textualize/textual/issues/4959"""
+    await _count_app_nodes()
diff --git a/tests/test_path.py b/tests/test_path.py
index d7088f8be7..3d5203a6e8 100644
--- a/tests/test_path.py
+++ b/tests/test_path.py
@@ -30,14 +30,15 @@ class ListPathApp(App[None]):
 
 
 @pytest.mark.parametrize(
-    "app,expected_css_path_attribute",
+    "app_class,expected_css_path_attribute",
     [
-        (RelativePathObjectApp(), [APP_DIR / "test.tcss"]),
-        (RelativePathStrApp(), [APP_DIR / "test.tcss"]),
-        (AbsolutePathObjectApp(), [Path("/tmp/test.tcss")]),
-        (AbsolutePathStrApp(), [Path("/tmp/test.tcss")]),
-        (ListPathApp(), [APP_DIR / "test.tcss", Path("/another/path.tcss")]),
+        (RelativePathObjectApp, [APP_DIR / "test.tcss"]),
+        (RelativePathStrApp, [APP_DIR / "test.tcss"]),
+        (AbsolutePathObjectApp, [Path("/tmp/test.tcss")]),
+        (AbsolutePathStrApp, [Path("/tmp/test.tcss")]),
+        (ListPathApp, [APP_DIR / "test.tcss", Path("/another/path.tcss")]),
     ],
 )
-def test_css_paths_of_various_types(app, expected_css_path_attribute):
+def test_css_paths_of_various_types(app_class, expected_css_path_attribute):
+    app = app_class()
     assert app.css_path == [path.absolute() for path in expected_css_path_attribute]
diff --git a/tests/test_screens.py b/tests/test_screens.py
index 00f3504361..a53abb7049 100644
--- a/tests/test_screens.py
+++ b/tests/test_screens.py
@@ -74,89 +74,88 @@ async def test_screens():
     # There should be nothing in the children since the app hasn't run yet
     assert not app._nodes
     assert not app.children
-    app._set_active()
-
-    with pytest.raises(ScreenStackError):
-        app.screen
-
-    assert not app._installed_screens
-
-    screen1 = Screen(name="screen1")
-    screen2 = Screen(name="screen2")
-    screen3 = Screen(name="screen3")
-
-    # installs screens
-    app.install_screen(screen1, "screen1")
-    app.install_screen(screen2, "screen2")
-
-    # Installing a screen does not add it to the DOM
-    assert not app._nodes
-    assert not app.children
+    with app._context():
+        with pytest.raises(ScreenStackError):
+            app.screen
+
+        assert not app._installed_screens
+
+        screen1 = Screen(name="screen1")
+        screen2 = Screen(name="screen2")
+        screen3 = Screen(name="screen3")
+
+        # installs screens
+        app.install_screen(screen1, "screen1")
+        app.install_screen(screen2, "screen2")
+
+        # Installing a screen does not add it to the DOM
+        assert not app._nodes
+        assert not app.children
+
+        # Check they are installed
+        assert app.is_screen_installed("screen1")
+        assert app.is_screen_installed("screen2")
+
+        assert app.get_screen("screen1") is screen1
+        with pytest.raises(KeyError):
+            app.get_screen("foo")
+
+        # Check screen3 is not installed
+        assert not app.is_screen_installed("screen3")
+
+        # Installs screen3
+        app.install_screen(screen3, "screen3")
+        # Confirm installed
+        assert app.is_screen_installed("screen3")
+
+        # Check screen stack is empty
+        assert app.screen_stack == []
+        # Push a screen
+        await app.push_screen("screen1")
+        # Check it is on the stack
+        assert app.screen_stack == [screen1]
+        # Check it is current
+        assert app.screen is screen1
+        # There should be one item in the children view
+        assert app.children == (screen1,)
+
+        # Switch to another screen
+        await app.switch_screen("screen2")
+        # Check it has changed the stack and that it is current
+        assert app.screen_stack == [screen2]
+        assert app.screen is screen2
+        assert app.children == (screen2,)
+
+        # Push another screen
+        await app.push_screen("screen3")
+        assert app.screen_stack == [screen2, screen3]
+        assert app.screen is screen3
+        # Only the current screen is in children
+        assert app.children == (screen3,)
+
+        # Pop a screen
+        await app.pop_screen()
+        assert app.screen is screen2
+        assert app.screen_stack == [screen2]
+
+        # Uninstall screens
+        app.uninstall_screen(screen1)
+        assert not app.is_screen_installed(screen1)
+        app.uninstall_screen("screen3")
+        assert not app.is_screen_installed(screen1)
+
+        # Check we can't uninstall a screen on the stack
+        with pytest.raises(ScreenStackError):
+            app.uninstall_screen(screen2)
 
-    # Check they are installed
-    assert app.is_screen_installed("screen1")
-    assert app.is_screen_installed("screen2")
-
-    assert app.get_screen("screen1") is screen1
-    with pytest.raises(KeyError):
-        app.get_screen("foo")
-
-    # Check screen3 is not installed
-    assert not app.is_screen_installed("screen3")
-
-    # Installs screen3
-    app.install_screen(screen3, "screen3")
-    # Confirm installed
-    assert app.is_screen_installed("screen3")
-
-    # Check screen stack is empty
-    assert app.screen_stack == []
-    # Push a screen
-    await app.push_screen("screen1")
-    # Check it is on the stack
-    assert app.screen_stack == [screen1]
-    # Check it is current
-    assert app.screen is screen1
-    # There should be one item in the children view
-    assert app.children == (screen1,)
-
-    # Switch to another screen
-    await app.switch_screen("screen2")
-    # Check it has changed the stack and that it is current
-    assert app.screen_stack == [screen2]
-    assert app.screen is screen2
-    assert app.children == (screen2,)
-
-    # Push another screen
-    await app.push_screen("screen3")
-    assert app.screen_stack == [screen2, screen3]
-    assert app.screen is screen3
-    # Only the current screen is in children
-    assert app.children == (screen3,)
-
-    # Pop a screen
-    await app.pop_screen()
-    assert app.screen is screen2
-    assert app.screen_stack == [screen2]
-
-    # Uninstall screens
-    app.uninstall_screen(screen1)
-    assert not app.is_screen_installed(screen1)
-    app.uninstall_screen("screen3")
-    assert not app.is_screen_installed(screen1)
-
-    # Check we can't uninstall a screen on the stack
-    with pytest.raises(ScreenStackError):
-        app.uninstall_screen(screen2)
-
-    # Check we can't pop last screen
-    with pytest.raises(ScreenStackError):
-        app.pop_screen()
+        # Check we can't pop last screen
+        with pytest.raises(ScreenStackError):
+            app.pop_screen()
 
-    screen1.remove()
-    screen2.remove()
-    screen3.remove()
-    await app._shutdown()
+        screen1.remove()
+        screen2.remove()
+        screen3.remove()
+        await app._shutdown()
 
 
 async def test_auto_focus_on_screen_if_app_auto_focus_is_none():
diff --git a/tests/test_unmount.py b/tests/test_unmount.py
index 324a3812a3..36c0acbeb1 100644
--- a/tests/test_unmount.py
+++ b/tests/test_unmount.py
@@ -50,4 +50,7 @@ async def on_mount(self) -> None:
         "MyScreen#main",
     ]
 
+    print(unmount_ids)
+    print(expected)
+
     assert unmount_ids == expected
diff --git a/tests/test_widget.py b/tests/test_widget.py
index 2652aec54d..6643916bbb 100644
--- a/tests/test_widget.py
+++ b/tests/test_widget.py
@@ -57,22 +57,21 @@ def render(self) -> str:
     widget3 = TextWidget("foo\nbar\nbaz", id="widget3")
 
     app = App()
-    app._set_active()
+    with app._context():
+        width = widget1.get_content_width(Size(20, 20), Size(80, 24))
+        height = widget1.get_content_height(Size(20, 20), Size(80, 24), width)
+        assert width == 3
+        assert height == 1
 
-    width = widget1.get_content_width(Size(20, 20), Size(80, 24))
-    height = widget1.get_content_height(Size(20, 20), Size(80, 24), width)
-    assert width == 3
-    assert height == 1
+        width = widget2.get_content_width(Size(20, 20), Size(80, 24))
+        height = widget2.get_content_height(Size(20, 20), Size(80, 24), width)
+        assert width == 3
+        assert height == 2
 
-    width = widget2.get_content_width(Size(20, 20), Size(80, 24))
-    height = widget2.get_content_height(Size(20, 20), Size(80, 24), width)
-    assert width == 3
-    assert height == 2
-
-    width = widget3.get_content_width(Size(20, 20), Size(80, 24))
-    height = widget3.get_content_height(Size(20, 20), Size(80, 24), width)
-    assert width == 3
-    assert height == 3
+        width = widget3.get_content_width(Size(20, 20), Size(80, 24))
+        height = widget3.get_content_height(Size(20, 20), Size(80, 24), width)
+        assert width == 3
+        assert height == 3
 
 
 class GetByIdApp(App):
@@ -87,34 +86,38 @@ def compose(self) -> ComposeResult:
             id="parent",
         )
 
+    @property
+    def parent(self) -> Widget:
+        return self.query_one("#parent")
+
 
 @pytest.fixture
 async def hierarchy_app():
     app = GetByIdApp()
-    async with app.run_test():
-        yield app
-
-
-@pytest.fixture
-async def parent(hierarchy_app):
-    yield hierarchy_app.get_widget_by_id("parent")
+    yield app
 
 
-def test_get_child_by_id_gets_first_child(parent):
-    child = parent.get_child_by_id(id="child1")
-    assert child.id == "child1"
-    assert child.get_child_by_id(id="grandchild1").id == "grandchild1"
-    assert parent.get_child_by_id(id="child2").id == "child2"
+async def test_get_child_by_id_gets_first_child(hierarchy_app):
+    async with hierarchy_app.run_test():
+        parent = hierarchy_app.parent
+        child = parent.get_child_by_id(id="child1")
+        assert child.id == "child1"
+        assert child.get_child_by_id(id="grandchild1").id == "grandchild1"
+        assert parent.get_child_by_id(id="child2").id == "child2"
 
 
-def test_get_child_by_id_no_matching_child(parent):
-    with pytest.raises(NoMatches):
-        parent.get_child_by_id(id="doesnt-exist")
+async def test_get_child_by_id_no_matching_child(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        with pytest.raises(NoMatches):
+            parent.get_child_by_id(id="doesnt-exist")
 
 
-def test_get_child_by_id_only_immediate_descendents(parent):
-    with pytest.raises(NoMatches):
-        parent.get_child_by_id(id="grandchild1")
+async def test_get_child_by_id_only_immediate_descendents(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        with pytest.raises(NoMatches):
+            parent.get_child_by_id(id="grandchild1")
 
 
 async def test_get_child_by_type():
@@ -135,51 +138,65 @@ def compose(self) -> ComposeResult:
             app.get_child_by_type(Label)
 
 
-def test_get_widget_by_id_no_matching_child(parent):
-    with pytest.raises(NoMatches):
-        parent.get_widget_by_id(id="i-dont-exist")
+async def test_get_widget_by_id_no_matching_child(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        with pytest.raises(NoMatches):
+            parent.get_widget_by_id(id="i-dont-exist")
 
 
-def test_get_widget_by_id_non_immediate_descendants(parent):
-    result = parent.get_widget_by_id("grandchild1")
-    assert result.id == "grandchild1"
+async def test_get_widget_by_id_non_immediate_descendants(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        result = parent.get_widget_by_id("grandchild1")
+        assert result.id == "grandchild1"
 
 
-def test_get_widget_by_id_immediate_descendants(parent):
-    result = parent.get_widget_by_id("child1")
-    assert result.id == "child1"
+async def test_get_widget_by_id_immediate_descendants(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        result = parent.get_widget_by_id("child1")
+        assert result.id == "child1"
 
 
-def test_get_widget_by_id_doesnt_return_self(parent):
-    with pytest.raises(NoMatches):
-        parent.get_widget_by_id("parent")
+async def test_get_widget_by_id_doesnt_return_self(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        with pytest.raises(NoMatches):
+            parent.get_widget_by_id("parent")
 
 
-def test_get_widgets_app_delegated(hierarchy_app, parent):
+async def test_get_widgets_app_delegated(hierarchy_app):
     # Check that get_child_by_id finds the parent, which is a child of the default Screen
-    queried_parent = hierarchy_app.get_child_by_id("parent")
-    assert queried_parent is parent
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        queried_parent = hierarchy_app.get_child_by_id("parent")
+        assert queried_parent is parent
 
-    # Check that the grandchild (descendant of the default screen) is found
-    grandchild = hierarchy_app.get_widget_by_id("grandchild1")
-    assert grandchild.id == "grandchild1"
+        # Check that the grandchild (descendant of the default screen) is found
+        grandchild = hierarchy_app.get_widget_by_id("grandchild1")
+        assert grandchild.id == "grandchild1"
 
 
-def test_widget_mount_ids_must_be_unique_mounting_all_in_one_go(parent):
-    widget1 = Widget(id="hello")
-    widget2 = Widget(id="hello")
+async def test_widget_mount_ids_must_be_unique_mounting_all_in_one_go(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        widget1 = Widget(id="hello")
+        widget2 = Widget(id="hello")
 
-    with pytest.raises(MountError):
-        parent.mount(widget1, widget2)
+        with pytest.raises(MountError):
+            parent.mount(widget1, widget2)
 
 
-def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(parent):
-    widget1 = Widget(id="hello")
-    widget2 = Widget(id="hello")
+async def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(hierarchy_app):
+    async with hierarchy_app.run_test() as pilot:
+        parent = pilot.app.parent
+        widget1 = Widget(id="hello")
+        widget2 = Widget(id="hello")
 
-    parent.mount(widget1)
-    with pytest.raises(DuplicateIds):
-        parent.mount(widget2)
+        parent.mount(widget1)
+        with pytest.raises(DuplicateIds):
+            parent.mount(widget2)
 
 
 def test_get_pseudo_class_state():
diff --git a/tests/test_widget_mounting.py b/tests/test_widget_mounting.py
index 7babed4781..79f7364ad6 100644
--- a/tests/test_widget_mounting.py
+++ b/tests/test_widget_mounting.py
@@ -116,8 +116,10 @@ async def test_mount_via_app() -> None:
             await pilot.app.mount(Static(), before="Static")
 
 
-def test_mount_error() -> None:
+async def test_mount_error() -> None:
     """Mounting a widget on an un-mounted widget should raise an error."""
-    with pytest.raises(MountError):
-        widget = Widget()
-        widget.mount(Static())
+    app = App()
+    async with app.run_test():
+        with pytest.raises(MountError):
+            widget = Widget()
+            widget.mount(Static())
diff --git a/tests/text_area/test_history.py b/tests/text_area/test_history.py
index 8d50a63f83..1d0c7a0b0b 100644
--- a/tests/text_area/test_history.py
+++ b/tests/text_area/test_history.py
@@ -6,7 +6,6 @@
 
 from textual.app import App, ComposeResult
 from textual.events import Paste
-from textual.pilot import Pilot
 from textual.widgets import TextArea
 from textual.widgets.text_area import EditHistory, Selection
 
@@ -57,300 +56,346 @@ async def text_area(pilot):
     return pilot.app.text_area
 
 
-async def test_simple_undo_redo(pilot, text_area: TextArea):
-    text_area.insert("123", (0, 0))
+async def test_simple_undo_redo():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        text_area.insert("123", (0, 0))
 
-    assert text_area.text == "123"
-    text_area.undo()
-    assert text_area.text == ""
-    text_area.redo()
-    assert text_area.text == "123"
+        assert text_area.text == "123"
+        text_area.undo()
+        assert text_area.text == ""
+        text_area.redo()
+        assert text_area.text == "123"
 
 
-async def test_undo_selection_retained(pilot: Pilot, text_area: TextArea):
+async def test_undo_selection_retained():
     # Select a range of text and press backspace.
-    text_area.text = SIMPLE_TEXT
-    text_area.selection = Selection((0, 0), (2, 3))
-    await pilot.press("backspace")
-    assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n"
-    assert text_area.selection == Selection.cursor((0, 0))
-
-    # Undo the deletion - the text comes back, and the selection is restored.
-    text_area.undo()
-    assert text_area.selection == Selection((0, 0), (2, 3))
-    assert text_area.text == SIMPLE_TEXT
-
-    # Redo the deletion - the text is gone again. The selection goes to the post-delete location.
-    text_area.redo()
-    assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n"
-    assert text_area.selection == Selection.cursor((0, 0))
-
-
-async def test_undo_checkpoint_created_on_cursor_move(
-    pilot: Pilot, text_area: TextArea
-):
-    text_area.text = SIMPLE_TEXT
-    # Characters are inserted on line 0 and 1.
-    checkpoint_one = text_area.text
-    checkpoint_one_selection = text_area.selection
-    await pilot.press("1")  # Added to initial batch.
-
-    # This cursor movement ensures a new checkpoint is created.
-    post_insert_one_location = text_area.selection
-    await pilot.press("down")
-
-    checkpoint_two = text_area.text
-    checkpoint_two_selection = text_area.selection
-    await pilot.press("2")  # Added to new batch.
-
-    checkpoint_three = text_area.text
-    checkpoint_three_selection = text_area.selection
-
-    # Going back to checkpoint two
-    text_area.undo()
-    assert text_area.text == checkpoint_two
-    assert text_area.selection == checkpoint_two_selection
-
-    # Back again to checkpoint one (initial state)
-    text_area.undo()
-    assert text_area.text == checkpoint_one
-    assert text_area.selection == checkpoint_one_selection
-
-    # Redo to move forward to checkpoint two.
-    text_area.redo()
-    assert text_area.text == checkpoint_two
-    assert text_area.selection == post_insert_one_location
-
-    # Redo to move forward to checkpoint three.
-    text_area.redo()
-    assert text_area.text == checkpoint_three
-    assert text_area.selection == checkpoint_three_selection
-
-
-async def test_setting_text_property_resets_history(pilot: Pilot, text_area: TextArea):
-    await pilot.press("1")
-
-    # Programmatically setting text, which should invalidate the history
-    text = "Hello, world!"
-    text_area.text = text
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        text_area.text = SIMPLE_TEXT
+        text_area.selection = Selection((0, 0), (2, 3))
+        await pilot.press("backspace")
+        assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n"
+        assert text_area.selection == Selection.cursor((0, 0))
 
-    # The undo doesn't do anything, since we set the `text` property.
-    text_area.undo()
-    assert text_area.text == text
+        # Undo the deletion - the text comes back, and the selection is restored.
+        text_area.undo()
+        assert text_area.selection == Selection((0, 0), (2, 3))
+        assert text_area.text == SIMPLE_TEXT
 
+        # Redo the deletion - the text is gone again. The selection goes to the post-delete location.
+        text_area.redo()
+        assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n"
+        assert text_area.selection == Selection.cursor((0, 0))
 
-async def test_edits_batched_by_time(pilot: Pilot, text_area: TextArea):
-    # The first "12" is batched since they happen within 2 seconds.
-    text_area.history.mock_time = 0
-    await pilot.press("1")
 
-    text_area.history.mock_time = 1.0
-    await pilot.press("2")
+async def test_undo_checkpoint_created_on_cursor_move():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        text_area.text = SIMPLE_TEXT
+        # Characters are inserted on line 0 and 1.
+        checkpoint_one = text_area.text
+        checkpoint_one_selection = text_area.selection
+        await pilot.press("1")  # Added to initial batch.
+
+        # This cursor movement ensures a new checkpoint is created.
+        post_insert_one_location = text_area.selection
+        await pilot.press("down")
+
+        checkpoint_two = text_area.text
+        checkpoint_two_selection = text_area.selection
+        await pilot.press("2")  # Added to new batch.
+
+        checkpoint_three = text_area.text
+        checkpoint_three_selection = text_area.selection
+
+        # Going back to checkpoint two
+        text_area.undo()
+        assert text_area.text == checkpoint_two
+        assert text_area.selection == checkpoint_two_selection
+
+        # Back again to checkpoint one (initial state)
+        text_area.undo()
+        assert text_area.text == checkpoint_one
+        assert text_area.selection == checkpoint_one_selection
+
+        # Redo to move forward to checkpoint two.
+        text_area.redo()
+        assert text_area.text == checkpoint_two
+        assert text_area.selection == post_insert_one_location
+
+        # Redo to move forward to checkpoint three.
+        text_area.redo()
+        assert text_area.text == checkpoint_three
+        assert text_area.selection == checkpoint_three_selection
+
+
+async def test_setting_text_property_resets_history():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        await pilot.press("1")
 
-    # Since "3" appears 10 seconds later, it's in a separate batch.
-    text_area.history.mock_time += 10.0
-    await pilot.press("3")
+        # Programmatically setting text, which should invalidate the history
+        text = "Hello, world!"
+        text_area.text = text
 
-    assert text_area.text == "123"
+        # The undo doesn't do anything, since we set the `text` property.
+        text_area.undo()
+        assert text_area.text == text
 
-    text_area.undo()
-    assert text_area.text == "12"
 
-    text_area.undo()
-    assert text_area.text == ""
+async def test_edits_batched_by_time():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        # The first "12" is batched since they happen within 2 seconds.
+        text_area.history.mock_time = 0
+        await pilot.press("1")
 
+        text_area.history.mock_time = 1.0
+        await pilot.press("2")
 
-async def test_undo_checkpoint_character_limit_reached(
-    pilot: Pilot, text_area: TextArea
-):
-    await pilot.press("1")
-    # Since the insertion below is > 100 characters it goes to a new batch.
-    text_area.insert("2" * 120)
+        # Since "3" appears 10 seconds later, it's in a separate batch.
+        text_area.history.mock_time += 10.0
+        await pilot.press("3")
 
-    text_area.undo()
-    assert text_area.text == "1"
-    text_area.undo()
-    assert text_area.text == ""
+        assert text_area.text == "123"
 
+        text_area.undo()
+        assert text_area.text == "12"
 
-async def test_redo_with_no_undo_is_noop(text_area: TextArea):
-    text_area.text = SIMPLE_TEXT
-    text_area.redo()
-    assert text_area.text == SIMPLE_TEXT
+        text_area.undo()
+        assert text_area.text == ""
 
 
-async def test_undo_with_empty_undo_stack_is_noop(text_area: TextArea):
-    text_area.text = SIMPLE_TEXT
-    text_area.undo()
-    assert text_area.text == SIMPLE_TEXT
+async def test_undo_checkpoint_character_limit_reached():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        await pilot.press("1")
+        # Since the insertion below is > 100 characters it goes to a new batch.
+        text_area.insert("2" * 120)
 
+        text_area.undo()
+        assert text_area.text == "1"
+        text_area.undo()
+        assert text_area.text == ""
 
-async def test_redo_stack_cleared_on_edit(pilot: Pilot, text_area: TextArea):
-    text_area.text = ""
-    await pilot.press("1")
-    text_area.history.checkpoint()
-    await pilot.press("2")
-    text_area.history.checkpoint()
-    await pilot.press("3")
 
-    text_area.undo()
-    text_area.undo()
-    text_area.undo()
-    assert text_area.text == ""
-    assert text_area.selection == Selection.cursor((0, 0))
+async def test_redo_with_no_undo_is_noop():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        text_area.text = SIMPLE_TEXT
+        text_area.redo()
+        assert text_area.text == SIMPLE_TEXT
 
-    # Redo stack has 3 edits in it now.
-    await pilot.press("f")
-    assert text_area.text == "f"
-    assert text_area.selection == Selection.cursor((0, 1))
 
-    # Redo stack is cleared because of the edit, so redo has no effect.
-    text_area.redo()
-    assert text_area.text == "f"
-    assert text_area.selection == Selection.cursor((0, 1))
-    text_area.redo()
-    assert text_area.text == "f"
-    assert text_area.selection == Selection.cursor((0, 1))
+async def test_undo_with_empty_undo_stack_is_noop():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        text_area.text = SIMPLE_TEXT
+        text_area.undo()
+        assert text_area.text == SIMPLE_TEXT
 
 
-async def test_inserts_not_batched_with_deletes(pilot: Pilot, text_area: TextArea):
+async def test_redo_stack_cleared_on_edit():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        text_area.text = ""
+        await pilot.press("1")
+        text_area.history.checkpoint()
+        await pilot.press("2")
+        text_area.history.checkpoint()
+        await pilot.press("3")
+
+        text_area.undo()
+        text_area.undo()
+        text_area.undo()
+        assert text_area.text == ""
+        assert text_area.selection == Selection.cursor((0, 0))
+
+        # Redo stack has 3 edits in it now.
+        await pilot.press("f")
+        assert text_area.text == "f"
+        assert text_area.selection == Selection.cursor((0, 1))
+
+        # Redo stack is cleared because of the edit, so redo has no effect.
+        text_area.redo()
+        assert text_area.text == "f"
+        assert text_area.selection == Selection.cursor((0, 1))
+        text_area.redo()
+        assert text_area.text == "f"
+        assert text_area.selection == Selection.cursor((0, 1))
+
+
+async def test_inserts_not_batched_with_deletes():
     # 3 batches here: __1___  ___________2____________  __3__
-    await pilot.press(*"123", "backspace", "backspace", *"23")
-
-    assert text_area.text == "123"
-
-    # Undo batch 1: the "23" insertion.
-    text_area.undo()
-    assert text_area.text == "1"
 
-    # Undo batch 2: the double backspace.
-    text_area.undo()
-    assert text_area.text == "123"
-
-    # Undo batch 3: the "123" insertion.
-    text_area.undo()
-    assert text_area.text == ""
+    app = TextAreaApp()
 
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        await pilot.press(*"123", "backspace", "backspace", *"23")
 
-async def test_paste_is_an_isolated_batch(pilot: Pilot, text_area: TextArea):
-    pilot.app.post_message(Paste("hello "))
-    pilot.app.post_message(Paste("world"))
-    await pilot.pause()
+        assert text_area.text == "123"
 
-    assert text_area.text == "hello world"
+        # Undo batch 1: the "23" insertion.
+        text_area.undo()
+        assert text_area.text == "1"
 
-    await pilot.press("!")
+        # Undo batch 2: the double backspace.
+        text_area.undo()
+        assert text_area.text == "123"
 
-    # The insertion of "!" does not get batched with the paste of "world".
-    text_area.undo()
-    assert text_area.text == "hello world"
+        # Undo batch 3: the "123" insertion.
+        text_area.undo()
+        assert text_area.text == ""
 
-    text_area.undo()
-    assert text_area.text == "hello "
 
-    text_area.undo()
-    assert text_area.text == ""
+async def test_paste_is_an_isolated_batch():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        pilot.app.post_message(Paste("hello "))
+        pilot.app.post_message(Paste("world"))
+        await pilot.pause()
 
+        assert text_area.text == "hello world"
 
-async def test_focus_creates_checkpoint(pilot: Pilot, text_area: TextArea):
-    await pilot.press(*"123")
-    text_area.has_focus = False
-    text_area.has_focus = True
-    await pilot.press(*"456")
-    assert text_area.text == "123456"
+        await pilot.press("!")
 
-    # Since we re-focused, a checkpoint exists between 123 and 456,
-    # so when we use undo, only the 456 is removed.
-    text_area.undo()
-    assert text_area.text == "123"
+        # The insertion of "!" does not get batched with the paste of "world".
+        text_area.undo()
+        assert text_area.text == "hello world"
 
+        text_area.undo()
+        assert text_area.text == "hello "
 
-async def test_undo_redo_deletions_batched(pilot: Pilot, text_area: TextArea):
-    text_area.text = SIMPLE_TEXT
-    text_area.selection = Selection((0, 2), (1, 2))
+        text_area.undo()
+        assert text_area.text == ""
 
-    # Perform a single delete of some selected text. It'll live in it's own
-    # batch since it's a multi-line operation.
-    await pilot.press("backspace")
-    checkpoint_one = "ABHIJ\nKLMNO\nPQRST\nUVWXY\nZ\n"
-    assert text_area.text == checkpoint_one
-    assert text_area.selection == Selection.cursor((0, 2))
 
-    # Pressing backspace a few times to delete more characters.
-    await pilot.press("backspace", "backspace", "backspace")
-    checkpoint_two = "HIJ\nKLMNO\nPQRST\nUVWXY\nZ\n"
-    assert text_area.text == checkpoint_two
-    assert text_area.selection == Selection.cursor((0, 0))
+async def test_focus_creates_checkpoint():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        await pilot.press(*"123")
+        text_area.has_focus = False
+        text_area.has_focus = True
+        await pilot.press(*"456")
+        assert text_area.text == "123456"
 
-    # When we undo, the 3 deletions above should be batched, but not
-    # the original deletion since it contains a newline character.
-    text_area.undo()
-    assert text_area.text == checkpoint_one
-    assert text_area.selection == Selection.cursor((0, 2))
+        # Since we re-focused, a checkpoint exists between 123 and 456,
+        # so when we use undo, only the 456 is removed.
+        text_area.undo()
+        assert text_area.text == "123"
 
-    # Undoing again restores us back to our initial text and selection.
-    text_area.undo()
-    assert text_area.text == SIMPLE_TEXT
-    assert text_area.selection == Selection((0, 2), (1, 2))
 
-    # At this point, the undo stack contains two items, so we can redo twice.
+async def test_undo_redo_deletions_batched():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        text_area.text = SIMPLE_TEXT
+        text_area.selection = Selection((0, 2), (1, 2))
+
+        # Perform a single delete of some selected text. It'll live in it's own
+        # batch since it's a multi-line operation.
+        await pilot.press("backspace")
+        checkpoint_one = "ABHIJ\nKLMNO\nPQRST\nUVWXY\nZ\n"
+        assert text_area.text == checkpoint_one
+        assert text_area.selection == Selection.cursor((0, 2))
+
+        # Pressing backspace a few times to delete more characters.
+        await pilot.press("backspace", "backspace", "backspace")
+        checkpoint_two = "HIJ\nKLMNO\nPQRST\nUVWXY\nZ\n"
+        assert text_area.text == checkpoint_two
+        assert text_area.selection == Selection.cursor((0, 0))
+
+        # When we undo, the 3 deletions above should be batched, but not
+        # the original deletion since it contains a newline character.
+        text_area.undo()
+        assert text_area.text == checkpoint_one
+        assert text_area.selection == Selection.cursor((0, 2))
+
+        # Undoing again restores us back to our initial text and selection.
+        text_area.undo()
+        assert text_area.text == SIMPLE_TEXT
+        assert text_area.selection == Selection((0, 2), (1, 2))
+
+        # At this point, the undo stack contains two items, so we can redo twice.
+
+        # Redo to go back to checkpoint one.
+        text_area.redo()
+        assert text_area.text == checkpoint_one
+        assert text_area.selection == Selection.cursor((0, 2))
+
+        # Redo again to go back to checkpoint two
+        text_area.redo()
+        assert text_area.text == checkpoint_two
+        assert text_area.selection == Selection.cursor((0, 0))
+
+        # Redo again does nothing.
+        text_area.redo()
+        assert text_area.text == checkpoint_two
+        assert text_area.selection == Selection.cursor((0, 0))
+
+
+async def test_max_checkpoints():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        assert len(text_area.history.undo_stack) == 0
+        for index in range(MAX_CHECKPOINTS):
+            # Press enter since that will ensure a checkpoint is created.
+            await pilot.press("enter")
 
-    # Redo to go back to checkpoint one.
-    text_area.redo()
-    assert text_area.text == checkpoint_one
-    assert text_area.selection == Selection.cursor((0, 2))
+        assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS
+        await pilot.press("enter")
+        # Ensure we don't go over the limit.
+        assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS
 
-    # Redo again to go back to checkpoint two
-    text_area.redo()
-    assert text_area.text == checkpoint_two
-    assert text_area.selection == Selection.cursor((0, 0))
 
-    # Redo again does nothing.
-    text_area.redo()
-    assert text_area.text == checkpoint_two
-    assert text_area.selection == Selection.cursor((0, 0))
+async def test_redo_stack():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        assert len(text_area.history.redo_stack) == 0
+        await pilot.press("enter")
+        await pilot.press(*"123")
+        assert len(text_area.history.undo_stack) == 2
+        assert len(text_area.history.redo_stack) == 0
+        text_area.undo()
+        assert len(text_area.history.undo_stack) == 1
+        assert len(text_area.history.redo_stack) == 1
+        text_area.undo()
+        assert len(text_area.history.undo_stack) == 0
+        assert len(text_area.history.redo_stack) == 2
+        text_area.redo()
+        assert len(text_area.history.undo_stack) == 1
+        assert len(text_area.history.redo_stack) == 1
+        text_area.redo()
+        assert len(text_area.history.undo_stack) == 2
+        assert len(text_area.history.redo_stack) == 0
+
+
+async def test_backward_selection_undo_redo():
+    app = TextAreaApp()
+    async with app.run_test() as pilot:
+        text_area = app.text_area
+        # Failed prior to https://github.com/Textualize/textual/pull/4352
+        text_area.text = SIMPLE_TEXT
+        text_area.selection = Selection((3, 2), (0, 0))
 
+        await pilot.press("a")
 
-async def test_max_checkpoints(pilot: Pilot, text_area: TextArea):
-    assert len(text_area.history.undo_stack) == 0
-    for index in range(MAX_CHECKPOINTS):
-        # Press enter since that will ensure a checkpoint is created.
-        await pilot.press("enter")
+        text_area.undo()
+        await pilot.press("down", "down", "down", "down")
 
-    assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS
-    await pilot.press("enter")
-    # Ensure we don't go over the limit.
-    assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS
-
-
-async def test_redo_stack(pilot: Pilot, text_area: TextArea):
-    assert len(text_area.history.redo_stack) == 0
-    await pilot.press("enter")
-    await pilot.press(*"123")
-    assert len(text_area.history.undo_stack) == 2
-    assert len(text_area.history.redo_stack) == 0
-    text_area.undo()
-    assert len(text_area.history.undo_stack) == 1
-    assert len(text_area.history.redo_stack) == 1
-    text_area.undo()
-    assert len(text_area.history.undo_stack) == 0
-    assert len(text_area.history.redo_stack) == 2
-    text_area.redo()
-    assert len(text_area.history.undo_stack) == 1
-    assert len(text_area.history.redo_stack) == 1
-    text_area.redo()
-    assert len(text_area.history.undo_stack) == 2
-    assert len(text_area.history.redo_stack) == 0
-
-
-async def test_backward_selection_undo_redo(pilot: Pilot, text_area: TextArea):
-    # Failed prior to https://github.com/Textualize/textual/pull/4352
-    text_area.text = SIMPLE_TEXT
-    text_area.selection = Selection((3, 2), (0, 0))
-
-    await pilot.press("a")
-
-    text_area.undo()
-    await pilot.press("down", "down", "down", "down")
-
-    assert text_area.text == SIMPLE_TEXT
+        assert text_area.text == SIMPLE_TEXT


Code review comments:
diff_hunk: @@ -50,4 +50,7 @@ async def on_mount(self) -> None:
         "MyScreen#main",
     ]
 
+    print(unmount_ids)
+    print(expected)
path: tests/test_unmount.py
text: @user1:
```suggestion
```