From 53c7ac8a0c2aca9ab8c71e10703ac314a9ebb0fa Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Jul 2024 11:42:08 +0100 Subject: [PATCH 01/80] merge main --- src/textual/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 6e7c5bc556..630b0bb562 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -26,12 +26,14 @@ ) from datetime import datetime from functools import partial +from pathlib import PurePath from time import perf_counter from typing import ( TYPE_CHECKING, Any, AsyncGenerator, Awaitable, + BinaryIO, Callable, ClassVar, Generator, @@ -39,6 +41,7 @@ Iterable, Iterator, Sequence, + TextIO, Type, TypeVar, overload, @@ -3689,3 +3692,8 @@ def open_url(self, url: str, *, new_tab: bool = True) -> None: """ if self._driver is not None: self._driver.open_url(url, new_tab) + + def save_file(self, path_or_file: str | PurePath | TextIO | BinaryIO) -> None: + """Save a file.""" + if self._driver is not None: + self._driver.save_file(path_or_file) From e8afe52611fe4f86ac2334e94f7131b204f4ff45 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Jul 2024 13:25:22 +0100 Subject: [PATCH 02/80] Beginning save_file implementation --- src/textual/app.py | 38 ++++++++++++++++++++++++++++++++------ src/textual/css/types.py | 4 +++- src/textual/driver.py | 31 ++++++++++++++++++------------- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 630b0bb562..58c0bf56c0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -26,14 +26,13 @@ ) from datetime import datetime from functools import partial -from pathlib import PurePath +from pathlib import Path, PurePath from time import perf_counter from typing import ( TYPE_CHECKING, Any, AsyncGenerator, Awaitable, - BinaryIO, Callable, ClassVar, Generator, @@ -41,7 +40,6 @@ Iterable, Iterator, Sequence, - TextIO, Type, TypeVar, overload, @@ -56,6 +54,8 @@ from rich.segment import Segment, Segments from rich.terminal_theme import TerminalTheme +from textual.css.types import SaveFileType + from . import ( Logger, LogGroup, @@ -3693,7 +3693,33 @@ def open_url(self, url: str, *, new_tab: bool = True) -> None: if self._driver is not None: self._driver.open_url(url, new_tab) - def save_file(self, path_or_file: str | PurePath | TextIO | BinaryIO) -> None: - """Save a file.""" + def save_file( + self, + path_or_file: SaveFileType, + *, + save_path: str | PurePath | None = None, + ) -> None: + """Save the file `path_or_file` to `save_path`. + + If running via web through Textual Web or Textual Serve, + this will initiate a download in the web browser. + + Args: + path_or_file: The path or file to save. + save_path: The location to save the file to. If None, + the default "downloads" directory will be used. This + argument is ignored when running via the web. + """ + # Ensure `path_or_file` is a file-like object - convert if needed. + if isinstance(path_or_file, str): + path_or_file = Path(path_or_file) + file_like = path_or_file.open("rb") + else: + file_like = path_or_file + + # Find the appropriate save location. + if save_path is None: + save_path = Path("~/Downloads").expanduser() + if self._driver is not None: - self._driver.save_file(path_or_file) + self._driver.save_file(file_like, save_path) diff --git a/src/textual/css/types.py b/src/textual/css/types.py index ce4cebdd0b..1049ff737f 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import Tuple +from pathlib import PurePath +from typing import BinaryIO, TextIO, Tuple, Union from typing_extensions import Literal from ..color import Color +SaveFileType = Union[str, PurePath, TextIO, BinaryIO] Edge = Literal["top", "right", "bottom", "left"] DockEdge = Literal["top", "right", "bottom", "left", ""] EdgeType = Literal[ diff --git a/src/textual/driver.py b/src/textual/driver.py index 16d519cac4..2eca3f44d6 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -3,6 +3,7 @@ import asyncio from abc import ABC, abstractmethod from contextlib import contextmanager +from pathlib import PurePath from typing import TYPE_CHECKING, Any, Iterator from . import events @@ -146,19 +147,6 @@ def disable_input(self) -> None: def stop_application_mode(self) -> None: """Stop application mode, restore state.""" - def open_url(self, url: str, new_tab: bool = True) -> None: - """Open a URL in the default web browser. - - Args: - url: The URL to open. - new_tab: Whether to open the URL in a new tab. - This is only relevant when running via the WebDriver, - and is ignored when called while running through the terminal. - """ - import webbrowser - - webbrowser.open(url) - def suspend_application_mode(self) -> None: """Suspend application mode. @@ -196,3 +184,20 @@ def no_automatic_restart(self) -> Iterator[None]: def close(self) -> None: """Perform any final cleanup.""" + + def open_url(self, url: str, new_tab: bool = True) -> None: + """Open a URL in the default web browser. + + Args: + url: The URL to open. + new_tab: Whether to open the URL in a new tab. + This is only relevant when running via the WebDriver, + and is ignored when called while running through the terminal. + """ + import webbrowser + + webbrowser.open(url) + + def save_file(self, path_or_file: str | PurePath | TextIO | BinaryIO) -> None: + """Save a file.""" + pass From 212b31316620f851bf665a139948cb5e2e487b64 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Jul 2024 13:29:42 +0100 Subject: [PATCH 03/80] Add platformdirs and use it to get the download dir if its not specified --- poetry.lock | 14 ++++++++++++-- pyproject.toml | 1 + src/textual/app.py | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index fa62ea1f85..608ea9f1a6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohttp" @@ -1635,6 +1635,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1642,8 +1643,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1660,6 +1668,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1667,6 +1676,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2356,4 +2366,4 @@ syntax = ["tree-sitter", "tree-sitter-languages"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "a632a2480a0262dcdbf49a456c2528dd6193a14cd00f203887dabab10c55c450" +content-hash = "a334bde26213e1cae0a4be69857cbbc17529058b67db2201d8bf1ca2e65dd855" diff --git a/pyproject.toml b/pyproject.toml index 0cfd6fd9bd..8481d3169a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ rich = ">=13.3.3" typing-extensions = "^4.4.0" tree-sitter = { version = "^0.20.1", optional = true } tree-sitter-languages = { version = "1.10.2", optional = true } +platformdirs = "^4.2.2" [tool.poetry.extras] syntax = ["tree-sitter", "tree_sitter_languages"] diff --git a/src/textual/app.py b/src/textual/app.py index 58c0bf56c0..f22a2a0a84 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -48,6 +48,7 @@ import rich import rich.repr +from platformdirs import user_downloads_path from rich.console import Console, RenderableType from rich.control import Control from rich.protocol import is_renderable @@ -3719,7 +3720,7 @@ def save_file( # Find the appropriate save location. if save_path is None: - save_path = Path("~/Downloads").expanduser() + save_path = user_downloads_path() if self._driver is not None: self._driver.save_file(file_like, save_path) From 6d1b53f7a194a05caceabffc3d8ab31b5a7f5030 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Jul 2024 14:08:19 +0100 Subject: [PATCH 04/80] Commenting --- src/textual/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index f22a2a0a84..0b4d24e4e8 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3718,9 +3718,12 @@ def save_file( else: file_like = path_or_file - # Find the appropriate save location. + # Find the appropriate save location if not specified. if save_path is None: save_path = user_downloads_path() + # Save the file. The driver will determine the appropriate action + # to take here. It could mean simply writing to the save_path, or + # sending the file to the web browser for download. if self._driver is not None: self._driver.save_file(file_like, save_path) From 1f439a841ebeb72a8632df8f6ca4c033c5005d05 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Jul 2024 16:48:41 +0100 Subject: [PATCH 05/80] Saving text and saving binary inside the App --- src/textual/app.py | 85 +++++++++++++++++++++++++++++++++------- src/textual/css/types.py | 4 +- src/textual/driver.py | 56 +++++++++++++++++++++++--- 3 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 0b4d24e4e8..c15f8855a0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -26,13 +26,14 @@ ) from datetime import datetime from functools import partial -from pathlib import Path, PurePath +from pathlib import Path from time import perf_counter from typing import ( TYPE_CHECKING, Any, AsyncGenerator, Awaitable, + BinaryIO, Callable, ClassVar, Generator, @@ -40,6 +41,7 @@ Iterable, Iterator, Sequence, + TextIO, Type, TypeVar, overload, @@ -55,8 +57,6 @@ from rich.segment import Segment, Segments from rich.terminal_theme import TerminalTheme -from textual.css.types import SaveFileType - from . import ( Logger, LogGroup, @@ -3694,11 +3694,56 @@ def open_url(self, url: str, *, new_tab: bool = True) -> None: if self._driver is not None: self._driver.open_url(url, new_tab) - def save_file( + def save_text( + self, + path_or_file: str | Path | TextIO, + *, + save_path: str | Path | None = None, + encoding: str | None = None, + ) -> None: + """Save the text file `path_or_file` to `save_path`. + + Args: + path_or_file: The path or file-like object to save. + save_path: The location to save the file to. If None, + the default "downloads" directory will be used. This + argument is ignored when running via the web. + encoding: The encoding to use when saving the file. If `None`, + the encoding will be determined by supplied file-like object + (if possible). If this is not possible, the encoding of the + current locale will be used. + """ + if self._driver is None: + return + + # Find the appropriate save location if not specified. + if save_path is None: + save_path = user_downloads_path() + elif isinstance(save_path, str): + save_path = Path(save_path) + + # Get the TextIO file-like object. + if isinstance(path_or_file, (str, Path)): + requires_close = True + text_file = Path(path_or_file).open("r", encoding=encoding) + else: + requires_close = False + text_file = path_or_file + + # Let the driver decide how to handle saving the file. + self._driver.save_text(text_file, save_path=save_path, encoding=encoding) + + # Close the file if we were the ones who opened it. + # If the user opened the file, they won't expect us to close it, + # so leave it to them. + if requires_close: + text_file.close() + + def save_binary( self, - path_or_file: SaveFileType, + path_or_file: str | Path | BinaryIO, *, - save_path: str | PurePath | None = None, + save_path: str | Path | None = None, ) -> None: """Save the file `path_or_file` to `save_path`. @@ -3706,24 +3751,34 @@ def save_file( this will initiate a download in the web browser. Args: - path_or_file: The path or file to save. + path_or_file: The path or file-like object to save. save_path: The location to save the file to. If None, the default "downloads" directory will be used. This argument is ignored when running via the web. """ - # Ensure `path_or_file` is a file-like object - convert if needed. - if isinstance(path_or_file, str): - path_or_file = Path(path_or_file) - file_like = path_or_file.open("rb") - else: - file_like = path_or_file + if self._driver is None: + return # Find the appropriate save location if not specified. if save_path is None: save_path = user_downloads_path() + elif isinstance(save_path, str): + save_path = Path(save_path) + + # Ensure `path_or_file` is a file-like object - convert if needed. + if isinstance(path_or_file, (str, Path)): + requires_close = True + binary_path = Path(path_or_file) + binary = binary_path.open("rb") + else: + requires_close = False + binary = path_or_file # Save the file. The driver will determine the appropriate action # to take here. It could mean simply writing to the save_path, or # sending the file to the web browser for download. - if self._driver is not None: - self._driver.save_file(file_like, save_path) + self._driver.save_binary(binary, save_path=save_path) + + # Close the file if we were the ones who opened it. + if requires_close: + binary.close() diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 1049ff737f..ce4cebdd0b 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -1,13 +1,11 @@ from __future__ import annotations -from pathlib import PurePath -from typing import BinaryIO, TextIO, Tuple, Union +from typing import Tuple from typing_extensions import Literal from ..color import Color -SaveFileType = Union[str, PurePath, TextIO, BinaryIO] Edge = Literal["top", "right", "bottom", "left"] DockEdge = Literal["top", "right", "bottom", "left", ""] EdgeType = Literal[ diff --git a/src/textual/driver.py b/src/textual/driver.py index 2eca3f44d6..a79a50bf56 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -1,10 +1,12 @@ from __future__ import annotations import asyncio +import shutil from abc import ABC, abstractmethod from contextlib import contextmanager -from pathlib import PurePath -from typing import TYPE_CHECKING, Any, Iterator +from io import TextIOBase, TextIOWrapper +from pathlib import Path +from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, TextIO from . import events from .events import MouseUp @@ -198,6 +200,50 @@ def open_url(self, url: str, new_tab: bool = True) -> None: webbrowser.open(url) - def save_file(self, path_or_file: str | PurePath | TextIO | BinaryIO) -> None: - """Save a file.""" - pass + def save_text( + self, + text: TextIO, + *, + save_path: Path, + encoding: str | None = None, + ) -> None: + """Save the text file `path_or_file` to `save_path`. + + If running via web through Textual Web or Textual Serve, + this will initiate a download in the web browser. + + Args: + path_or_file: The path or file-like object to save. + save_path: The location to save the file to. + encoding: The textencoding to use when saving the file. If `None`, + the encoding will be determined by supplied file-like object + (if possible), or default to the encoding of the current locale. + """ + + def save_binary( + self, + binary: BinaryIO, + save_path: Path, + ) -> None: + """Save the file `path_or_file` to `save_path`. + + If running via web through Textual Web or Textual Serve, + this will initiate a download in the web browser. + + Args: + file_like: The file to save. + save_path: The location to save the file to. If None, + the default "downloads" directory will be used. This + argument is ignored when running via the web. + """ + with open(save_path, "wb") as destination_file: + file_like.seek(0) + if isinstance(file_like, BinaryIO): + # Copy the file object to the destination file. + shutil.copyfileobj(file_like, destination_file) + elif isinstance(file_like, TextIOBase): + text_encoding = getattr(file_like, "encoding", "utf-8") + reader = TextIOWrapper(file_like.buffer, encoding=text_encoding) + shutil.copyfileobj(reader.detach(), destination_file) + else: + raise ValueError(f"Unsupported file type: {type(file_like)}") From 1806bfe794e556ab52147a3bc1162f5e30053bd2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 31 Jul 2024 16:55:13 +0100 Subject: [PATCH 06/80] Initial implementation of save_text in driver --- src/textual/driver.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/textual/driver.py b/src/textual/driver.py index a79a50bf56..df7d600052 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -4,7 +4,6 @@ import shutil from abc import ABC, abstractmethod from contextlib import contextmanager -from io import TextIOBase, TextIOWrapper from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, TextIO @@ -215,10 +214,11 @@ def save_text( Args: path_or_file: The path or file-like object to save. save_path: The location to save the file to. - encoding: The textencoding to use when saving the file. If `None`, - the encoding will be determined by supplied file-like object - (if possible), or default to the encoding of the current locale. + encoding: The text encoding to use when saving the file. + This will be passed to Python's `open()` built-in function. """ + with open(save_path, "w", encoding=encoding) as destination_file: + shutil.copyfileobj(text, destination_file) def save_binary( self, @@ -236,14 +236,3 @@ def save_binary( the default "downloads" directory will be used. This argument is ignored when running via the web. """ - with open(save_path, "wb") as destination_file: - file_like.seek(0) - if isinstance(file_like, BinaryIO): - # Copy the file object to the destination file. - shutil.copyfileobj(file_like, destination_file) - elif isinstance(file_like, TextIOBase): - text_encoding = getattr(file_like, "encoding", "utf-8") - reader = TextIOWrapper(file_like.buffer, encoding=text_encoding) - shutil.copyfileobj(reader.detach(), destination_file) - else: - raise ValueError(f"Unsupported file type: {type(file_like)}") From 1f528a7867f446ed2f52621a839771786fac82c0 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 12:58:03 +0100 Subject: [PATCH 07/80] Saving binary file in non-web driver --- src/textual/app.py | 2 +- src/textual/driver.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index c15f8855a0..4e275c2a2a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3779,6 +3779,6 @@ def save_binary( # sending the file to the web browser for download. self._driver.save_binary(binary, save_path=save_path) - # Close the file if we were the ones who opened it. + # Close the file if we opened it inside this method. if requires_close: binary.close() diff --git a/src/textual/driver.py b/src/textual/driver.py index df7d600052..7e19b912dd 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -236,3 +236,5 @@ def save_binary( the default "downloads" directory will be used. This argument is ignored when running via the web. """ + with open(save_path, "wb") as destination_file: + shutil.copyfileobj(binary, destination_file) From df62ca4f0c5a1b6089ee80f93964a484095ab11a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 17:04:59 +0100 Subject: [PATCH 08/80] Choosing filenames and generating if necessary for save_text and save_binary in App. Extract and re-use filename generation logic from the SVG screenshot saving code. --- src/textual/_files.py | 26 +++++++++++++ src/textual/app.py | 63 +++++++++++++++++-------------- src/textual/driver.py | 6 +-- src/textual/drivers/web_driver.py | 20 +++++++++- 4 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 src/textual/_files.py diff --git a/src/textual/_files.py b/src/textual/_files.py new file mode 100644 index 0000000000..5433f6cf11 --- /dev/null +++ b/src/textual/_files.py @@ -0,0 +1,26 @@ +from datetime import datetime + + +def generate_datetime_filename( + prefix: str, suffix: str, datetime_format: str | None = None +) -> str: + """Generate a filename which includes the current date and time. + + Useful for ensuring a degree of uniqueness when saving files. + + Args: + prefix: Prefix to attach to the start of the filename, before the timestamp string. + suffix: Suffix to attach to the end of the filename, after the timestamp string. + This should include the file extension. + datetime_format: The format of the datetime to include in the filename. + If None, the ISO format will be used. + """ + if datetime_format is None: + dt = datetime.now().isoformat() + else: + dt = datetime.now().strftime(datetime_format) + + file_name_stem = f"{prefix} {dt}" + for reserved in ' <>:"/\\|?*.': + file_name_stem = file_name_stem.replace(reserved, "_") + return file_name_stem + suffix diff --git a/src/textual/app.py b/src/textual/app.py index 4e275c2a2a..b50d78a9e7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -24,7 +24,6 @@ redirect_stderr, redirect_stdout, ) -from datetime import datetime from functools import partial from pathlib import Path from time import perf_counter @@ -78,6 +77,7 @@ from ._context import message_hook as message_hook_context_var from ._dispatch_key import dispatch_key from ._event_broker import NoHandler, extract_handler_actions +from ._files import generate_datetime_filename from ._path import CSSPathType, _css_path_type_as_list, _make_path_object_relative from ._types import AnimationLevel from ._wait import wait_for_idle @@ -1271,14 +1271,7 @@ def save_screenshot( """ path = path or "./" if not filename: - if time_format is None: - dt = datetime.now().isoformat() - else: - dt = datetime.now().strftime(time_format) - svg_filename_stem = f"{self.title.lower()} {dt}" - for reserved in ' <>:"/\\|?*.': - svg_filename_stem = svg_filename_stem.replace(reserved, "_") - svg_filename = svg_filename_stem + ".svg" + svg_filename = generate_datetime_filename(self.title, ".svg", time_format) else: svg_filename = filename svg_path = os.path.expanduser(os.path.join(path, svg_filename)) @@ -3698,16 +3691,17 @@ def save_text( self, path_or_file: str | Path | TextIO, *, - save_path: str | Path | None = None, + save_location: str | Path | None = None, encoding: str | None = None, ) -> None: """Save the text file `path_or_file` to `save_path`. Args: path_or_file: The path or file-like object to save. - save_path: The location to save the file to. If None, - the default "downloads" directory will be used. This - argument is ignored when running via the web. + save_location: The directory to save the file to. If path_or_file + is a file-like object, the filename will be generated from + the `name` attribute if available. If path_or_file is a path + the filename will be generated from the path. encoding: The encoding to use when saving the file. If `None`, the encoding will be determined by supplied file-like object (if possible). If this is not possible, the encoding of the @@ -3716,22 +3710,33 @@ def save_text( if self._driver is None: return - # Find the appropriate save location if not specified. - if save_path is None: - save_path = user_downloads_path() - elif isinstance(save_path, str): - save_path = Path(save_path) - # Get the TextIO file-like object. if isinstance(path_or_file, (str, Path)): requires_close = True - text_file = Path(path_or_file).open("r", encoding=encoding) + path = Path(path_or_file) + text_file = path.open("r", encoding=encoding) + file_name = path.name else: requires_close = False text_file = path_or_file + # Get the encoding and file_name from the file-like object if required. + encoding = encoding or getattr(text_file, "encoding", None) + file_name = getattr(text_file, "name", None) + # Some file-like objects don't have a name attribute, so generate a filename. + if not file_name: + file_name = generate_datetime_filename(self.title, "") + + # Find the full path to write the file to. + save_directory = ( + user_downloads_path() if save_location is None else Path(save_location) + ) # Let the driver decide how to handle saving the file. - self._driver.save_text(text_file, save_path=save_path, encoding=encoding) + self._driver.save_text( + text_file, + save_path=save_directory / file_name, + encoding=encoding, + ) # Close the file if we were the ones who opened it. # If the user opened the file, they won't expect us to close it, @@ -3759,25 +3764,27 @@ def save_binary( if self._driver is None: return - # Find the appropriate save location if not specified. - if save_path is None: - save_path = user_downloads_path() - elif isinstance(save_path, str): - save_path = Path(save_path) - # Ensure `path_or_file` is a file-like object - convert if needed. if isinstance(path_or_file, (str, Path)): requires_close = True binary_path = Path(path_or_file) binary = binary_path.open("rb") + file_name = binary_path.name else: requires_close = False binary = path_or_file + file_name = getattr(binary, "name", None) + # Generate a filename if the file-like object doesn't have one. + if not file_name: + file_name = generate_datetime_filename(self.title, "") + + # Find the appropriate save location if not specified. + save_directory = user_downloads_path() if save_path is None else Path(save_path) # Save the file. The driver will determine the appropriate action # to take here. It could mean simply writing to the save_path, or # sending the file to the web browser for download. - self._driver.save_binary(binary, save_path=save_path) + self._driver.save_binary(binary, save_path=save_directory / file_name) # Close the file if we opened it inside this method. if requires_close: diff --git a/src/textual/driver.py b/src/textual/driver.py index 7e19b912dd..73c9bb57c7 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -213,7 +213,7 @@ def save_text( Args: path_or_file: The path or file-like object to save. - save_path: The location to save the file to. + save_path: The full path to save the file to. encoding: The text encoding to use when saving the file. This will be passed to Python's `open()` built-in function. """ @@ -233,8 +233,8 @@ def save_binary( Args: file_like: The file to save. save_path: The location to save the file to. If None, - the default "downloads" directory will be used. This - argument is ignored when running via the web. + the default "downloads" directory will be used. When + running via web, only the file name """ with open(save_path, "wb") as destination_file: shutil.copyfileobj(binary, destination_file) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 18898b82a8..33775e9312 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -18,8 +18,9 @@ import sys from codecs import getincrementaldecoder from functools import partial +from pathlib import Path from threading import Event, Thread -from typing import Any +from typing import Any, BinaryIO from .. import events, log, messages from .._xterm_parser import XTermParser @@ -232,3 +233,20 @@ def open_url(self, url: str, new_tab: bool = True) -> None: new_tab: Whether to open the URL in a new tab. """ self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab}) + + def save_binary( + self, + binary: BinaryIO, + save_path: Path, + ) -> None: + """Save the file `path_or_file` to `save_path`. + + If running via web through Textual Web or Textual Serve, + this will initiate a download in the web browser. + + Args: + file_like: The file to save. + save_path: The location to save the file to. If None, + the default "downloads" directory will be used. This + argument is ignored when running via the web. + """ From 43eb939111f374d6988a104eab5c2a8427216ce5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 5 Aug 2024 17:28:41 +0100 Subject: [PATCH 09/80] More progress on saving a file --- src/textual/drivers/web_driver.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 33775e9312..ba5ee3dbfc 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -20,7 +20,7 @@ from functools import partial from pathlib import Path from threading import Event, Thread -from typing import Any, BinaryIO +from typing import Any, BinaryIO, TextIO from .. import events, log, messages from .._xterm_parser import XTermParser @@ -234,6 +234,27 @@ def open_url(self, url: str, new_tab: bool = True) -> None: """ self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab}) + def save_text( + self, + text: TextIO, + *, + save_path: Path, + encoding: str | None = None, + ) -> None: + """Save the text file to the specified path. + + Args: + text: The text file to save. + save_path: The location to save the file to. + encoding: The encoding of the text file. + open_method: *web only* + """ + + # Inform the parent process that we're saving a text file. + self.write_meta( + {"type": "save_file", "path": str(save_path), "encoding": encoding} + ) + def save_binary( self, binary: BinaryIO, @@ -246,7 +267,7 @@ def save_binary( Args: file_like: The file to save. - save_path: The location to save the file to. If None, - the default "downloads" directory will be used. This - argument is ignored when running via the web. + save_path: The location to save the file to. Only the file name is used, + since we're running in a web environment and cannot enforce a download location + on the user. The filename will be used to set the `Content-Disposition` header. """ From 92d7330787c2debffb060268f8b4a03155d1cf74 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 6 Aug 2024 09:59:11 +0100 Subject: [PATCH 10/80] More work on deliver file API --- src/textual/app.py | 30 +++++++++++---- src/textual/driver.py | 17 +++++++-- src/textual/drivers/web_driver.py | 61 ++++++++++++++++++++++++------- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b50d78a9e7..020ed98cbd 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -66,6 +66,7 @@ log, messages, on, + work, ) from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START @@ -3687,14 +3688,22 @@ def open_url(self, url: str, *, new_tab: bool = True) -> None: if self._driver is not None: self._driver.open_url(url, new_tab) - def save_text( + @work(thread=True) + def deliver_text( self, path_or_file: str | Path | TextIO, *, save_location: str | Path | None = None, encoding: str | None = None, ) -> None: - """Save the text file `path_or_file` to `save_path`. + """Deliver a text file to the end-user of the application. + + If running in a terminal, this will save the file to the user's + downloads directory. + + If running via a web browser, this will initiate a download. + + This is a blocking operation. Args: path_or_file: The path or file-like object to save. @@ -3732,7 +3741,7 @@ def save_text( ) # Let the driver decide how to handle saving the file. - self._driver.save_text( + self._driver.deliver_text( text_file, save_path=save_directory / file_name, encoding=encoding, @@ -3744,16 +3753,21 @@ def save_text( if requires_close: text_file.close() - def save_binary( + @work(thread=True) + def deliver_binary( self, path_or_file: str | Path | BinaryIO, *, save_path: str | Path | None = None, ) -> None: - """Save the file `path_or_file` to `save_path`. + """Deliver a binary file to the end-user of the application. + + If running in a terminal, this will save the file to the user's + downloads directory. + + If running via a web browser, this will initiate a download. - If running via web through Textual Web or Textual Serve, - this will initiate a download in the web browser. + This is a blocking operation. Args: path_or_file: The path or file-like object to save. @@ -3784,7 +3798,7 @@ def save_binary( # Save the file. The driver will determine the appropriate action # to take here. It could mean simply writing to the save_path, or # sending the file to the web browser for download. - self._driver.save_binary(binary, save_path=save_directory / file_name) + self._driver.deliver_binary(binary, save_path=save_directory / file_name) # Close the file if we opened it inside this method. if requires_close: diff --git a/src/textual/driver.py b/src/textual/driver.py index 73c9bb57c7..1f0e1146b3 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, TextIO +from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal, TextIO from . import events from .events import MouseUp @@ -199,11 +199,12 @@ def open_url(self, url: str, new_tab: bool = True) -> None: webbrowser.open(url) - def save_text( + def deliver_text( self, text: TextIO, *, save_path: Path, + open_method: Literal["browser", "download"] = "download", encoding: str | None = None, ) -> None: """Save the text file `path_or_file` to `save_path`. @@ -214,16 +215,21 @@ def save_text( Args: path_or_file: The path or file-like object to save. save_path: The full path to save the file to. + open_method: *web only* Whether to open the file in the browser or to + prompt the user to download it. When running via a standard (non-web) + terminal, this is ignored. encoding: The text encoding to use when saving the file. This will be passed to Python's `open()` built-in function. """ with open(save_path, "w", encoding=encoding) as destination_file: shutil.copyfileobj(text, destination_file) - def save_binary( + def deliver_binary( self, binary: BinaryIO, + *, save_path: Path, + open_method: Literal["browser", "download"] = "download", ) -> None: """Save the file `path_or_file` to `save_path`. @@ -234,7 +240,10 @@ def save_binary( file_like: The file to save. save_path: The location to save the file to. If None, the default "downloads" directory will be used. When - running via web, only the file name + running via web, only the file name will be used. + open_method: *web only* Whether to open the file in the browser or + to prompt the user to download it. When running via a standard + (non-web) terminal, this is ignored. """ with open(save_path, "wb") as destination_file: shutil.copyfileobj(binary, destination_file) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index ba5ee3dbfc..c9b823f87e 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -20,7 +20,7 @@ from functools import partial from pathlib import Path from threading import Event, Thread -from typing import Any, BinaryIO, TextIO +from typing import Any, BinaryIO, Literal, TextIO from .. import events, log, messages from .._xterm_parser import XTermParser @@ -234,40 +234,75 @@ def open_url(self, url: str, new_tab: bool = True) -> None: """ self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab}) - def save_text( + def deliver_text( self, text: TextIO, *, save_path: Path, + open_method: Literal["browser", "download"] = "download", encoding: str | None = None, ) -> None: - """Save the text file to the specified path. + """Deliver a text file to the end-user of the application. + + If running in a terminal, this will save the file to the user's + downloads directory. + + If running via web through Textual Web or Textual Serve, + this will initiate a download in the web browser. Args: text: The text file to save. save_path: The location to save the file to. + open_method: *web only* Choose whether the file should + be opened in the browser or whether the user should + be prompted to download the file. encoding: The encoding of the text file. - open_method: *web only* """ + self._deliver_file(text.buffer, save_path, open_method) - # Inform the parent process that we're saving a text file. - self.write_meta( - {"type": "save_file", "path": str(save_path), "encoding": encoding} - ) - - def save_binary( + def deliver_binary( self, binary: BinaryIO, + *, save_path: Path, + open_method: Literal["browser", "download"] = "download", ) -> None: - """Save the file `path_or_file` to `save_path`. + """Deliver a binary file to the end-user of the application. - If running via web through Textual Web or Textual Serve, - this will initiate a download in the web browser. + If running in a terminal, this will save the file to the user's + downloads directory. + + If running via a web browser, this will initiate a download. + + This is a blocking operation. Args: file_like: The file to save. save_path: The location to save the file to. Only the file name is used, since we're running in a web environment and cannot enforce a download location on the user. The filename will be used to set the `Content-Disposition` header. + open_method: Whether to open the file in the browser or to prompt the user to download it. """ + self._deliver_file(binary, save_path, open_method) + + def _deliver_file( + self, + binary: BinaryIO, + path: Path, + open_method: Literal["browser", "download"] = "download", + ) -> None: + """Deliver a binary file to the end-user of the application.""" + binary.seek(0) + self.write_meta( + { + "type": "deliver_file_start", + "path": str(path.resolve()), + "open_method": open_method, + } + ) + + self.write_meta( + { + "type": "deliver_file_end", + } + ) From 0e95c9a67ebd800c320a4af59ece179386291734 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 6 Aug 2024 10:15:12 +0100 Subject: [PATCH 11/80] Add msgpack --- poetry.lock | 14 ++------------ pyproject.toml | 1 + 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/poetry.lock b/poetry.lock index 608ea9f1a6..1b9c92bc68 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# 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.7.1 and should not be changed by hand. [[package]] name = "aiohttp" @@ -1635,7 +1635,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1643,15 +1642,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1668,7 +1660,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1676,7 +1667,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2366,4 +2356,4 @@ syntax = ["tree-sitter", "tree-sitter-languages"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "a334bde26213e1cae0a4be69857cbbc17529058b67db2201d8bf1ca2e65dd855" +content-hash = "a3d7683d8f960d6a129715f1707e292b6d80a700d85a810cfe5d150518ec7e05" diff --git a/pyproject.toml b/pyproject.toml index d8d1a51264..6d4410092a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ typing-extensions = "^4.4.0" tree-sitter = { version = "^0.20.1", optional = true } tree-sitter-languages = { version = "1.10.2", optional = true } platformdirs = "^4.2.2" +msgpack = "^1.0.8" [tool.poetry.extras] syntax = ["tree-sitter", "tree_sitter_languages"] From ba0c7260a707246715ea417d68740f7444223af6 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 6 Aug 2024 12:40:12 +0100 Subject: [PATCH 12/80] Handling requests for chunks from the server --- poetry.lock | 13 ++++++- pyproject.toml | 1 + src/textual/drivers/web_driver.py | 65 ++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1b9c92bc68..a6cd1d1b87 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1218,6 +1218,17 @@ files = [ {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, ] +[[package]] +name = "msgpack-types" +version = "0.3.0" +description = "Type stubs for msgpack" +optional = false +python-versions = "<4.0,>=3.7" +files = [ + {file = "msgpack_types-0.3.0-py3-none-any.whl", hash = "sha256:44933978973cf1f09a97fc830676b10ba848cc103d73628af5c57f14ec4d6e92"}, + {file = "msgpack_types-0.3.0.tar.gz", hash = "sha256:e14faa08ab56ce529738f04d585e601c90f415d11d84f8089883d00d731d1ebf"}, +] + [[package]] name = "multidict" version = "6.0.5" @@ -2356,4 +2367,4 @@ syntax = ["tree-sitter", "tree-sitter-languages"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "a3d7683d8f960d6a129715f1707e292b6d80a700d85a810cfe5d150518ec7e05" +content-hash = "6c6e84b0f622a308223bfc9fecd294a36a5f63a728c770380bb987a8971d14f1" diff --git a/pyproject.toml b/pyproject.toml index 6d4410092a..910555ed08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ tree-sitter = { version = "^0.20.1", optional = true } tree-sitter-languages = { version = "1.10.2", optional = true } platformdirs = "^4.2.2" msgpack = "^1.0.8" +msgpack-types = "^0.3.0" [tool.poetry.extras] syntax = ["tree-sitter", "tree_sitter_languages"] diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index c9b823f87e..3c8a73f9b1 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -16,11 +16,14 @@ import os import signal import sys +import uuid from codecs import getincrementaldecoder from functools import partial from pathlib import Path -from threading import Event, Thread -from typing import Any, BinaryIO, Literal, TextIO +from threading import Event, Lock, Thread +from typing import Any, BinaryIO, Literal, TextIO, cast + +import msgpack from .. import events, log, messages from .._xterm_parser import XTermParser @@ -64,6 +67,11 @@ def __init__( self._key_thread: Thread = Thread(target=self.run_input_thread) self._input_reader = InputReader() + self._deliveries_lock = Lock() + self._deliveries: dict[str, BinaryIO] = {} + """Maps delivery keys to file-like objects, used + for delivering files to the browser.""" + def write(self, data: str) -> None: """Write string data to the output device, which may be piped to the controlling process (i.e. textual-web). @@ -85,6 +93,15 @@ def write_meta(self, data: dict[str, object]) -> None: meta_bytes = json.dumps(data).encode("utf-8", errors="ignore") self._write(b"M%s%s" % (len(meta_bytes).to_bytes(4, "big"), meta_bytes)) + def write_packed(self, data: tuple[str | bytes, ...]) -> None: + """Pack a msgpack compatible data-structure and write to stdout. + + Args: + data: The dictionary to pack and write. + """ + packed_bytes = msgpack.packb(data) + self._write(b"P%s" % packed_bytes) + def flush(self) -> None: pass @@ -214,8 +231,8 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: """ if packet_type == "resize": self._size = (payload["width"], payload["height"]) - size = Size(*self._size) - self._app.post_message(events.Resize(size, size)) + requested_size = Size(*self._size) + self._app.post_message(events.Resize(requested_size, requested_size)) elif packet_type == "focus": self._app.post_message(events.AppFocus()) elif packet_type == "blur": @@ -224,6 +241,34 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: self._app.post_message(messages.ExitApp()) elif packet_type == "exit": raise _ExitInput() + elif packet_type == "deliver_chunk_request": + # A request from the server to deliver another chunk of a file + try: + delivery_key = cast(str, payload["key"]) + requested_size = cast(int, payload["size"]) + except KeyError: + log.error("Protocol error: deliver_chunk_request missing key or size") + return + + try: + with self._deliveries_lock: + binary_io = self._deliveries[delivery_key] + except KeyError: + log.error(f"Protocol error: deliver_chunk_request invalid key {key!r}") + else: + # Read the requested number of bytes from the file + # No need to lock here since each delivery is handled in + # its own thread, so no risk of two threads reading from the + # same object at once. + chunk = binary_io.read(requested_size) + if chunk: + self.write_packed(("deliver_chunk", delivery_key, chunk)) + else: + # Delivery complete - inform the server and clean up + self.write_packed(("deliver_file_end", delivery_key)) + binary_io.close() + with self._deliveries_lock: + del self._deliveries[delivery_key] def open_url(self, url: str, new_tab: bool = True) -> None: """Open a URL in the default web browser. @@ -293,16 +338,16 @@ def _deliver_file( ) -> None: """Deliver a binary file to the end-user of the application.""" binary.seek(0) + + # Generate a unique key for this delivery + key = str(uuid.uuid4().hex) + + # Inform the server that we're starting a new file delivery self.write_meta( { "type": "deliver_file_start", + "key": key, "path": str(path.resolve()), "open_method": open_method, } ) - - self.write_meta( - { - "type": "deliver_file_end", - } - ) From 0f0b25ba8b6aa4b14f6c105bfa2bb78bb80a9a5b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 6 Aug 2024 12:50:30 +0100 Subject: [PATCH 13/80] Tidying resources and error handling around sending chunks --- src/textual/drivers/web_driver.py | 37 ++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 3c8a73f9b1..efb6e1ccfe 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -250,25 +250,40 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: log.error("Protocol error: deliver_chunk_request missing key or size") return + deliveries = self._deliveries + deliveries_lock = self._deliveries_lock + + binary_io: BinaryIO | None = None try: - with self._deliveries_lock: - binary_io = self._deliveries[delivery_key] + with deliveries_lock: + binary_io = deliveries[delivery_key] except KeyError: - log.error(f"Protocol error: deliver_chunk_request invalid key {key!r}") + log.error( + f"Protocol error: deliver_chunk_request invalid key {delivery_key!r}" + ) else: # Read the requested number of bytes from the file # No need to lock here since each delivery is handled in # its own thread, so no risk of two threads reading from the # same object at once. - chunk = binary_io.read(requested_size) - if chunk: - self.write_packed(("deliver_chunk", delivery_key, chunk)) - else: - # Delivery complete - inform the server and clean up - self.write_packed(("deliver_file_end", delivery_key)) + try: + chunk = binary_io.read(requested_size) + if chunk: + self.write_packed(("deliver_chunk", delivery_key, chunk)) + else: + # Delivery complete - inform the server and clean up + self.write_packed(("deliver_file_end", delivery_key)) + binary_io.close() + with deliveries_lock: + del deliveries[delivery_key] + except Exception: + log.error( + f"Error delivering file chunk for key {delivery_key!r}. " + "Cancelling delivery." + ) binary_io.close() - with self._deliveries_lock: - del self._deliveries[delivery_key] + with deliveries_lock: + del deliveries[delivery_key] def open_url(self, url: str, new_tab: bool = True) -> None: """Open a URL in the default web browser. From e7bfe013dd4bc7ed7b9e090e3d97275b9a75b1fd Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 6 Aug 2024 12:54:03 +0100 Subject: [PATCH 14/80] Simplifying driver API --- src/textual/drivers/web_driver.py | 43 +++++++------------------------ 1 file changed, 9 insertions(+), 34 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index efb6e1ccfe..26ca4783ec 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -294,15 +294,14 @@ def open_url(self, url: str, new_tab: bool = True) -> None: """ self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab}) - def deliver_text( + def deliver_file( self, - text: TextIO, + file_like: TextIO | BinaryIO, *, save_path: Path, open_method: Literal["browser", "download"] = "download", - encoding: str | None = None, ) -> None: - """Deliver a text file to the end-user of the application. + """Deliver a file to the end-user of the application. If running in a terminal, this will save the file to the user's downloads directory. @@ -311,47 +310,23 @@ def deliver_text( this will initiate a download in the web browser. Args: - text: The text file to save. + file_like: The file to deliver. save_path: The location to save the file to. open_method: *web only* Choose whether the file should be opened in the browser or whether the user should be prompted to download the file. - encoding: The encoding of the text file. """ - self._deliver_file(text.buffer, save_path, open_method) - - def deliver_binary( - self, - binary: BinaryIO, - *, - save_path: Path, - open_method: Literal["browser", "download"] = "download", - ) -> None: - """Deliver a binary file to the end-user of the application. - - If running in a terminal, this will save the file to the user's - downloads directory. - - If running via a web browser, this will initiate a download. - - This is a blocking operation. - - Args: - file_like: The file to save. - save_path: The location to save the file to. Only the file name is used, - since we're running in a web environment and cannot enforce a download location - on the user. The filename will be used to set the `Content-Disposition` header. - open_method: Whether to open the file in the browser or to prompt the user to download it. - """ - self._deliver_file(binary, save_path, open_method) + binary_io = file_like.buffer if isinstance(file_like, TextIO) else file_like + self._deliver_file(binary_io, path=save_path, open_method=open_method) def _deliver_file( self, binary: BinaryIO, + *, path: Path, - open_method: Literal["browser", "download"] = "download", + open_method: Literal["browser", "download"], ) -> None: - """Deliver a binary file to the end-user of the application.""" + """Deliver a file to the end-user of the application.""" binary.seek(0) # Generate a unique key for this delivery From 3ec56f3e094d5b9c1f250dcc689d7e5bbb075251 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 6 Aug 2024 12:57:45 +0100 Subject: [PATCH 15/80] Add import annotations from future --- src/textual/_files.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/_files.py b/src/textual/_files.py index 5433f6cf11..37fd76adfb 100644 --- a/src/textual/_files.py +++ b/src/textual/_files.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime From 2ecfabc388a26ec439e21929efeccdb71071e64f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 7 Aug 2024 09:40:04 +0100 Subject: [PATCH 16/80] Include length in packed messages as it makes parsing easier --- src/textual/drivers/web_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 26ca4783ec..91555e4c0c 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -100,7 +100,7 @@ def write_packed(self, data: tuple[str | bytes, ...]) -> None: data: The dictionary to pack and write. """ packed_bytes = msgpack.packb(data) - self._write(b"P%s" % packed_bytes) + self._write(b"P%s%s" % (len(packed_bytes).to_bytes(4, "big"), packed_bytes)) def flush(self) -> None: pass From bbba1aa4a380b0bad1777877cfb3d7fb844260f3 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 7 Aug 2024 17:00:58 +0100 Subject: [PATCH 17/80] Simplifying deliver file --- src/textual/app.py | 82 +++++++++++++------------------ src/textual/driver.py | 36 +++----------- src/textual/drivers/web_driver.py | 45 +++++++++++++---- 3 files changed, 77 insertions(+), 86 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 020ed98cbd..a961a82641 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -66,7 +66,6 @@ log, messages, on, - work, ) from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START @@ -3688,12 +3687,12 @@ def open_url(self, url: str, *, new_tab: bool = True) -> None: if self._driver is not None: self._driver.open_url(url, new_tab) - @work(thread=True) def deliver_text( self, path_or_file: str | Path | TextIO, *, - save_location: str | Path | None = None, + save_directory: str | Path | None = None, + open_method: Literal["browser", "download"] = "download", encoding: str | None = None, ) -> None: """Deliver a text file to the end-user of the application. @@ -3716,76 +3715,58 @@ def deliver_text( (if possible). If this is not possible, the encoding of the current locale will be used. """ - if self._driver is None: - return - - # Get the TextIO file-like object. - if isinstance(path_or_file, (str, Path)): - requires_close = True - path = Path(path_or_file) - text_file = path.open("r", encoding=encoding) - file_name = path.name - else: - requires_close = False - text_file = path_or_file - # Get the encoding and file_name from the file-like object if required. - encoding = encoding or getattr(text_file, "encoding", None) - file_name = getattr(text_file, "name", None) - # Some file-like objects don't have a name attribute, so generate a filename. - if not file_name: - file_name = generate_datetime_filename(self.title, "") - - # Find the full path to write the file to. - save_directory = ( - user_downloads_path() if save_location is None else Path(save_location) - ) - - # Let the driver decide how to handle saving the file. - self._driver.deliver_text( - text_file, - save_path=save_directory / file_name, + self._deliver_binary( + path_or_file.buffer if isinstance(path_or_file, TextIO) else path_or_file, + save_directory=save_directory, + open_method=open_method, encoding=encoding, ) - # Close the file if we were the ones who opened it. - # If the user opened the file, they won't expect us to close it, - # so leave it to them. - if requires_close: - text_file.close() - - @work(thread=True) def deliver_binary( self, path_or_file: str | Path | BinaryIO, *, - save_path: str | Path | None = None, + save_directory: str | Path | None = None, + open_method: Literal["browser", "download"] = "download", ) -> None: """Deliver a binary file to the end-user of the application. + If a BinaryIO object is supplied, it will be closed by this method + and *must not be used* after it is supplied to this method. + If running in a terminal, this will save the file to the user's downloads directory. If running via a web browser, this will initiate a download. - This is a blocking operation. + This operation runs in a thread when running on web, so this method + returning does not indicate that the file has been delivered. Args: path_or_file: The path or file-like object to save. - save_path: The location to save the file to. If None, + save_directory: The directory to save the file to. If None, the default "downloads" directory will be used. This argument is ignored when running via the web. """ + self._deliver_binary(path_or_file, save_directory, open_method) + + def _deliver_binary( + self, + path_or_file: str | Path | BinaryIO, + save_directory: str | Path | None, + open_method: Literal["browser", "download"], + encoding: str | None = None, + ) -> None: + """Deliver a binary file to the end-user of the application.""" if self._driver is None: return # Ensure `path_or_file` is a file-like object - convert if needed. if isinstance(path_or_file, (str, Path)): - requires_close = True binary_path = Path(path_or_file) binary = binary_path.open("rb") file_name = binary_path.name else: - requires_close = False binary = path_or_file file_name = getattr(binary, "name", None) # Generate a filename if the file-like object doesn't have one. @@ -3793,13 +3774,16 @@ def deliver_binary( file_name = generate_datetime_filename(self.title, "") # Find the appropriate save location if not specified. - save_directory = user_downloads_path() if save_path is None else Path(save_path) + save_directory = ( + user_downloads_path() if save_directory is None else Path(save_directory) + ) # Save the file. The driver will determine the appropriate action # to take here. It could mean simply writing to the save_path, or # sending the file to the web browser for download. - self._driver.deliver_binary(binary, save_path=save_directory / file_name) - - # Close the file if we opened it inside this method. - if requires_close: - binary.close() + self._driver.deliver_binary( + binary, + save_path=save_directory / file_name, + encoding=encoding, + open_method=open_method, + ) diff --git a/src/textual/driver.py b/src/textual/driver.py index 1f0e1146b3..f787602af1 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal, TextIO +from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal from . import events from .events import MouseUp @@ -199,37 +199,13 @@ def open_url(self, url: str, new_tab: bool = True) -> None: webbrowser.open(url) - def deliver_text( - self, - text: TextIO, - *, - save_path: Path, - open_method: Literal["browser", "download"] = "download", - encoding: str | None = None, - ) -> None: - """Save the text file `path_or_file` to `save_path`. - - If running via web through Textual Web or Textual Serve, - this will initiate a download in the web browser. - - Args: - path_or_file: The path or file-like object to save. - save_path: The full path to save the file to. - open_method: *web only* Whether to open the file in the browser or to - prompt the user to download it. When running via a standard (non-web) - terminal, this is ignored. - encoding: The text encoding to use when saving the file. - This will be passed to Python's `open()` built-in function. - """ - with open(save_path, "w", encoding=encoding) as destination_file: - shutil.copyfileobj(text, destination_file) - def deliver_binary( self, binary: BinaryIO, *, save_path: Path, open_method: Literal["browser", "download"] = "download", + encoding: str | None = None, ) -> None: """Save the file `path_or_file` to `save_path`. @@ -237,13 +213,17 @@ def deliver_binary( this will initiate a download in the web browser. Args: - file_like: The file to save. + binary: The binary file to save. save_path: The location to save the file to. If None, the default "downloads" directory will be used. When running via web, only the file name will be used. open_method: *web only* Whether to open the file in the browser or to prompt the user to download it. When running via a standard (non-web) terminal, this is ignored. + encoding: The text encoding to use when saving the file. + This will be passed to Python's `open()` built-in function. """ - with open(save_path, "wb") as destination_file: + mode = "wb" if encoding is None else "w" + with open(save_path, mode, encoding=encoding) as destination_file: shutil.copyfileobj(binary, destination_file) + binary.close() diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 91555e4c0c..21dbf4363c 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -243,6 +243,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: raise _ExitInput() elif packet_type == "deliver_chunk_request": # A request from the server to deliver another chunk of a file + log.info(f"Deliver chunk request: {payload}") try: delivery_key = cast(str, payload["key"]) requested_size = cast(int, payload["size"]) @@ -263,15 +264,17 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: ) else: # Read the requested number of bytes from the file - # No need to lock here since each delivery is handled in - # its own thread, so no risk of two threads reading from the - # same object at once. try: + log.info(f"Reading {requested_size} bytes from {delivery_key}") chunk = binary_io.read(requested_size) if chunk: + log.info( + f"Delivering chunk {delivery_key} of size {len(chunk)}" + ) self.write_packed(("deliver_chunk", delivery_key, chunk)) else: # Delivery complete - inform the server and clean up + log.info(f"Delivery complete for {delivery_key}") self.write_packed(("deliver_file_end", delivery_key)) binary_io.close() with deliveries_lock: @@ -281,6 +284,9 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: f"Error delivering file chunk for key {delivery_key!r}. " "Cancelling delivery." ) + import traceback + + log.error(str(traceback.format_exc())) binary_io.close() with deliveries_lock: del deliveries[delivery_key] @@ -294,11 +300,12 @@ def open_url(self, url: str, new_tab: bool = True) -> None: """ self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab}) - def deliver_file( + def deliver_text( self, - file_like: TextIO | BinaryIO, + text: TextIO, *, save_path: Path, + encoding: str | None = None, open_method: Literal["browser", "download"] = "download", ) -> None: """Deliver a file to the end-user of the application. @@ -312,19 +319,34 @@ def deliver_file( Args: file_like: The file to deliver. save_path: The location to save the file to. + encoding: The encoding of the text file or None to use the default encoding. open_method: *web only* Choose whether the file should be opened in the browser or whether the user should be prompted to download the file. """ - binary_io = file_like.buffer if isinstance(file_like, TextIO) else file_like - self._deliver_file(binary_io, path=save_path, open_method=open_method) + self._deliver_file( + text.buffer, save_path=save_path, open_method=open_method, encoding=encoding + ) + + def deliver_binary( + self, + binary: BinaryIO, + *, + save_path: Path, + open_method: Literal["browser", "download"] = "download", + encoding: str | None = None, + ) -> None: + self._deliver_file( + binary, save_path=save_path, open_method=open_method, encoding=encoding + ) def _deliver_file( self, binary: BinaryIO, *, - path: Path, + save_path: Path, open_method: Literal["browser", "download"], + encoding: str | None = None, ) -> None: """Deliver a file to the end-user of the application.""" binary.seek(0) @@ -332,12 +354,17 @@ def _deliver_file( # Generate a unique key for this delivery key = str(uuid.uuid4().hex) + with self._deliveries_lock: + self._deliveries[key] = binary + # Inform the server that we're starting a new file delivery + log.info(f"Delivering file {save_path} to {open_method}") self.write_meta( { "type": "deliver_file_start", "key": key, - "path": str(path.resolve()), + "path": str(save_path.resolve()), "open_method": open_method, + "encoding": encoding, } ) From 62bfd1d99cc24293a6c1f3ea36841d3352c3fd03 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 8 Aug 2024 16:11:46 +0100 Subject: [PATCH 18/80] Ensuring empty chunk is sent to indicate end of file --- src/textual/drivers/web_driver.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 21dbf4363c..a66097e1e4 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -267,15 +267,10 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: try: log.info(f"Reading {requested_size} bytes from {delivery_key}") chunk = binary_io.read(requested_size) - if chunk: - log.info( - f"Delivering chunk {delivery_key} of size {len(chunk)}" - ) - self.write_packed(("deliver_chunk", delivery_key, chunk)) - else: - # Delivery complete - inform the server and clean up + log.info(f"Delivering chunk {delivery_key} of size {len(chunk)}") + self.write_packed(("deliver_chunk", delivery_key, chunk)) + if not chunk: log.info(f"Delivery complete for {delivery_key}") - self.write_packed(("deliver_file_end", delivery_key)) binary_io.close() with deliveries_lock: del deliveries[delivery_key] From fb031808bfdf0362c5f5072c420f5648aa31578f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 12 Aug 2024 11:46:47 +0100 Subject: [PATCH 19/80] Exposing control over MIME type in file delivery --- src/textual/app.py | 47 ++++++++++++++++++++++++++----- src/textual/driver.py | 10 +++++-- src/textual/drivers/web_driver.py | 39 ++++++------------------- 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index a961a82641..0392d9ffad 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -11,6 +11,7 @@ import importlib import inspect import io +import mimetypes import os import signal import sys @@ -3694,6 +3695,7 @@ def deliver_text( save_directory: str | Path | None = None, open_method: Literal["browser", "download"] = "download", encoding: str | None = None, + mime_type: str | None = None, ) -> None: """Deliver a text file to the end-user of the application. @@ -3714,12 +3716,16 @@ def deliver_text( the encoding will be determined by supplied file-like object (if possible). If this is not possible, the encoding of the current locale will be used. + mime_type: The MIME type of the file or None to guess based on file extension. + If no MIME type is supplied and we cannot guess the MIME type, from the + file extension, the MIME type will be set to "text/plain". """ self._deliver_binary( - path_or_file.buffer if isinstance(path_or_file, TextIO) else path_or_file, + path_or_file, save_directory=save_directory, open_method=open_method, encoding=encoding, + mime_type=mime_type, ) def deliver_binary( @@ -3728,6 +3734,7 @@ def deliver_binary( *, save_directory: str | Path | None = None, open_method: Literal["browser", "download"] = "download", + mime_type: str | None = None, ) -> None: """Deliver a binary file to the end-user of the application. @@ -3747,15 +3754,22 @@ def deliver_binary( save_directory: The directory to save the file to. If None, the default "downloads" directory will be used. This argument is ignored when running via the web. + open_method: The method to use to open the file. "browser" will open the file in the + web browser, "download" will initiate a download. Note that this can sometimes + be impacted by the browser's settings. + mime_type: The MIME type of the file or None to guess based on file extension. + If no MIME type is supplied and we cannot guess the MIME type, from the + file extension, the MIME type will be set to "application/octet-stream". """ - self._deliver_binary(path_or_file, save_directory, open_method) + self._deliver_binary(path_or_file, save_directory, open_method, mime_type) def _deliver_binary( self, - path_or_file: str | Path | BinaryIO, + path_or_file: str | Path | BinaryIO | TextIO, save_directory: str | Path | None, open_method: Literal["browser", "download"], encoding: str | None = None, + mime_type: str | None = None, ) -> None: """Deliver a binary file to the end-user of the application.""" if self._driver is None: @@ -3766,12 +3780,30 @@ def _deliver_binary( binary_path = Path(path_or_file) binary = binary_path.open("rb") file_name = binary_path.name - else: + if not mime_type: + mime_type, _ = mimetypes.guess_type(file_name) + if mime_type is None: + mime_type = "application/octet-stream" + elif isinstance(path_or_file, TextIO): + binary = path_or_file.buffer + file_name = getattr(binary, "name", None) + # If we have a filename and no MIME type, try to guess the MIME type. + if file_name and not mime_type: + mime_type, _ = mimetypes.guess_type(file_name) + if mime_type is None: + mime_type = "text/plain" + else: # isinstance(path_or_file, BinaryIO): binary = path_or_file file_name = getattr(binary, "name", None) - # Generate a filename if the file-like object doesn't have one. - if not file_name: - file_name = generate_datetime_filename(self.title, "") + # If we have a filename and no MIME type, try to guess the MIME type. + if file_name and not mime_type: + mime_type, _ = mimetypes.guess_type(file_name) + if mime_type is None: + mime_type = "application/octet-stream" + + # Generate a filename if the file-like object doesn't have one. + if not file_name: + file_name = generate_datetime_filename(self.title, "") # Find the appropriate save location if not specified. save_directory = ( @@ -3786,4 +3818,5 @@ def _deliver_binary( save_path=save_directory / file_name, encoding=encoding, open_method=open_method, + mime_type=mime_type, ) diff --git a/src/textual/driver.py b/src/textual/driver.py index f787602af1..769884a33d 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -206,6 +206,7 @@ def deliver_binary( save_path: Path, open_method: Literal["browser", "download"] = "download", encoding: str | None = None, + mime_type: str | None = None, ) -> None: """Save the file `path_or_file` to `save_path`. @@ -220,10 +221,13 @@ def deliver_binary( open_method: *web only* Whether to open the file in the browser or to prompt the user to download it. When running via a standard (non-web) terminal, this is ignored. - encoding: The text encoding to use when saving the file. + encoding: *web only* The text encoding to use when saving the file. This will be passed to Python's `open()` built-in function. + When running via web, this will be used to set the charset + in the `Content-Type` header. + mime_type: *web only* The MIME type of the file. This will be used to + set the `Content-Type` header in the HTTP response. """ - mode = "wb" if encoding is None else "w" - with open(save_path, mode, encoding=encoding) as destination_file: + with open(save_path, "wb") as destination_file: shutil.copyfileobj(binary, destination_file) binary.close() diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index a66097e1e4..c388e2f587 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -21,7 +21,7 @@ from functools import partial from pathlib import Path from threading import Event, Lock, Thread -from typing import Any, BinaryIO, Literal, TextIO, cast +from typing import Any, BinaryIO, Literal, cast import msgpack @@ -295,34 +295,6 @@ def open_url(self, url: str, new_tab: bool = True) -> None: """ self.write_meta({"type": "open_url", "url": url, "new_tab": new_tab}) - def deliver_text( - self, - text: TextIO, - *, - save_path: Path, - encoding: str | None = None, - open_method: Literal["browser", "download"] = "download", - ) -> None: - """Deliver a file to the end-user of the application. - - If running in a terminal, this will save the file to the user's - downloads directory. - - If running via web through Textual Web or Textual Serve, - this will initiate a download in the web browser. - - Args: - file_like: The file to deliver. - save_path: The location to save the file to. - encoding: The encoding of the text file or None to use the default encoding. - open_method: *web only* Choose whether the file should - be opened in the browser or whether the user should - be prompted to download the file. - """ - self._deliver_file( - text.buffer, save_path=save_path, open_method=open_method, encoding=encoding - ) - def deliver_binary( self, binary: BinaryIO, @@ -330,9 +302,14 @@ def deliver_binary( save_path: Path, open_method: Literal["browser", "download"] = "download", encoding: str | None = None, + mime_type: str | None = None, ) -> None: self._deliver_file( - binary, save_path=save_path, open_method=open_method, encoding=encoding + binary, + save_path=save_path, + open_method=open_method, + encoding=encoding, + mime_type=mime_type, ) def _deliver_file( @@ -342,6 +319,7 @@ def _deliver_file( save_path: Path, open_method: Literal["browser", "download"], encoding: str | None = None, + mime_type: str | None = None, ) -> None: """Deliver a file to the end-user of the application.""" binary.seek(0) @@ -361,5 +339,6 @@ def _deliver_file( "path": str(save_path.resolve()), "open_method": open_method, "encoding": encoding, + "mime_type": mime_type, } ) From 848fed3028abcdb25700a5756ddb3e06aee56c0f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 12 Aug 2024 14:39:14 +0100 Subject: [PATCH 20/80] Logging and reverting version --- src/textual/drivers/web_driver.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index a218b4de79..9a38e8a0d8 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -243,7 +243,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: raise _ExitInput() elif packet_type == "deliver_chunk_request": # A request from the server to deliver another chunk of a file - log.info(f"Deliver chunk request: {payload}") + log.debug(f"Deliver chunk request: {payload}") try: delivery_key = cast(str, payload["key"]) requested_size = cast(int, payload["size"]) @@ -265,9 +265,9 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: else: # Read the requested number of bytes from the file try: - log.info(f"Reading {requested_size} bytes from {delivery_key}") + log.debug(f"Reading {requested_size} bytes from {delivery_key}") chunk = binary_io.read(requested_size) - log.info(f"Delivering chunk {delivery_key} of size {len(chunk)}") + log.debug(f"Delivering chunk {delivery_key!r} of len {len(chunk)}") self.write_packed(("deliver_chunk", delivery_key, chunk)) if not chunk: log.info(f"Delivery complete for {delivery_key}") @@ -331,14 +331,13 @@ def _deliver_file( self._deliveries[key] = binary # Inform the server that we're starting a new file delivery - log.info(f"Delivering file {save_path} to {open_method}") - self.write_meta( - { - "type": "deliver_file_start", - "key": key, - "path": str(save_path.resolve()), - "open_method": open_method, - "encoding": encoding, - "mime_type": mime_type, - } - ) + meta: dict[str, object] = { + "type": "deliver_file_start", + "key": key, + "path": str(save_path.resolve()), + "open_method": open_method, + "encoding": encoding, + "mime_type": mime_type, + } + self.write_meta(meta) + log.info(f"Delivering file {meta['path']!r}: {meta!r}") From 9d6924a48bc203b05079d2b3eb8e9262053adabf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 12 Aug 2024 14:52:03 +0100 Subject: [PATCH 21/80] Improving documentation --- src/textual/app.py | 11 +++++++---- src/textual/drivers/web_driver.py | 7 ++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 63577b5ab6..b0bd50bca9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3703,12 +3703,14 @@ def deliver_text( ) -> None: """Deliver a text file to the end-user of the application. + If a TextIO object is supplied, it will be closed by this method + and *must not be used* after this method is called. + If running in a terminal, this will save the file to the user's downloads directory. - If running via a web browser, this will initiate a download. - - This is a blocking operation. + If running via a web browser, this will initiate a download via + a single-use URL. Args: path_or_file: The path or file-like object to save. @@ -3748,7 +3750,8 @@ def deliver_binary( If running in a terminal, this will save the file to the user's downloads directory. - If running via a web browser, this will initiate a download. + If running via a web browser, this will initiate a download via + a single-use URL. This operation runs in a thread when running on web, so this method returning does not indicate that the file has been delivered. diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 9a38e8a0d8..53efda5f97 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -275,6 +275,10 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: with deliveries_lock: del deliveries[delivery_key] except Exception: + binary_io.close() + with deliveries_lock: + del deliveries[delivery_key] + log.error( f"Error delivering file chunk for key {delivery_key!r}. " "Cancelling delivery." @@ -282,9 +286,6 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: import traceback log.error(str(traceback.format_exc())) - binary_io.close() - with deliveries_lock: - del deliveries[delivery_key] def open_url(self, url: str, new_tab: bool = True) -> None: """Open a URL in the default web browser. From 2adac06ce3df17dd4fb01d864ff12918f7d485e1 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 12 Aug 2024 14:54:39 +0100 Subject: [PATCH 22/80] Move msgpack-types to dev deps --- poetry.lock | 891 ++++++++++++++++++++++++++----------------------- pyproject.toml | 2 +- 2 files changed, 482 insertions(+), 411 deletions(-) diff --git a/poetry.lock b/poetry.lock index a6cd1d1b87..b0e6e3dc24 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,91 +1,103 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.3.5" +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"}, +] [[package]] name = "aiohttp" -version = "3.9.5" +version = "3.10.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, - {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, - {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, - {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, - {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, - {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, - {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, - {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, - {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, - {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, - {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, - {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, - {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, - {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, - {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, - {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, - {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, - {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, - {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, - {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, - {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, - {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, - {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, - {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, - {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, - {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, - {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, + {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"}, ] [package.dependencies] +aiohappyeyeballs = ">=2.3.0" aiosignal = ">=1.1.2" async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" @@ -94,7 +106,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "brotlicffi"] +speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] [[package]] name = "aiosignal" @@ -112,13 +124,13 @@ frozenlist = ">=1.1.0" [[package]] name = "anyio" -version = "4.3.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, - {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -145,32 +157,32 @@ files = [ [[package]] name = "attrs" -version = "23.2.0" +version = "24.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.7" files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, ] [package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] [[package]] name = "babel" -version = "2.15.0" +version = "2.16.0" description = "Internationalization utilities" optional = false python-versions = ">=3.8" files = [ - {file = "Babel-2.15.0-py3-none-any.whl", hash = "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb"}, - {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, + {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, + {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, ] [package.dependencies] @@ -225,15 +237,36 @@ d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cachecontrol" +version = "0.14.0" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachecontrol-0.14.0-py3-none-any.whl", hash = "sha256:f5bf3f0620c38db2e5122c0726bdebb0d16869de966ea6a2befe92470b740ea0"}, + {file = "cachecontrol-0.14.0.tar.gz", hash = "sha256:7db1195b41c81f8274a7bbd97c956f44e8348265a1bc7641c37dfebc39f0c938"}, +] + +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["CacheControl[filecache,redis]", "black", "build", "cherrypy", "furo", "mypy", "pytest", "pytest-cov", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -373,63 +406,83 @@ files = [ [[package]] name = "coverage" -version = "7.5.1" +version = "7.6.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0884920835a033b78d1c73b6d3bbcda8161a900f38a488829a83982925f6c2e"}, - {file = "coverage-7.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:39afcd3d4339329c5f58de48a52f6e4e50f6578dd6099961cf22228feb25f38f"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b0ceee8147444347da6a66be737c9d78f3353b0681715b668b72e79203e4a"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a9ca3f2fae0088c3c71d743d85404cec8df9be818a005ea065495bedc33da35"}, - {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd215c0c7d7aab005221608a3c2b46f58c0285a819565887ee0b718c052aa4e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4bf0655ab60d754491004a5efd7f9cccefcc1081a74c9ef2da4735d6ee4a6223"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61c4bf1ba021817de12b813338c9be9f0ad5b1e781b9b340a6d29fc13e7c1b5e"}, - {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:db66fc317a046556a96b453a58eced5024af4582a8dbdc0c23ca4dbc0d5b3146"}, - {file = "coverage-7.5.1-cp310-cp310-win32.whl", hash = "sha256:b016ea6b959d3b9556cb401c55a37547135a587db0115635a443b2ce8f1c7228"}, - {file = "coverage-7.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:df4e745a81c110e7446b1cc8131bf986157770fa405fe90e15e850aaf7619bc8"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:796a79f63eca8814ca3317a1ea443645c9ff0d18b188de470ed7ccd45ae79428"}, - {file = "coverage-7.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fc84a37bfd98db31beae3c2748811a3fa72bf2007ff7902f68746d9757f3746"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6175d1a0559986c6ee3f7fccfc4a90ecd12ba0a383dcc2da30c2b9918d67d8a3"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fc81d5878cd6274ce971e0a3a18a8803c3fe25457165314271cf78e3aae3aa2"}, - {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:556cf1a7cbc8028cb60e1ff0be806be2eded2daf8129b8811c63e2b9a6c43bca"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9981706d300c18d8b220995ad22627647be11a4276721c10911e0e9fa44c83e8"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7fed867ee50edf1a0b4a11e8e5d0895150e572af1cd6d315d557758bfa9c057"}, - {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef48e2707fb320c8f139424a596f5b69955a85b178f15af261bab871873bb987"}, - {file = "coverage-7.5.1-cp311-cp311-win32.whl", hash = "sha256:9314d5678dcc665330df5b69c1e726a0e49b27df0461c08ca12674bcc19ef136"}, - {file = "coverage-7.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fa567e99765fe98f4e7d7394ce623e794d7cabb170f2ca2ac5a4174437e90dd"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b6cf3764c030e5338e7f61f95bd21147963cf6aa16e09d2f74f1fa52013c1206"}, - {file = "coverage-7.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ec92012fefebee89a6b9c79bc39051a6cb3891d562b9270ab10ecfdadbc0c34"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16db7f26000a07efcf6aea00316f6ac57e7d9a96501e990a36f40c965ec7a95d"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beccf7b8a10b09c4ae543582c1319c6df47d78fd732f854ac68d518ee1fb97fa"}, - {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8748731ad392d736cc9ccac03c9845b13bb07d020a33423fa5b3a36521ac6e4e"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7352b9161b33fd0b643ccd1f21f3a3908daaddf414f1c6cb9d3a2fd618bf2572"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7a588d39e0925f6a2bff87154752481273cdb1736270642aeb3635cb9b4cad07"}, - {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:68f962d9b72ce69ea8621f57551b2fa9c70509af757ee3b8105d4f51b92b41a7"}, - {file = "coverage-7.5.1-cp312-cp312-win32.whl", hash = "sha256:f152cbf5b88aaeb836127d920dd0f5e7edff5a66f10c079157306c4343d86c19"}, - {file = "coverage-7.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:5a5740d1fb60ddf268a3811bcd353de34eb56dc24e8f52a7f05ee513b2d4f596"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2213def81a50519d7cc56ed643c9e93e0247f5bbe0d1247d15fa520814a7cd7"}, - {file = "coverage-7.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5037f8fcc2a95b1f0e80585bd9d1ec31068a9bcb157d9750a172836e98bc7a90"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3721c2c9e4c4953a41a26c14f4cef64330392a6d2d675c8b1db3b645e31f0e"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca498687ca46a62ae590253fba634a1fe9836bc56f626852fb2720f334c9e4e5"}, - {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cdcbc320b14c3e5877ee79e649677cb7d89ef588852e9583e6b24c2e5072661"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:57e0204b5b745594e5bc14b9b50006da722827f0b8c776949f1135677e88d0b8"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fe7502616b67b234482c3ce276ff26f39ffe88adca2acf0261df4b8454668b4"}, - {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9e78295f4144f9dacfed4f92935fbe1780021247c2fabf73a819b17f0ccfff8d"}, - {file = "coverage-7.5.1-cp38-cp38-win32.whl", hash = "sha256:1434e088b41594baa71188a17533083eabf5609e8e72f16ce8c186001e6b8c41"}, - {file = "coverage-7.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:0646599e9b139988b63704d704af8e8df7fa4cbc4a1f33df69d97f36cb0a38de"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4cc37def103a2725bc672f84bd939a6fe4522310503207aae4d56351644682f1"}, - {file = "coverage-7.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc0b4d8bfeabd25ea75e94632f5b6e047eef8adaed0c2161ada1e922e7f7cece"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d0a0f5e06881ecedfe6f3dd2f56dcb057b6dbeb3327fd32d4b12854df36bf26"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9735317685ba6ec7e3754798c8871c2f49aa5e687cc794a0b1d284b2389d1bd5"}, - {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d21918e9ef11edf36764b93101e2ae8cc82aa5efdc7c5a4e9c6c35a48496d601"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c3e757949f268364b96ca894b4c342b41dc6f8f8b66c37878aacef5930db61be"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:79afb6197e2f7f60c4824dd4b2d4c2ec5801ceb6ba9ce5d2c3080e5660d51a4f"}, - {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1d0d98d95dd18fe29dc66808e1accf59f037d5716f86a501fc0256455219668"}, - {file = "coverage-7.5.1-cp39-cp39-win32.whl", hash = "sha256:1cc0fe9b0b3a8364093c53b0b4c0c2dd4bb23acbec4c9240b5f284095ccf7981"}, - {file = "coverage-7.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:dde0070c40ea8bb3641e811c1cfbf18e265d024deff6de52c5950677a8fb1e0f"}, - {file = "coverage-7.5.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:6537e7c10cc47c595828b8a8be04c72144725c383c4702703ff4e42e44577312"}, - {file = "coverage-7.5.1.tar.gz", hash = "sha256:54de9ef3a9da981f7af93eafde4ede199e0846cd819eb27c88e2b712aae9708c"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, ] [package.dependencies] @@ -451,13 +504,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.2.1" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"}, - {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -479,18 +532,18 @@ testing = ["hatch", "pre-commit", "pytest", "tox"] [[package]] name = "filelock" -version = "3.14.0" +version = "3.15.4" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, - {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, ] [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-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.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)"] [[package]] @@ -699,13 +752,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "identify" -version = "2.5.36" +version = "2.6.0" description = "File identification library for Python" optional = false python-versions = ">=3.8" files = [ - {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, - {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, + {file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"}, + {file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"}, ] [package.extras] @@ -724,22 +777,22 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.1.0" +version = "8.2.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"}, - {file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"}, + {file = "importlib_metadata-8.2.0-py3-none-any.whl", hash = "sha256:11901fa0c2f97919b288679932bb64febaeacf289d18ac84dd68cb2e74213369"}, + {file = "importlib_metadata-8.2.0.tar.gz", hash = "sha256:72e8d4399996132204f9a16dcc751af254a48f8d1b20b9ff0f98d4a8f901e73d"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "iniconfig" @@ -1036,13 +1089,13 @@ pyyaml = ">=5.1" [[package]] name = "mkdocs-git-revision-date-localized-plugin" -version = "1.2.5" +version = "1.2.6" 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.5-py3-none-any.whl", hash = "sha256:d796a18b07cfcdb154c133e3ec099d2bb5f38389e4fd54d3eb516a8a736815b8"}, - {file = "mkdocs_git_revision_date_localized_plugin-1.2.5.tar.gz", hash = "sha256:0c439816d9d0dba48e027d9d074b2b9f1d7cd179f74ba46b51e4da7bb3dc4b9b"}, + {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"}, ] [package.dependencies] @@ -1053,13 +1106,13 @@ pytz = "*" [[package]] name = "mkdocs-material" -version = "9.5.23" +version = "9.5.31" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.23-py3-none-any.whl", hash = "sha256:ffd08a5beaef3cd135aceb58ded8b98bbbbf2b70e5b656f6a14a63c917d9b001"}, - {file = "mkdocs_material-9.5.23.tar.gz", hash = "sha256:4627fc3f15de2cba2bde9debc2fd59b9888ef494beabfe67eb352e23d14bf288"}, + {file = "mkdocs_material-9.5.31-py3-none-any.whl", hash = "sha256:1b1f49066fdb3824c1e96d6bacd2d4375de4ac74580b47e79ff44c4d835c5fcb"}, + {file = "mkdocs_material-9.5.31.tar.gz", hash = "sha256:31833ec664772669f5856f4f276bf3fdf0e642a445e64491eda459249c3a1ca8"}, ] [package.dependencies] @@ -1093,19 +1146,21 @@ files = [ [[package]] name = "mkdocs-rss-plugin" -version = "1.12.2" +version = "1.15.0" description = "MkDocs plugin which generates a static RSS feed using git log and page.meta." optional = false python-versions = "<4,>=3.8" files = [ - {file = "mkdocs_rss_plugin-1.12.2-py2.py3-none-any.whl", hash = "sha256:2e1d5cb871494f2634b9c3fe523a4246ba5c00c5f6627242101675d3c63b2bdd"}, - {file = "mkdocs_rss_plugin-1.12.2.tar.gz", hash = "sha256:313f127967ebcf14ad6f74f1f6f89f054deeb3a1de510770778ce150b75f2247"}, + {file = "mkdocs_rss_plugin-1.15.0-py2.py3-none-any.whl", hash = "sha256:7308ac13f0976c0479db5a62cb7ef9b10fdd74b6521e459bb66a13e2cfe69d4b"}, + {file = "mkdocs_rss_plugin-1.15.0.tar.gz", hash = "sha256:92995ed6c77b2ae1f5f2913e62282c27e50c35d618c4291b5b939e50badd7504"}, ] [package.dependencies] +cachecontrol = {version = ">=0.14,<1", extras = ["filecache"]} GitPython = ">=3.1,<3.2" -mkdocs = ">=1.4,<2" -pytz = {version = "==2022.*", markers = "python_version < \"3.9\""} +mkdocs = ">=1.5,<2" +pytz = {version = "==2024.*", markers = "python_version < \"3.9\""} +requests = ">=2.31,<3" tzdata = {version = "==2024.*", markers = "python_version >= \"3.9\" and sys_platform == \"win32\""} [package.extras] @@ -1330,44 +1385,44 @@ files = [ [[package]] name = "mypy" -version = "1.10.0" +version = "1.11.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"}, - {file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"}, - {file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"}, - {file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"}, - {file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"}, - {file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"}, - {file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"}, - {file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"}, - {file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"}, - {file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"}, - {file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"}, - {file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"}, - {file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"}, - {file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"}, - {file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"}, - {file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"}, - {file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"}, - {file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"}, - {file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"}, - {file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"}, - {file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"}, - {file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"}, - {file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"}, - {file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"}, - {file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"}, - {file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"}, - {file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"}, + {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"}, ] [package.dependencies] mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.1.0" +typing-extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] @@ -1388,27 +1443,24 @@ files = [ [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] @@ -1497,13 +1549,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.8.1" +version = "10.9" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.8.1-py3-none-any.whl", hash = "sha256:f938326115884f48c6059c67377c46cf631c733ef3629b6eed1349989d1b30cb"}, - {file = "pymdown_extensions-10.8.1.tar.gz", hash = "sha256:3ab1db5c9e21728dabf75192d71471f8e50f216627e9a1fa9535ecb0231b9940"}, + {file = "pymdown_extensions-10.9-py3-none-any.whl", hash = "sha256:d323f7e90d83c86113ee78f3fe62fc9dee5f56b54d912660703ea1816fed5626"}, + {file = "pymdown_extensions-10.9.tar.gz", hash = "sha256:6ff740bcd99ec4172a938970d42b96128bdc9d4b9bcad72494f29921dc69b753"}, ] [package.dependencies] @@ -1515,13 +1567,13 @@ extra = ["pygments (>=2.12)"] [[package]] name = "pytest" -version = "8.3.1" +version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.1-py3-none-any.whl", hash = "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c"}, - {file = "pytest-8.3.1.tar.gz", hash = "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6"}, + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, ] [package.dependencies] @@ -1537,13 +1589,13 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-asyncio" -version = "0.23.6" +version = "0.23.8" description = "Pytest support for asyncio" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"}, - {file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"}, + {file = "pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2"}, + {file = "pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3"}, ] [package.dependencies] @@ -1625,62 +1677,75 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2022.7.1" +version = "2024.1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, - {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] @@ -1699,101 +1764,101 @@ pyyaml = "*" [[package]] name = "regex" -version = "2024.5.15" +version = "2024.7.24" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" files = [ - {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"}, - {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"}, - {file = "regex-2024.5.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0bd000c6e266927cb7a1bc39d55be95c4b4f65c5be53e659537537e019232b1"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eaa7ddaf517aa095fa8da0b5015c44d03da83f5bd49c87961e3c997daed0de7"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba68168daedb2c0bab7fd7e00ced5ba90aebf91024dea3c88ad5063c2a562cca"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e8d717bca3a6e2064fc3a08df5cbe366369f4b052dcd21b7416e6d71620dca1"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1337b7dbef9b2f71121cdbf1e97e40de33ff114801263b275aafd75303bd62b5"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9ebd0a36102fcad2f03696e8af4ae682793a5d30b46c647eaf280d6cfb32796"}, - {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9efa1a32ad3a3ea112224897cdaeb6aa00381627f567179c0314f7b65d354c62"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1595f2d10dff3d805e054ebdc41c124753631b6a471b976963c7b28543cf13b0"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b802512f3e1f480f41ab5f2cfc0e2f761f08a1f41092d6718868082fc0d27143"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a0981022dccabca811e8171f913de05720590c915b033b7e601f35ce4ea7019f"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:19068a6a79cf99a19ccefa44610491e9ca02c2be3305c7760d3831d38a467a6f"}, - {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b5269484f6126eee5e687785e83c6b60aad7663dafe842b34691157e5083e53"}, - {file = "regex-2024.5.15-cp310-cp310-win32.whl", hash = "sha256:ada150c5adfa8fbcbf321c30c751dc67d2f12f15bd183ffe4ec7cde351d945b3"}, - {file = "regex-2024.5.15-cp310-cp310-win_amd64.whl", hash = "sha256:ac394ff680fc46b97487941f5e6ae49a9f30ea41c6c6804832063f14b2a5a145"}, - {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f5b1dff3ad008dccf18e652283f5e5339d70bf8ba7c98bf848ac33db10f7bc7a"}, - {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6a2b494a76983df8e3d3feea9b9ffdd558b247e60b92f877f93a1ff43d26656"}, - {file = "regex-2024.5.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a32b96f15c8ab2e7d27655969a23895eb799de3665fa94349f3b2fbfd547236f"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10002e86e6068d9e1c91eae8295ef690f02f913c57db120b58fdd35a6bb1af35"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec54d5afa89c19c6dd8541a133be51ee1017a38b412b1321ccb8d6ddbeb4cf7d"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10e4ce0dca9ae7a66e6089bb29355d4432caed736acae36fef0fdd7879f0b0cb"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e507ff1e74373c4d3038195fdd2af30d297b4f0950eeda6f515ae3d84a1770f"}, - {file = "regex-2024.5.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f059a4d795e646e1c37665b9d06062c62d0e8cc3c511fe01315973a6542e40"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0721931ad5fe0dda45d07f9820b90b2148ccdd8e45bb9e9b42a146cb4f695649"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:833616ddc75ad595dee848ad984d067f2f31be645d603e4d158bba656bbf516c"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:287eb7f54fc81546346207c533ad3c2c51a8d61075127d7f6d79aaf96cdee890"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:19dfb1c504781a136a80ecd1fff9f16dddf5bb43cec6871778c8a907a085bb3d"}, - {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:119af6e56dce35e8dfb5222573b50c89e5508d94d55713c75126b753f834de68"}, - {file = "regex-2024.5.15-cp311-cp311-win32.whl", hash = "sha256:1c1c174d6ec38d6c8a7504087358ce9213d4332f6293a94fbf5249992ba54efa"}, - {file = "regex-2024.5.15-cp311-cp311-win_amd64.whl", hash = "sha256:9e717956dcfd656f5055cc70996ee2cc82ac5149517fc8e1b60261b907740201"}, - {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:632b01153e5248c134007209b5c6348a544ce96c46005d8456de1d552455b014"}, - {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e64198f6b856d48192bf921421fdd8ad8eb35e179086e99e99f711957ffedd6e"}, - {file = "regex-2024.5.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68811ab14087b2f6e0fc0c2bae9ad689ea3584cad6917fc57be6a48bbd012c49"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ec0c2fea1e886a19c3bee0cd19d862b3aa75dcdfb42ebe8ed30708df64687a"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0c0c0003c10f54a591d220997dd27d953cd9ccc1a7294b40a4be5312be8797b"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2431b9e263af1953c55abbd3e2efca67ca80a3de8a0437cb58e2421f8184717a"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a605586358893b483976cffc1723fb0f83e526e8f14c6e6614e75919d9862cf"}, - {file = "regex-2024.5.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391d7f7f1e409d192dba8bcd42d3e4cf9e598f3979cdaed6ab11288da88cb9f2"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9ff11639a8d98969c863d4617595eb5425fd12f7c5ef6621a4b74b71ed8726d5"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4eee78a04e6c67e8391edd4dad3279828dd66ac4b79570ec998e2155d2e59fd5"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8fe45aa3f4aa57faabbc9cb46a93363edd6197cbc43523daea044e9ff2fea83e"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0a3d8d6acf0c78a1fff0e210d224b821081330b8524e3e2bc5a68ef6ab5803d"}, - {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c486b4106066d502495b3025a0a7251bf37ea9540433940a23419461ab9f2a80"}, - {file = "regex-2024.5.15-cp312-cp312-win32.whl", hash = "sha256:c49e15eac7c149f3670b3e27f1f28a2c1ddeccd3a2812cba953e01be2ab9b5fe"}, - {file = "regex-2024.5.15-cp312-cp312-win_amd64.whl", hash = "sha256:673b5a6da4557b975c6c90198588181029c60793835ce02f497ea817ff647cb2"}, - {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:87e2a9c29e672fc65523fb47a90d429b70ef72b901b4e4b1bd42387caf0d6835"}, - {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3bea0ba8b73b71b37ac833a7f3fd53825924165da6a924aec78c13032f20850"}, - {file = "regex-2024.5.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bfc4f82cabe54f1e7f206fd3d30fda143f84a63fe7d64a81558d6e5f2e5aaba9"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5bb9425fe881d578aeca0b2b4b3d314ec88738706f66f219c194d67179337cb"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64c65783e96e563103d641760664125e91bd85d8e49566ee560ded4da0d3e704"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf2430df4148b08fb4324b848672514b1385ae3807651f3567871f130a728cc3"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5397de3219a8b08ae9540c48f602996aa6b0b65d5a61683e233af8605c42b0f2"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:455705d34b4154a80ead722f4f185b04c4237e8e8e33f265cd0798d0e44825fa"}, - {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2b6f1b3bb6f640c1a92be3bbfbcb18657b125b99ecf141fb3310b5282c7d4ed"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3ad070b823ca5890cab606c940522d05d3d22395d432f4aaaf9d5b1653e47ced"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5b5467acbfc153847d5adb21e21e29847bcb5870e65c94c9206d20eb4e99a384"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e6662686aeb633ad65be2a42b4cb00178b3fbf7b91878f9446075c404ada552f"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:2b4c884767504c0e2401babe8b5b7aea9148680d2e157fa28f01529d1f7fcf67"}, - {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3cd7874d57f13bf70078f1ff02b8b0aa48d5b9ed25fc48547516c6aba36f5741"}, - {file = "regex-2024.5.15-cp38-cp38-win32.whl", hash = "sha256:e4682f5ba31f475d58884045c1a97a860a007d44938c4c0895f41d64481edbc9"}, - {file = "regex-2024.5.15-cp38-cp38-win_amd64.whl", hash = "sha256:d99ceffa25ac45d150e30bd9ed14ec6039f2aad0ffa6bb87a5936f5782fc1569"}, - {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13cdaf31bed30a1e1c2453ef6015aa0983e1366fad2667657dbcac7b02f67133"}, - {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cac27dcaa821ca271855a32188aa61d12decb6fe45ffe3e722401fe61e323cd1"}, - {file = "regex-2024.5.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7dbe2467273b875ea2de38ded4eba86cbcbc9a1a6d0aa11dcf7bd2e67859c435"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f18a9a3513a99c4bef0e3efd4c4a5b11228b48aa80743be822b71e132ae4f5"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d347a741ea871c2e278fde6c48f85136c96b8659b632fb57a7d1ce1872547600"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1878b8301ed011704aea4c806a3cadbd76f84dece1ec09cc9e4dc934cfa5d4da"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4babf07ad476aaf7830d77000874d7611704a7fcf68c9c2ad151f5d94ae4bfc4"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35cb514e137cb3488bce23352af3e12fb0dbedd1ee6e60da053c69fb1b29cc6c"}, - {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cdd09d47c0b2efee9378679f8510ee6955d329424c659ab3c5e3a6edea696294"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:72d7a99cd6b8f958e85fc6ca5b37c4303294954eac1376535b03c2a43eb72629"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a094801d379ab20c2135529948cb84d417a2169b9bdceda2a36f5f10977ebc16"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c18345010870e58238790a6779a1219b4d97bd2e77e1140e8ee5d14df071aa"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:16093f563098448ff6b1fa68170e4acbef94e6b6a4e25e10eae8598bb1694b5d"}, - {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e38a7d4e8f633a33b4c7350fbd8bad3b70bf81439ac67ac38916c4a86b465456"}, - {file = "regex-2024.5.15-cp39-cp39-win32.whl", hash = "sha256:71a455a3c584a88f654b64feccc1e25876066c4f5ef26cd6dd711308aa538694"}, - {file = "regex-2024.5.15-cp39-cp39-win_amd64.whl", hash = "sha256:cab12877a9bdafde5500206d1020a584355a97884dfd388af3699e9137bf7388"}, - {file = "regex-2024.5.15.tar.gz", hash = "sha256:d3ee02d9e5f482cc8309134a91eeaacbdd2261ba111b0fef3748eeb4913e6a2c"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024"}, + {file = "regex-2024.7.24-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5"}, + {file = "regex-2024.7.24-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce"}, + {file = "regex-2024.7.24-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa"}, + {file = "regex-2024.7.24-cp310-cp310-win32.whl", hash = "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66"}, + {file = "regex-2024.7.24-cp310-cp310-win_amd64.whl", hash = "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b"}, + {file = "regex-2024.7.24-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51"}, + {file = "regex-2024.7.24-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1"}, + {file = "regex-2024.7.24-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e"}, + {file = "regex-2024.7.24-cp311-cp311-win32.whl", hash = "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c"}, + {file = "regex-2024.7.24-cp311-cp311-win_amd64.whl", hash = "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad"}, + {file = "regex-2024.7.24-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440"}, + {file = "regex-2024.7.24-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94"}, + {file = "regex-2024.7.24-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38"}, + {file = "regex-2024.7.24-cp312-cp312-win32.whl", hash = "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc"}, + {file = "regex-2024.7.24-cp312-cp312-win_amd64.whl", hash = "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:66b4c0731a5c81921e938dcf1a88e978264e26e6ac4ec96a4d21ae0354581ae0"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:88ecc3afd7e776967fa16c80f974cb79399ee8dc6c96423321d6f7d4b881c92b"}, + {file = "regex-2024.7.24-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:64bd50cf16bcc54b274e20235bf8edbb64184a30e1e53873ff8d444e7ac656b2"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb462f0e346fcf41a901a126b50f8781e9a474d3927930f3490f38a6e73b6950"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a82465ebbc9b1c5c50738536fdfa7cab639a261a99b469c9d4c7dcbb2b3f1e57"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68a8f8c046c6466ac61a36b65bb2395c74451df2ffb8458492ef49900efed293"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac8e84fff5d27420f3c1e879ce9929108e873667ec87e0c8eeb413a5311adfe"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba2537ef2163db9e6ccdbeb6f6424282ae4dea43177402152c67ef869cf3978b"}, + {file = "regex-2024.7.24-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:43affe33137fcd679bdae93fb25924979517e011f9dea99163f80b82eadc7e53"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:c9bb87fdf2ab2370f21e4d5636e5317775e5d51ff32ebff2cf389f71b9b13750"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:945352286a541406f99b2655c973852da7911b3f4264e010218bbc1cc73168f2"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:8bc593dcce679206b60a538c302d03c29b18e3d862609317cb560e18b66d10cf"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3f3b6ca8eae6d6c75a6cff525c8530c60e909a71a15e1b731723233331de4169"}, + {file = "regex-2024.7.24-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c51edc3541e11fbe83f0c4d9412ef6c79f664a3745fab261457e84465ec9d5a8"}, + {file = "regex-2024.7.24-cp38-cp38-win32.whl", hash = "sha256:d0a07763776188b4db4c9c7fb1b8c494049f84659bb387b71c73bbc07f189e96"}, + {file = "regex-2024.7.24-cp38-cp38-win_amd64.whl", hash = "sha256:8fd5afd101dcf86a270d254364e0e8dddedebe6bd1ab9d5f732f274fa00499a5"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0ffe3f9d430cd37d8fa5632ff6fb36d5b24818c5c986893063b4e5bdb84cdf24"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25419b70ba00a16abc90ee5fce061228206173231f004437730b67ac77323f0d"}, + {file = "regex-2024.7.24-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33e2614a7ce627f0cdf2ad104797d1f68342d967de3695678c0cb84f530709f8"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d33a0021893ede5969876052796165bab6006559ab845fd7b515a30abdd990dc"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04ce29e2c5fedf296b1a1b0acc1724ba93a36fb14031f3abfb7abda2806c1535"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b16582783f44fbca6fcf46f61347340c787d7530d88b4d590a397a47583f31dd"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:836d3cc225b3e8a943d0b02633fb2f28a66e281290302a79df0e1eaa984ff7c1"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:438d9f0f4bc64e8dea78274caa5af971ceff0f8771e1a2333620969936ba10be"}, + {file = "regex-2024.7.24-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:973335b1624859cb0e52f96062a28aa18f3a5fc77a96e4a3d6d76e29811a0e6e"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c5e69fd3eb0b409432b537fe3c6f44ac089c458ab6b78dcec14478422879ec5f"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ae2757ace61bc4061b69af19e4689fa4416e1a04840f33b441034202b5cd02d4"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:44fc61b99035fd9b3b9453f1713234e5a7c92a04f3577252b45feefe1b327759"}, + {file = "regex-2024.7.24-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:84c312cdf839e8b579f504afcd7b65f35d60b6285d892b19adea16355e8343c9"}, + {file = "regex-2024.7.24-cp39-cp39-win32.whl", hash = "sha256:ca5b2028c2f7af4e13fb9fc29b28d0ce767c38c7facdf64f6c2cd040413055f1"}, + {file = "regex-2024.7.24-cp39-cp39-win_amd64.whl", hash = "sha256:7c479f5ae937ec9985ecaf42e2e10631551d909f203e31308c12d703922742f9"}, + {file = "regex-2024.7.24.tar.gz", hash = "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506"}, ] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -1844,19 +1909,19 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "setuptools" -version = "69.5.1" +version = "72.1.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, + {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"}, + {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +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"] [[package]] name = "six" @@ -1938,7 +2003,7 @@ files = [ name = "tree-sitter" version = "0.20.4" description = "Python bindings for the Tree-Sitter parsing library" -optional = true +optional = false python-versions = ">=3.3" files = [ {file = "tree_sitter-0.20.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c259b9bcb596e54f54713eb3951226fc834d65289940f4bfdcdf519f08e8e876"}, @@ -2117,27 +2182,27 @@ files = [ [[package]] name = "types-tree-sitter-languages" -version = "1.10.0.20240201" +version = "1.10.0.20240612" description = "Typing stubs for tree-sitter-languages" optional = false python-versions = ">=3.8" files = [ - {file = "types-tree-sitter-languages-1.10.0.20240201.tar.gz", hash = "sha256:10822bc9d2b98f7e8019a97f0233c68555d5c447ba4ef24284e93fd866ec73de"}, - {file = "types_tree_sitter_languages-1.10.0.20240201-py3-none-any.whl", hash = "sha256:3cb72f9df4c9b92a8710f0c1966de2d2295584b7cbea3194d0ba577fa50be56c"}, + {file = "types-tree-sitter-languages-1.10.0.20240612.tar.gz", hash = "sha256:e9e4e53e8a94cf1438051e85c92e0fb20470c6ec702a6faa424a9bb2022fb5cc"}, + {file = "types_tree_sitter_languages-1.10.0.20240612-py3-none-any.whl", hash = "sha256:5d654de047f7e5f7e66fa39ae0bf13f78f981e74dda2afd4a9d178a3b1dc3fc4"}, ] [package.dependencies] -types-tree-sitter = "*" +tree-sitter = ">=0.20.3" [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -2167,13 +2232,13 @@ test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "urllib3" -version = "2.2.1" +version = "2.2.2" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, + {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, ] [package.extras] @@ -2184,13 +2249,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.2" +version = "20.26.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"}, - {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"}, + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, ] [package.dependencies] @@ -2204,40 +2269,46 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "watchdog" -version = "4.0.0" +version = "4.0.2" description = "Filesystem events monitoring" optional = false python-versions = ">=3.8" files = [ - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, - {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, - {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, - {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, - {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, - {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, - {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, - {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, - {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, - {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, - {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, - {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, - {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, - {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, + {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ede7f010f2239b97cc79e6cb3c249e72962404ae3865860855d5cbe708b0fd22"}, + {file = "watchdog-4.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2cffa171445b0efa0726c561eca9a27d00a1f2b83846dbd5a4f639c4f8ca8e1"}, + {file = "watchdog-4.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c50f148b31b03fbadd6d0b5980e38b558046b127dc483e5e4505fcef250f9503"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c7d4bf585ad501c5f6c980e7be9c4f15604c7cc150e942d82083b31a7548930"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:914285126ad0b6eb2258bbbcb7b288d9dfd655ae88fa28945be05a7b475a800b"}, + {file = "watchdog-4.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:984306dc4720da5498b16fc037b36ac443816125a3705dfde4fd90652d8028ef"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1cdcfd8142f604630deef34722d695fb455d04ab7cfe9963055df1fc69e6727a"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d7ab624ff2f663f98cd03c8b7eedc09375a911794dfea6bf2a359fcc266bff29"}, + {file = "watchdog-4.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:132937547a716027bd5714383dfc40dc66c26769f1ce8a72a859d6a48f371f3a"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd67c7df93eb58f360c43802acc945fa8da70c675b6fa37a241e17ca698ca49b"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcfd02377be80ef3b6bc4ce481ef3959640458d6feaae0bd43dd90a43da90a7d"}, + {file = "watchdog-4.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:980b71510f59c884d684b3663d46e7a14b457c9611c481e5cef08f4dd022eed7"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:aa160781cafff2719b663c8a506156e9289d111d80f3387cf3af49cedee1f040"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f6ee8dedd255087bc7fe82adf046f0b75479b989185fb0bdf9a98b612170eac7"}, + {file = "watchdog-4.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0b4359067d30d5b864e09c8597b112fe0a0a59321a0f331498b013fb097406b4"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:770eef5372f146997638d737c9a3c597a3b41037cfbc5c41538fc27c09c3a3f9"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eeea812f38536a0aa859972d50c76e37f4456474b02bd93674d1947cf1e39578"}, + {file = "watchdog-4.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b2c45f6e1e57ebb4687690c05bc3a2c1fb6ab260550c4290b8abb1335e0fd08b"}, + {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:10b6683df70d340ac3279eff0b2766813f00f35a1d37515d2c99959ada8f05fa"}, + {file = "watchdog-4.0.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7c739888c20f99824f7aa9d31ac8a97353e22d0c0e54703a547a218f6637eb3"}, + {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c100d09ac72a8a08ddbf0629ddfa0b8ee41740f9051429baa8e31bb903ad7508"}, + {file = "watchdog-4.0.2-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:f5315a8c8dd6dd9425b974515081fc0aadca1d1d61e078d2246509fd756141ee"}, + {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2d468028a77b42cc685ed694a7a550a8d1771bb05193ba7b24006b8241a571a1"}, + {file = "watchdog-4.0.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f15edcae3830ff20e55d1f4e743e92970c847bcddc8b7509bcd172aa04de506e"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:936acba76d636f70db8f3c66e76aa6cb5136a936fc2a5088b9ce1c7a3508fc83"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e252f8ca942a870f38cf785aef420285431311652d871409a64e2a0a52a2174c"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:0e83619a2d5d436a7e58a1aea957a3c1ccbf9782c43c0b4fed80580e5e4acd1a"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:88456d65f207b39f1981bf772e473799fcdc10801062c36fd5ad9f9d1d463a73"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:32be97f3b75693a93c683787a87a0dc8db98bb84701539954eef991fb35f5fbc"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:c82253cfc9be68e3e49282831afad2c1f6593af80c0daf1287f6a92657986757"}, + {file = "watchdog-4.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c0b14488bd336c5b1845cee83d3e631a1f8b4e9c5091ec539406e4a324f882d8"}, + {file = "watchdog-4.0.2-py3-none-win32.whl", hash = "sha256:0d8a7e523ef03757a5aa29f591437d64d0d894635f8a50f370fe37f913ce4e19"}, + {file = "watchdog-4.0.2-py3-none-win_amd64.whl", hash = "sha256:c344453ef3bf875a535b0488e3ad28e341adbd5a9ffb0f7d62cefacc8824ef2b"}, + {file = "watchdog-4.0.2-py3-none-win_ia64.whl", hash = "sha256:baececaa8edff42cd16558a639a9b0ddf425f93d892e8392a56bf904f5eff22c"}, + {file = "watchdog-4.0.2.tar.gz", hash = "sha256:b4dfbb6c49221be4535623ea4474a4d6ee0a9cef4a80b20c28db4d858b64e270"}, ] [package.extras] @@ -2348,18 +2419,18 @@ multidict = ">=4.0" [[package]] name = "zipp" -version = "3.18.1" +version = "3.20.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, - {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, + {file = "zipp-3.20.0-py3-none-any.whl", hash = "sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d"}, + {file = "zipp-3.20.0.tar.gz", hash = "sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] +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)"] [extras] syntax = ["tree-sitter", "tree-sitter-languages"] @@ -2367,4 +2438,4 @@ syntax = ["tree-sitter", "tree-sitter-languages"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "6c6e84b0f622a308223bfc9fecd294a36a5f63a728c770380bb987a8971d14f1" +content-hash = "e0b88042ca78f41105061623e79496bd575a8a0be26f8bedde8823cf35f40956" diff --git a/pyproject.toml b/pyproject.toml index c790401fcd..f59a5b9323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,6 @@ tree-sitter = { version = "^0.20.1", optional = true } tree-sitter-languages = { version = "1.10.2", optional = true } platformdirs = "^4.2.2" msgpack = "^1.0.8" -msgpack-types = "^0.3.0" [tool.poetry.extras] syntax = ["tree-sitter", "tree_sitter_languages"] @@ -77,6 +76,7 @@ types-tree-sitter = "^0.20.1.4" types-tree-sitter-languages = "^1.7.0.1" isort = "^5.13.2" pytest-textual-snapshot = "^1.0.0" +msgpack-types = "^0.3.0" [tool.pytest.ini_options] asyncio_mode = "auto" From 485f475bf91015196c2f5377ea06127e78e4a822 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 12 Aug 2024 16:10:26 +0100 Subject: [PATCH 23/80] PR feedback --- src/textual/app.py | 23 +++++++++++++++++------ src/textual/drivers/web_driver.py | 16 +++++----------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b0bd50bca9..c569d76af8 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3697,6 +3697,7 @@ def deliver_text( path_or_file: str | Path | TextIO, *, save_directory: str | Path | None = None, + save_filename: str | None = None, open_method: Literal["browser", "download"] = "download", encoding: str | None = None, mime_type: str | None = None, @@ -3714,9 +3715,10 @@ def deliver_text( Args: path_or_file: The path or file-like object to save. - save_location: The directory to save the file to. If path_or_file + save_directory: The directory to save the file to. + save_filename: The filename to save the file to. If `path_or_file` is a file-like object, the filename will be generated from - the `name` attribute if available. If path_or_file is a path + the `name` attribute if available. If `path_or_file` is a path the filename will be generated from the path. encoding: The encoding to use when saving the file. If `None`, the encoding will be determined by supplied file-like object @@ -3729,6 +3731,7 @@ def deliver_text( self._deliver_binary( path_or_file, save_directory=save_directory, + save_filename=save_filename, open_method=open_method, encoding=encoding, mime_type=mime_type, @@ -3739,6 +3742,7 @@ def deliver_binary( path_or_file: str | Path | BinaryIO, *, save_directory: str | Path | None = None, + save_filename: str | None = None, open_method: Literal["browser", "download"] = "download", mime_type: str | None = None, ) -> None: @@ -3761,6 +3765,10 @@ def deliver_binary( save_directory: The directory to save the file to. If None, the default "downloads" directory will be used. This argument is ignored when running via the web. + save_filename: The filename to save the file to. If `path_or_file` + is a file-like object, the filename will be generated from + the `name` attribute if available. If `path_or_file` is a path + the filename will be generated from the path. open_method: The method to use to open the file. "browser" will open the file in the web browser, "download" will initiate a download. Note that this can sometimes be impacted by the browser's settings. @@ -3768,12 +3776,15 @@ def deliver_binary( If no MIME type is supplied and we cannot guess the MIME type, from the file extension, the MIME type will be set to "application/octet-stream". """ - self._deliver_binary(path_or_file, save_directory, open_method, mime_type) + self._deliver_binary( + path_or_file, save_directory, save_filename, open_method, mime_type + ) def _deliver_binary( self, path_or_file: str | Path | BinaryIO | TextIO, save_directory: str | Path | None, + save_filename: str | None, open_method: Literal["browser", "download"], encoding: str | None = None, mime_type: str | None = None, @@ -3786,14 +3797,14 @@ def _deliver_binary( if isinstance(path_or_file, (str, Path)): binary_path = Path(path_or_file) binary = binary_path.open("rb") - file_name = binary_path.name + file_name = save_filename or binary_path.name if not mime_type: mime_type, _ = mimetypes.guess_type(file_name) if mime_type is None: mime_type = "application/octet-stream" elif isinstance(path_or_file, TextIO): binary = path_or_file.buffer - file_name = getattr(binary, "name", None) + file_name = save_filename or getattr(binary, "name", None) # If we have a filename and no MIME type, try to guess the MIME type. if file_name and not mime_type: mime_type, _ = mimetypes.guess_type(file_name) @@ -3801,7 +3812,7 @@ def _deliver_binary( mime_type = "text/plain" else: # isinstance(path_or_file, BinaryIO): binary = path_or_file - file_name = getattr(binary, "name", None) + file_name = save_filename or getattr(binary, "name", None) # If we have a filename and no MIME type, try to guess the MIME type. if file_name and not mime_type: mime_type, _ = mimetypes.guess_type(file_name) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 53efda5f97..a2250469be 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -20,7 +20,7 @@ from codecs import getincrementaldecoder from functools import partial from pathlib import Path -from threading import Event, Lock, Thread +from threading import Event, Thread from typing import Any, BinaryIO, Literal, cast import msgpack @@ -67,7 +67,6 @@ def __init__( self._key_thread: Thread = Thread(target=self.run_input_thread) self._input_reader = InputReader() - self._deliveries_lock = Lock() self._deliveries: dict[str, BinaryIO] = {} """Maps delivery keys to file-like objects, used for delivering files to the browser.""" @@ -252,12 +251,10 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: return deliveries = self._deliveries - deliveries_lock = self._deliveries_lock binary_io: BinaryIO | None = None try: - with deliveries_lock: - binary_io = deliveries[delivery_key] + binary_io = deliveries[delivery_key] except KeyError: log.error( f"Protocol error: deliver_chunk_request invalid key {delivery_key!r}" @@ -272,12 +269,10 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: if not chunk: log.info(f"Delivery complete for {delivery_key}") binary_io.close() - with deliveries_lock: - del deliveries[delivery_key] + del deliveries[delivery_key] except Exception: binary_io.close() - with deliveries_lock: - del deliveries[delivery_key] + del deliveries[delivery_key] log.error( f"Error delivering file chunk for key {delivery_key!r}. " @@ -328,8 +323,7 @@ def _deliver_file( # Generate a unique key for this delivery key = str(uuid.uuid4().hex) - with self._deliveries_lock: - self._deliveries[key] = binary + self._deliveries[key] = binary # Inform the server that we're starting a new file delivery meta: dict[str, object] = { From 700b0147e2c64210266cc875095aa55ca8f79eca Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 12 Aug 2024 16:25:22 +0100 Subject: [PATCH 24/80] Fixing a docstring --- src/textual/app.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index c569d76af8..b1b51232f2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3765,10 +3765,13 @@ def deliver_binary( save_directory: The directory to save the file to. If None, the default "downloads" directory will be used. This argument is ignored when running via the web. - save_filename: The filename to save the file to. If `path_or_file` - is a file-like object, the filename will be generated from - the `name` attribute if available. If `path_or_file` is a path - the filename will be generated from the path. + save_filename: The filename to save the file to. If None, the following logic + applies to generate the filename: + - If `path_or_file` is a file-like object, the filename will be taken from + the `name` attribute if available. + - If `path_or_file` is a path, the filename will be taken from the path. + - If a filename is not available, a filename will be generated using the + App's title and the current date and time. open_method: The method to use to open the file. "browser" will open the file in the web browser, "download" will initiate a download. Note that this can sometimes be impacted by the browser's settings. From 963e0dab46f8f4a46cf70cb2f6e77444fa06d002 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 13 Aug 2024 11:55:40 +0100 Subject: [PATCH 25/80] Extracting some logic --- pyproject.toml | 2 +- src/textual/app.py | 86 +++++++++++++++++++++++++++------------------- 2 files changed, 51 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f59a5b9323..90d95c6ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.76.0" +version = "0.76.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/app.py b/src/textual/app.py index 2b6e57a1b8..73cd3bb6f9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3733,16 +3733,33 @@ def deliver_text( the filename will be generated from the path. encoding: The encoding to use when saving the file. If `None`, the encoding will be determined by supplied file-like object - (if possible). If this is not possible, the encoding of the - current locale will be used. + (if possible). If this is not possible, 'utf-8' will be used. mime_type: The MIME type of the file or None to guess based on file extension. If no MIME type is supplied and we cannot guess the MIME type, from the file extension, the MIME type will be set to "text/plain". """ + # Ensure `path_or_file` is a file-like object - convert if needed. + if isinstance(path_or_file, (str, Path)): + binary_path = Path(path_or_file) + binary = binary_path.open("rb") + file_name = save_filename or binary_path.name + else: + encoding = encoding or getattr(path_or_file, "encoding", None) or "utf-8" + binary = path_or_file + file_name = save_filename or getattr(path_or_file, "name", None) + + # If we could infer a filename, and no MIME type was supplied, guess the MIME type. + if file_name and not mime_type: + mime_type, _ = mimetypes.guess_type(file_name) + + # Still no MIME type? Default it to "text/plain". + if mime_type is None: + mime_type = "text/plain" + self._deliver_binary( - path_or_file, + binary, save_directory=save_directory, - save_filename=save_filename, + save_filename=file_name, open_method=open_method, encoding=encoding, mime_type=mime_type, @@ -3759,7 +3776,7 @@ def deliver_binary( ) -> None: """Deliver a binary file to the end-user of the application. - If a BinaryIO object is supplied, it will be closed by this method + If an IO object is supplied, it will be closed by this method and *must not be used* after it is supplied to this method. If running in a terminal, this will save the file to the user's @@ -3790,13 +3807,36 @@ def deliver_binary( If no MIME type is supplied and we cannot guess the MIME type, from the file extension, the MIME type will be set to "application/octet-stream". """ + # Ensure `path_or_file` is a file-like object - convert if needed. + if isinstance(path_or_file, (str, Path)): + binary_path = Path(path_or_file) + binary = binary_path.open("rb") + file_name = save_filename or binary_path.name + else: # IO object + binary = path_or_file + file_name = save_filename or getattr(path_or_file, "name", None) + + # If we could infer a filename, and no MIME type was supplied, guess the MIME type. + if file_name and not mime_type: + mime_type, _ = mimetypes.guess_type(file_name) + + # Still no MIME type? Default it to "application/octet-stream". + if mime_type is None: + mime_type = "application/octet-stream" + self._deliver_binary( - path_or_file, save_directory, save_filename, open_method, mime_type + binary, + save_directory=save_directory, + save_filename=file_name, + open_method=open_method, + mime_type=mime_type, + encoding=None, ) def _deliver_binary( self, - path_or_file: str | Path | BinaryIO | TextIO, + binary: BinaryIO, + *, save_directory: str | Path | None, save_filename: str | None, open_method: Literal["browser", "download"], @@ -3807,35 +3847,9 @@ def _deliver_binary( if self._driver is None: return - # Ensure `path_or_file` is a file-like object - convert if needed. - if isinstance(path_or_file, (str, Path)): - binary_path = Path(path_or_file) - binary = binary_path.open("rb") - file_name = save_filename or binary_path.name - if not mime_type: - mime_type, _ = mimetypes.guess_type(file_name) - if mime_type is None: - mime_type = "application/octet-stream" - elif isinstance(path_or_file, TextIO): - binary = path_or_file.buffer - file_name = save_filename or getattr(binary, "name", None) - # If we have a filename and no MIME type, try to guess the MIME type. - if file_name and not mime_type: - mime_type, _ = mimetypes.guess_type(file_name) - if mime_type is None: - mime_type = "text/plain" - else: # isinstance(path_or_file, BinaryIO): - binary = path_or_file - file_name = save_filename or getattr(binary, "name", None) - # If we have a filename and no MIME type, try to guess the MIME type. - if file_name and not mime_type: - mime_type, _ = mimetypes.guess_type(file_name) - if mime_type is None: - mime_type = "application/octet-stream" - # Generate a filename if the file-like object doesn't have one. - if not file_name: - file_name = generate_datetime_filename(self.title, "") + if save_filename is None: + save_filename = generate_datetime_filename(self.title, "") # Find the appropriate save location if not specified. save_directory = ( @@ -3847,7 +3861,7 @@ def _deliver_binary( # sending the file to the web browser for download. self._driver.deliver_binary( binary, - save_path=save_directory / file_name, + save_path=save_directory / save_filename, encoding=encoding, open_method=open_method, mime_type=mime_type, From 88f06bb02f7a2eef8d5398341e11fb4a16ab53c7 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 14 Aug 2024 09:27:37 +0100 Subject: [PATCH 26/80] Updating deliver_text|binary interface --- src/textual/app.py | 2 +- src/textual/driver.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 73cd3bb6f9..c9210f9742 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3835,7 +3835,7 @@ def deliver_binary( def _deliver_binary( self, - binary: BinaryIO, + binary: BinaryIO | TextIO, *, save_directory: str | Path | None, save_filename: str | None, diff --git a/src/textual/driver.py b/src/textual/driver.py index 769884a33d..9a7fecb271 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal +from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal, TextIO from . import events from .events import MouseUp @@ -201,7 +201,7 @@ def open_url(self, url: str, new_tab: bool = True) -> None: def deliver_binary( self, - binary: BinaryIO, + binary: BinaryIO | TextIO, *, save_path: Path, open_method: Literal["browser", "download"] = "download", @@ -228,6 +228,11 @@ def deliver_binary( mime_type: *web only* The MIME type of the file. This will be used to set the `Content-Type` header in the HTTP response. """ - with open(save_path, "wb") as destination_file: - shutil.copyfileobj(binary, destination_file) + if isinstance(binary, BinaryIO): + with open(save_path, "wb") as destination_file: + shutil.copyfileobj(binary, destination_file) + else: # isinstance(binary, TextIO): + with open(save_path, "w", encoding=encoding) as destination_file: + shutil.copyfileobj(binary, destination_file) + binary.close() From 65e1b9ddad769ab0a238e6720a9213e7ce0fe512 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 14 Aug 2024 09:35:23 +0100 Subject: [PATCH 27/80] Updating type hints --- src/textual/drivers/web_driver.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index a2250469be..58ad83254e 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -21,7 +21,7 @@ from functools import partial from pathlib import Path from threading import Event, Thread -from typing import Any, BinaryIO, Literal, cast +from typing import Any, BinaryIO, Literal, TextIO, cast import msgpack @@ -67,7 +67,7 @@ def __init__( self._key_thread: Thread = Thread(target=self.run_input_thread) self._input_reader = InputReader() - self._deliveries: dict[str, BinaryIO] = {} + self._deliveries: dict[str, BinaryIO | TextIO] = {} """Maps delivery keys to file-like objects, used for delivering files to the browser.""" @@ -252,7 +252,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: deliveries = self._deliveries - binary_io: BinaryIO | None = None + binary_io: BinaryIO | TextIO | None = None try: binary_io = deliveries[delivery_key] except KeyError: @@ -293,7 +293,7 @@ def open_url(self, url: str, new_tab: bool = True) -> None: def deliver_binary( self, - binary: BinaryIO, + binary: BinaryIO | TextIO, *, save_path: Path, open_method: Literal["browser", "download"] = "download", @@ -310,7 +310,7 @@ def deliver_binary( def _deliver_file( self, - binary: BinaryIO, + binary: BinaryIO | TextIO, *, save_path: Path, open_method: Literal["browser", "download"], From db4e2d5b66f39ca81927e58ba3a3380ee1a4ecfb Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 14 Aug 2024 09:43:49 +0100 Subject: [PATCH 28/80] Update typing --- src/textual/drivers/web_driver.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 58ad83254e..c1efdae5ba 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -252,26 +252,26 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: deliveries = self._deliveries - binary_io: BinaryIO | TextIO | None = None + file_like: BinaryIO | TextIO | None = None try: - binary_io = deliveries[delivery_key] + file_like = deliveries[delivery_key] except KeyError: log.error( f"Protocol error: deliver_chunk_request invalid key {delivery_key!r}" ) else: - # Read the requested number of bytes from the file + # Read the requested amount of data from the file try: log.debug(f"Reading {requested_size} bytes from {delivery_key}") - chunk = binary_io.read(requested_size) + chunk = file_like.read(requested_size) log.debug(f"Delivering chunk {delivery_key!r} of len {len(chunk)}") self.write_packed(("deliver_chunk", delivery_key, chunk)) if not chunk: log.info(f"Delivery complete for {delivery_key}") - binary_io.close() + file_like.close() del deliveries[delivery_key] except Exception: - binary_io.close() + file_like.close() del deliveries[delivery_key] log.error( From bd7113c6a4000c9013edfb3297e7efb2b0a91f3c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 15 Aug 2024 10:50:34 +0100 Subject: [PATCH 29/80] Fix version number --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90d95c6ec9..f59a5b9323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.76.1" +version = "0.76.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 3dba6b94235166ad328d7e3d82473b62048024f8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 20 Aug 2024 11:00:03 +0100 Subject: [PATCH 30/80] Threaded delivery when not on web --- src/textual/driver.py | 31 +++++++++++++++++++++++-------- src/textual/events.py | 9 +++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/textual/driver.py b/src/textual/driver.py index 9a7fecb271..fe26f11db1 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -import shutil +import threading from abc import ABC, abstractmethod from contextlib import contextmanager from pathlib import Path @@ -229,10 +229,25 @@ def deliver_binary( set the `Content-Type` header in the HTTP response. """ if isinstance(binary, BinaryIO): - with open(save_path, "wb") as destination_file: - shutil.copyfileobj(binary, destination_file) - else: # isinstance(binary, TextIO): - with open(save_path, "w", encoding=encoding) as destination_file: - shutil.copyfileobj(binary, destination_file) - - binary.close() + mode = "wb" + else: + mode = "w" + + def save_file_thread(): + with open(save_path, mode) as destination_file: + read = binary.read + write = destination_file.write + while True: + data = read(1024) + if not data: + break + write(data) + binary.close() + self._app.call_from_thread(self._delivery_complete, save_path=save_path) + + thread = threading.Thread(target=save_file_thread) + thread.start() + + def _delivery_complete(self, save_path: Path) -> None: + """Called when a file has been delivered.""" + self._app.post_message(events.DeliveryComplete(save_path=save_path)) diff --git a/src/textual/events.py b/src/textual/events.py index 13188d6845..fb7ad263d6 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -14,6 +14,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Type, TypeVar import rich.repr @@ -732,3 +733,11 @@ def __init__(self, text: str, stderr: bool = False) -> None: def __rich_repr__(self) -> rich.repr.Result: yield self.text yield self.stderr + + +@dataclass +class DeliveryComplete(Event, bubble=False): + """Sent to App when a file has been delivered.""" + + save_path: Path + """The save_path to the file that was delivered.""" From 0b28fcf7d15e45d068b13af42ec8f957eb65afa4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 20 Aug 2024 13:18:48 +0100 Subject: [PATCH 31/80] Update docstring --- src/textual/drivers/web_driver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index c1efdae5ba..5b27c03eaf 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -73,7 +73,7 @@ def __init__( def write(self, data: str) -> None: """Write string data to the output device, which may be piped to - the controlling process (i.e. textual-web). + the parent process (i.e. textual-web/textual-serve). Args: data: Raw data. @@ -84,7 +84,7 @@ def write(self, data: str) -> None: def write_meta(self, data: dict[str, object]) -> None: """Write a dictionary containing some metadata to stdout, which - may be piped to the controlling process (i.e. textual-web). + may be piped to the parent process (i.e. textual-web/textual-serve). Args: data: Meta dict. From 124a17a6e47860a8eb8b9634930d767036a95175 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 20 Aug 2024 13:42:12 +0100 Subject: [PATCH 32/80] Returning the delivery key to the caller of App.deliver_text/delivery_binary --- src/textual/app.py | 33 ++++++++++++++++++++++++++----- src/textual/driver.py | 10 +++++++--- src/textual/drivers/web_driver.py | 15 ++++++++------ src/textual/events.py | 5 ++--- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index c9210f9742..697f4ef340 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -16,6 +16,7 @@ import signal import sys import threading +import uuid import warnings from asyncio import Task, create_task from concurrent.futures import Future @@ -3712,7 +3713,7 @@ def deliver_text( open_method: Literal["browser", "download"] = "download", encoding: str | None = None, mime_type: str | None = None, - ) -> None: + ) -> str | None: """Deliver a text file to the end-user of the application. If a TextIO object is supplied, it will be closed by this method @@ -3724,6 +3725,11 @@ def deliver_text( If running via a web browser, this will initiate a download via a single-use URL. + After the file has been delivered, a `DeliveryComplete` message will be posted + to this `App`, which contains the `delivery_key` returned by this method. By + handling this message, you can add custom logic to your application that fires + only after the file has been delivered. + Args: path_or_file: The path or file-like object to save. save_directory: The directory to save the file to. @@ -3737,6 +3743,9 @@ def deliver_text( mime_type: The MIME type of the file or None to guess based on file extension. If no MIME type is supplied and we cannot guess the MIME type, from the file extension, the MIME type will be set to "text/plain". + + Returns: + The delivery key that uniquely identifies the file delivery. """ # Ensure `path_or_file` is a file-like object - convert if needed. if isinstance(path_or_file, (str, Path)): @@ -3756,7 +3765,7 @@ def deliver_text( if mime_type is None: mime_type = "text/plain" - self._deliver_binary( + return self._deliver_binary( binary, save_directory=save_directory, save_filename=file_name, @@ -3773,7 +3782,7 @@ def deliver_binary( save_filename: str | None = None, open_method: Literal["browser", "download"] = "download", mime_type: str | None = None, - ) -> None: + ) -> str | None: """Deliver a binary file to the end-user of the application. If an IO object is supplied, it will be closed by this method @@ -3788,6 +3797,11 @@ def deliver_binary( This operation runs in a thread when running on web, so this method returning does not indicate that the file has been delivered. + After the file has been delivered, a `DeliveryComplete` message will be posted + to this `App`, which contains the `delivery_key` returned by this method. By + handling this message, you can add custom logic to your application that fires + only after the file has been delivered. + Args: path_or_file: The path or file-like object to save. save_directory: The directory to save the file to. If None, @@ -3806,6 +3820,9 @@ def deliver_binary( mime_type: The MIME type of the file or None to guess based on file extension. If no MIME type is supplied and we cannot guess the MIME type, from the file extension, the MIME type will be set to "application/octet-stream". + + Returns: + The delivery key that uniquely identifies the file delivery. """ # Ensure `path_or_file` is a file-like object - convert if needed. if isinstance(path_or_file, (str, Path)): @@ -3824,7 +3841,7 @@ def deliver_binary( if mime_type is None: mime_type = "application/octet-stream" - self._deliver_binary( + return self._deliver_binary( binary, save_directory=save_directory, save_filename=file_name, @@ -3842,7 +3859,7 @@ def _deliver_binary( open_method: Literal["browser", "download"], encoding: str | None = None, mime_type: str | None = None, - ) -> None: + ) -> str | None: """Deliver a binary file to the end-user of the application.""" if self._driver is None: return @@ -3856,13 +3873,19 @@ def _deliver_binary( user_downloads_path() if save_directory is None else Path(save_directory) ) + # Generate a unique key for this delivery + delivery_key = str(uuid.uuid4().hex) + # Save the file. The driver will determine the appropriate action # to take here. It could mean simply writing to the save_path, or # sending the file to the web browser for download. self._driver.deliver_binary( binary, + delivery_key=delivery_key, save_path=save_directory / save_filename, encoding=encoding, open_method=open_method, mime_type=mime_type, ) + + return delivery_key diff --git a/src/textual/driver.py b/src/textual/driver.py index fe26f11db1..dc5264e568 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -203,6 +203,7 @@ def deliver_binary( self, binary: BinaryIO | TextIO, *, + delivery_key: str, save_path: Path, open_method: Literal["browser", "download"] = "download", encoding: str | None = None, @@ -215,6 +216,7 @@ def deliver_binary( Args: binary: The binary file to save. + delivery_key: The unique key that was used to deliver the file. save_path: The location to save the file to. If None, the default "downloads" directory will be used. When running via web, only the file name will be used. @@ -243,11 +245,13 @@ def save_file_thread(): break write(data) binary.close() - self._app.call_from_thread(self._delivery_complete, save_path=save_path) + self._app.call_from_thread( + self._delivery_complete, delivery_key=delivery_key + ) thread = threading.Thread(target=save_file_thread) thread.start() - def _delivery_complete(self, save_path: Path) -> None: + def _delivery_complete(self, delivery_key: str) -> None: """Called when a file has been delivered.""" - self._app.post_message(events.DeliveryComplete(save_path=save_path)) + self._app.post_message(events.DeliveryComplete(delivery_key)) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 5b27c03eaf..d577e5ab00 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -16,7 +16,6 @@ import os import signal import sys -import uuid from codecs import getincrementaldecoder from functools import partial from pathlib import Path @@ -266,10 +265,14 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: chunk = file_like.read(requested_size) log.debug(f"Delivering chunk {delivery_key!r} of len {len(chunk)}") self.write_packed(("deliver_chunk", delivery_key, chunk)) + # We've hit an empty chunk, so we're done if not chunk: log.info(f"Delivery complete for {delivery_key}") file_like.close() del deliveries[delivery_key] + self._app.call_from_thread( + self._delivery_complete, delivery_key=delivery_key + ) except Exception: file_like.close() del deliveries[delivery_key] @@ -295,6 +298,7 @@ def deliver_binary( self, binary: BinaryIO | TextIO, *, + delivery_key: str, save_path: Path, open_method: Literal["browser", "download"] = "download", encoding: str | None = None, @@ -302,6 +306,7 @@ def deliver_binary( ) -> None: self._deliver_file( binary, + delivery_key=delivery_key, save_path=save_path, open_method=open_method, encoding=encoding, @@ -312,6 +317,7 @@ def _deliver_file( self, binary: BinaryIO | TextIO, *, + delivery_key: str, save_path: Path, open_method: Literal["browser", "download"], encoding: str | None = None, @@ -320,15 +326,12 @@ def _deliver_file( """Deliver a file to the end-user of the application.""" binary.seek(0) - # Generate a unique key for this delivery - key = str(uuid.uuid4().hex) - - self._deliveries[key] = binary + self._deliveries[delivery_key] = binary # Inform the server that we're starting a new file delivery meta: dict[str, object] = { "type": "deliver_file_start", - "key": key, + "key": delivery_key, "path": str(save_path.resolve()), "open_method": open_method, "encoding": encoding, diff --git a/src/textual/events.py b/src/textual/events.py index fb7ad263d6..45fb51a1eb 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -14,7 +14,6 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path from typing import TYPE_CHECKING, Type, TypeVar import rich.repr @@ -739,5 +738,5 @@ def __rich_repr__(self) -> rich.repr.Result: class DeliveryComplete(Event, bubble=False): """Sent to App when a file has been delivered.""" - save_path: Path - """The save_path to the file that was delivered.""" + key: str + """The key that was used to deliver the file.""" From f46e56b6298aaadb6646303bdca69888c69eebf9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 20 Aug 2024 15:21:21 +0100 Subject: [PATCH 33/80] Send empty strings instead of None to parent process --- src/textual/drivers/web_driver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index d577e5ab00..d39de7f2a7 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -334,8 +334,8 @@ def _deliver_file( "key": delivery_key, "path": str(save_path.resolve()), "open_method": open_method, - "encoding": encoding, - "mime_type": mime_type, + "encoding": encoding or "", + "mime_type": mime_type or "", } self.write_meta(meta) log.info(f"Delivering file {meta['path']!r}: {meta!r}") From 087f35390cf0535be67acd0c2a1f150955887770 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 20 Aug 2024 15:32:07 +0100 Subject: [PATCH 34/80] Use binary encoding instead of msgpack --- src/textual/drivers/web_driver.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index d39de7f2a7..4aa8b61764 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -22,9 +22,8 @@ from threading import Event, Thread from typing import Any, BinaryIO, Literal, TextIO, cast -import msgpack - from .. import events, log, messages +from .._binary_encode import dump as binary_dump from .._xterm_parser import XTermParser from ..app import App from ..driver import Driver @@ -91,13 +90,13 @@ def write_meta(self, data: dict[str, object]) -> None: meta_bytes = json.dumps(data).encode("utf-8", errors="ignore") self._write(b"M%s%s" % (len(meta_bytes).to_bytes(4, "big"), meta_bytes)) - def write_packed(self, data: tuple[str | bytes, ...]) -> None: - """Pack a msgpack compatible data-structure and write to stdout. + def write_binary_encoded(self, data: tuple[str | bytes, ...]) -> None: + """Binary encode a data-structure and write to stdout. Args: - data: The dictionary to pack and write. + data: The data to binary encode and write. """ - packed_bytes = msgpack.packb(data) + packed_bytes = binary_dump(data) self._write(b"P%s%s" % (len(packed_bytes).to_bytes(4, "big"), packed_bytes)) def flush(self) -> None: @@ -264,7 +263,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: log.debug(f"Reading {requested_size} bytes from {delivery_key}") chunk = file_like.read(requested_size) log.debug(f"Delivering chunk {delivery_key!r} of len {len(chunk)}") - self.write_packed(("deliver_chunk", delivery_key, chunk)) + self.write_binary_encoded(("deliver_chunk", delivery_key, chunk)) # We've hit an empty chunk, so we're done if not chunk: log.info(f"Delivery complete for {delivery_key}") From b2eddbe7a73f59ea1d90d5ba82aa47e2f750177d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 20 Aug 2024 15:32:34 +0100 Subject: [PATCH 35/80] Remove msgpack dependency --- poetry.lock | 2 +- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index b0e6e3dc24..ebce1a274b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2438,4 +2438,4 @@ syntax = ["tree-sitter", "tree-sitter-languages"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "e0b88042ca78f41105061623e79496bd575a8a0be26f8bedde8823cf35f40956" +content-hash = "ed240189b1039bfd1d6b34350c2ada54f25397bd315bff368c2704640a12cd04" diff --git a/pyproject.toml b/pyproject.toml index f59a5b9323..2fd978f0ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ typing-extensions = "^4.4.0" tree-sitter = { version = "^0.20.1", optional = true } tree-sitter-languages = { version = "1.10.2", optional = true } platformdirs = "^4.2.2" -msgpack = "^1.0.8" [tool.poetry.extras] syntax = ["tree-sitter", "tree_sitter_languages"] From 5bbfd49b3c9234a65fa53cb63dd89c5b5b58cc9f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 20 Aug 2024 16:02:53 +0100 Subject: [PATCH 36/80] Remove msgpack-types dependency --- poetry.lock | 13 +------------ pyproject.toml | 1 - 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index ebce1a274b..b840e36ff8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1273,17 +1273,6 @@ files = [ {file = "msgpack-1.0.8.tar.gz", hash = "sha256:95c02b0e27e706e48d0e5426d1710ca78e0f0628d6e89d5b5a5b91a5f12274f3"}, ] -[[package]] -name = "msgpack-types" -version = "0.3.0" -description = "Type stubs for msgpack" -optional = false -python-versions = "<4.0,>=3.7" -files = [ - {file = "msgpack_types-0.3.0-py3-none-any.whl", hash = "sha256:44933978973cf1f09a97fc830676b10ba848cc103d73628af5c57f14ec4d6e92"}, - {file = "msgpack_types-0.3.0.tar.gz", hash = "sha256:e14faa08ab56ce529738f04d585e601c90f415d11d84f8089883d00d731d1ebf"}, -] - [[package]] name = "multidict" version = "6.0.5" @@ -2438,4 +2427,4 @@ syntax = ["tree-sitter", "tree-sitter-languages"] [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "ed240189b1039bfd1d6b34350c2ada54f25397bd315bff368c2704640a12cd04" +content-hash = "a334bde26213e1cae0a4be69857cbbc17529058b67db2201d8bf1ca2e65dd855" diff --git a/pyproject.toml b/pyproject.toml index 2fd978f0ff..6e00bf5dc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,6 @@ types-tree-sitter = "^0.20.1.4" types-tree-sitter-languages = "^1.7.0.1" isort = "^5.13.2" pytest-textual-snapshot = "^1.0.0" -msgpack-types = "^0.3.0" [tool.pytest.ini_options] asyncio_mode = "auto" From 77a05fd507e82063870ad99277c17660b82de9ca Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 21 Aug 2024 11:09:51 +0100 Subject: [PATCH 37/80] Docstring --- src/textual/events.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/events.py b/src/textual/events.py index 45fb51a1eb..d7e0a41ff6 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -739,4 +739,7 @@ class DeliveryComplete(Event, bubble=False): """Sent to App when a file has been delivered.""" key: str - """The key that was used to deliver the file.""" + """The delivery key associated with the delivery. + + This is the same key that was returned by `App.deliver_text`/`App.deliver_binary`. + """ From 40450c0a9a88e909131fa8d139d2c189a4b0714d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 22 Aug 2024 10:56:33 +0100 Subject: [PATCH 38/80] Reading 64k per chunk --- .pre-commit-config.yaml | 4 ++-- src/textual/driver.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5724b5fb19..a19c92bd68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: hooks: - id: isort name: isort (python) - language_version: '3.11' + language_version: '3.8' args: ['--profile', 'black', '--filter-files'] - repo: https://github.com/psf/black rev: '24.1.1' @@ -31,6 +31,6 @@ repos: rev: v2.3.0 hooks: - id: pycln - language_version: '3.11' + language_version: '3.8' args: [--all] exclude: ^tests/snapshot_tests diff --git a/src/textual/driver.py b/src/textual/driver.py index dc5264e568..40c832b4bf 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -239,8 +239,9 @@ def save_file_thread(): with open(save_path, mode) as destination_file: read = binary.read write = destination_file.write + chunk_size = 1024 * 64 while True: - data = read(1024) + data = read(chunk_size) if not data: break write(data) From 8127286c9e3baeb2f9036afce0b01fc48d3e9ad1 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 22 Aug 2024 14:46:20 +0100 Subject: [PATCH 39/80] Handling errors --- src/textual/driver.py | 65 ++++++++++++++++++++++++++++--------------- src/textual/events.py | 17 +++++++++++ 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/textual/driver.py b/src/textual/driver.py index 40c832b4bf..5133d028c5 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -217,9 +217,7 @@ def deliver_binary( Args: binary: The binary file to save. delivery_key: The unique key that was used to deliver the file. - save_path: The location to save the file to. If None, - the default "downloads" directory will be used. When - running via web, only the file name will be used. + save_path: The location to save the file to. open_method: *web only* Whether to open the file in the browser or to prompt the user to download it. When running via a standard (non-web) terminal, this is ignored. @@ -230,29 +228,52 @@ def deliver_binary( mime_type: *web only* The MIME type of the file. This will be used to set the `Content-Type` header in the HTTP response. """ + + def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None: + try: + with open(save_path, mode) as destination_file: + read = binary.read + write = destination_file.write + chunk_size = 1024 * 64 + while True: + data = read(chunk_size) + if not data: + # No data left to read - delivery is complete. + self._delivery_complete(delivery_key, save_path) + break + write(data) + except Exception as error: + # If any exception occurs during the delivery, pass + # it on to the app via a DeliveryFailed event. + self._delivery_failed(delivery_key, exception=error) + finally: + if not binary.closed: + binary.close() + if isinstance(binary, BinaryIO): mode = "wb" else: mode = "w" - def save_file_thread(): - with open(save_path, mode) as destination_file: - read = binary.read - write = destination_file.write - chunk_size = 1024 * 64 - while True: - data = read(chunk_size) - if not data: - break - write(data) - binary.close() - self._app.call_from_thread( - self._delivery_complete, delivery_key=delivery_key - ) - - thread = threading.Thread(target=save_file_thread) + thread = threading.Thread(target=save_file_thread, args=(binary, mode)) thread.start() - def _delivery_complete(self, delivery_key: str) -> None: - """Called when a file has been delivered.""" - self._app.post_message(events.DeliveryComplete(delivery_key)) + def _delivery_complete(self, delivery_key: str, save_path: Path | None) -> None: + """Called when a file has been delivered successfully. + + Delivers a DeliveryComplete event to the app. + """ + self._app.call_from_thread( + self._app.post_message, + events.DeliveryComplete(key=delivery_key, path=save_path), + ) + + def _delivery_failed(self, delivery_key: str, exception: BaseException) -> None: + """Called when a file delivery fails. + + Delivers a DeliveryFailed event to the app. + """ + self._app.call_from_thread( + self._app.post_message, + events.DeliveryFailed(key=delivery_key, exception=exception), + ) diff --git a/src/textual/events.py b/src/textual/events.py index d7e0a41ff6..d676ef2051 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -14,6 +14,7 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Type, TypeVar import rich.repr @@ -743,3 +744,19 @@ class DeliveryComplete(Event, bubble=False): This is the same key that was returned by `App.deliver_text`/`App.deliver_binary`. """ + + path: Path | None = None + """The path where the file was saved, or `None` if the path is not available, for + example if the file was delivered via web browser. + """ + + +@dataclass +class DeliveryFailed(Event, bubble=False): + """Sent to App when a file delivery fails.""" + + key: str + """The delivery key associated with the delivery.""" + + exception: BaseException + """The exception that was raised during the delivery.""" From 915e05cd17187de7ea18b8681a57253f3d885d92 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 22 Aug 2024 15:13:45 +0100 Subject: [PATCH 40/80] Logging file delivery error --- src/textual/driver.py | 6 +++++- src/textual/drivers/web_driver.py | 8 ++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/textual/driver.py b/src/textual/driver.py index 5133d028c5..2c3af34244 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal, TextIO -from . import events +from . import events, log from .events import MouseUp if TYPE_CHECKING: @@ -245,6 +245,10 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None: except Exception as error: # If any exception occurs during the delivery, pass # it on to the app via a DeliveryFailed event. + log.error(f"Failed to deliver file: {error}") + import traceback + + log.error(str(traceback.format_exc())) self._delivery_failed(delivery_key, exception=error) finally: if not binary.closed: diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 4aa8b61764..a50d5618e1 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -269,10 +269,8 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: log.info(f"Delivery complete for {delivery_key}") file_like.close() del deliveries[delivery_key] - self._app.call_from_thread( - self._delivery_complete, delivery_key=delivery_key - ) - except Exception: + self._delivery_complete(delivery_key, save_path=None) + except Exception as error: file_like.close() del deliveries[delivery_key] @@ -284,6 +282,8 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: log.error(str(traceback.format_exc())) + self._delivery_failed(delivery_key, exception=error) + def open_url(self, url: str, new_tab: bool = True) -> None: """Open a URL in the default web browser. From 011ac1df5980a8465c87bd77f9e02511d5ed28b7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 14:45:24 +0100 Subject: [PATCH 41/80] Faster query_one --- docs/guide/queries.md | 1 - src/textual/_node_list.py | 21 ++++++--- src/textual/css/model.py | 5 +++ src/textual/dom.py | 89 +++++++++++++++++++++++++++++++++++---- src/textual/widget.py | 11 ++--- tests/test_query.py | 2 +- 6 files changed, 108 insertions(+), 21 deletions(-) diff --git a/docs/guide/queries.md b/docs/guide/queries.md index d33659f382..c0ce0be51f 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -21,7 +21,6 @@ send_button = self.query_one("#send") This will retrieve a widget with an ID of `send`, if there is exactly one. If there are no matching widgets, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception. -If there is more than one match, Textual will raise a [TooManyMatches][textual.css.query.TooManyMatches] exception. You can also add a second parameter for the expected type, which will ensure that you get the type you are expecting. diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 198558777d..52555f9d61 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from _typeshed import SupportsRichComparison + from .dom import DOMNode from .widget import Widget @@ -24,7 +25,8 @@ class NodeList(Sequence["Widget"]): Although named a list, widgets may appear only once, making them more like a set. """ - def __init__(self) -> None: + def __init__(self, parent: DOMNode | None = None) -> None: + self._parent = parent # The nodes in the list self._nodes: list[Widget] = [] self._nodes_set: set[Widget] = set() @@ -52,6 +54,13 @@ def __len__(self) -> int: def __contains__(self, widget: object) -> bool: return widget in self._nodes + def updated(self) -> None: + """Mark the nodes as having been updated.""" + self._updates += 1 + node = self._parent + while node is not None and (node := node._parent) is not None: + node._nodes._updates += 1 + def _sort( self, *, @@ -69,7 +78,7 @@ def _sort( else: self._nodes.sort(key=key, reverse=reverse) - self._updates += 1 + self.updated() def index(self, widget: Any, start: int = 0, stop: int = sys.maxsize) -> int: """Return the index of the given widget. @@ -102,7 +111,7 @@ def _append(self, widget: Widget) -> None: if widget_id is not None: self._ensure_unique_id(widget_id) self._nodes_by_id[widget_id] = widget - self._updates += 1 + self.updated() def _insert(self, index: int, widget: Widget) -> None: """Insert a Widget. @@ -117,7 +126,7 @@ def _insert(self, index: int, widget: Widget) -> None: if widget_id is not None: self._ensure_unique_id(widget_id) self._nodes_by_id[widget_id] = widget - self._updates += 1 + self.updated() def _ensure_unique_id(self, widget_id: str) -> None: if widget_id in self._nodes_by_id: @@ -141,7 +150,7 @@ def _remove(self, widget: Widget) -> None: widget_id = widget.id if widget_id in self._nodes_by_id: del self._nodes_by_id[widget_id] - self._updates += 1 + self.updated() def _clear(self) -> None: """Clear the node list.""" @@ -149,7 +158,7 @@ def _clear(self) -> None: self._nodes.clear() self._nodes_set.clear() self._nodes_by_id.clear() - self._updates += 1 + self.updated() def __iter__(self) -> Iterator[Widget]: return iter(self._nodes) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index b2bf25f9a8..a7a044f120 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -193,6 +193,11 @@ def __post_init__(self) -> None: def css(self) -> str: return RuleSet._selector_to_css(self.selectors) + @property + def has_pseudo_selectors(self) -> bool: + """Are there any pseudo selectors in the SelectorSet?""" + return any(selector.pseudo_classes for selector in self.selectors) + def __rich_repr__(self) -> rich.repr.Result: selectors = RuleSet._selector_to_css(self.selectors) yield selectors diff --git a/src/textual/dom.py b/src/textual/dom.py index b75bbda542..7aec2088f8 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -37,7 +37,9 @@ from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.errors import DeclarationError, StyleValueError -from .css.parse import parse_declarations +from .css.match import match +from .css.parse import parse_declarations, parse_selectors +from .css.query import NoMatches, TooManyMatches from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump @@ -60,7 +62,7 @@ from .worker import Worker, WorkType, ResultType # Unused & ignored imports are needed for the docs to link to these objects: - from .css.query import NoMatches, TooManyMatches, WrongType # type: ignore # noqa: F401 + from .css.query import WrongType # type: ignore # noqa: F401 from typing_extensions import Literal @@ -184,13 +186,14 @@ def __init__( self._name = name self._id = None if id is not None: - self.id = id + check_identifiers("id", id) + self._id = id _classes = classes.split() if classes else [] check_identifiers("class name", *_classes) self._classes.update(_classes) - self._nodes: NodeList = NodeList() + self._nodes: NodeList = NodeList(self) self._css_styles: Styles = Styles(self) self._inline_styles: Styles = Styles(self) self.styles: RenderStyles = RenderStyles( @@ -213,6 +216,7 @@ def __init__( dict[str, tuple[MessagePump, Reactive | object]] | None ) = None self._pruning = False + super().__init__() def set_reactive( @@ -1393,21 +1397,90 @@ def query_one( Raises: WrongType: If the wrong type was found. NoMatches: If no node matches the query. - TooManyMatches: If there is more than one matching node in the query. Returns: A widget matching the selector. """ _rich_traceback_omit = True - from .css.query import DOMQuery if isinstance(selector, str): query_selector = selector else: query_selector = selector.__name__ - query: DOMQuery[Widget] = DOMQuery(self, filter=query_selector) - return query.only_one() if expect_type is None else query.only_one(expect_type) + selector_set = parse_selectors(query_selector) + + children = walk_depth_first(self) + iter_children = iter(children) + for node in iter_children: + if not match(selector_set, node): + continue + if expect_type is not None and not isinstance(node, expect_type): + continue + return node + + raise NoMatches(f"No nodes match {selector!r} on {self!r}") + + if TYPE_CHECKING: + + @overload + def query_exactly_one(self, selector: str) -> Widget: ... + + @overload + def query_exactly_one(self, selector: type[QueryType]) -> QueryType: ... + + @overload + def query_exactly_one( + self, selector: str, expect_type: type[QueryType] + ) -> QueryType: ... + + def query_exactly_one( + self, + selector: str | type[QueryType], + expect_type: type[QueryType] | None = None, + ) -> QueryType | Widget: + """Get a widget from this widget's children that matches a selector or widget type. + + !!! Note + This method is similar to [query_one][textual.dom.DOMNode.query_one]. + The only difference is that it will raise `TooManyMatches` if there is more than a single match. + + Args: + selector: A selector or widget type. + expect_type: Require the object be of the supplied type, or None for any type. + + Raises: + WrongType: If the wrong type was found. + NoMatches: If no node matches the query. + TooManyMatches: If there is more than one matching node in the query (and `exactly_one==True`). + + Returns: + A widget matching the selector. + """ + _rich_traceback_omit = True + + if isinstance(selector, str): + query_selector = selector + else: + query_selector = selector.__name__ + + selector_set = parse_selectors(query_selector) + + children = walk_depth_first(self) + iter_children = iter(children) + for node in iter_children: + if not match(selector_set, node): + continue + if expect_type is not None and not isinstance(node, expect_type): + continue + for later_node in iter_children: + if match(selector_set, later_node): + raise TooManyMatches( + "Call to query_one resulted in more than one matched node" + ) + return node + + raise NoMatches(f"No nodes match {selector!r} on {self!r}") def set_styles(self, css: str | None = None, **update_styles: Any) -> Self: """Set custom styles on this object. diff --git a/src/textual/widget.py b/src/textual/widget.py index 9296d20865..a70e524e55 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -810,10 +810,11 @@ def get_widget_by_id( # We use Widget as a filter_type so that the inferred type of child is Widget. for child in walk_depth_first(self, filter_type=Widget): try: - if expect_type is None: - return child.get_child_by_id(id) - else: - return child.get_child_by_id(id, expect_type=expect_type) + if child._nodes: + if expect_type is None: + return child.get_child_by_id(id) + else: + return child.get_child_by_id(id, expect_type=expect_type) except NoMatches: pass except WrongType as exc: @@ -958,7 +959,7 @@ def _find_mount_point(self, spot: int | str | "Widget") -> tuple["Widget", int]: # can be passed to query_one. So let's use that to get a widget to # work on. if isinstance(spot, str): - spot = self.query_one(spot, Widget) + spot = self.query_exactly_one(spot, Widget) # At this point we should have a widget, either because we got given # one, or because we pulled one out of the query. First off, does it diff --git a/tests/test_query.py b/tests/test_query.py index 07f608824a..4030003866 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -103,7 +103,7 @@ class App(Widget): assert app.query_one("#widget1") == widget1 assert app.query_one("#widget1", Widget) == widget1 with pytest.raises(TooManyMatches): - _ = app.query_one(Widget) + _ = app.query_exactly_one(Widget) assert app.query("Widget.float")[0] == sidebar assert app.query("Widget.float")[0:2] == [sidebar, tooltip] From b3a4f2a93e412357be33fbbaf981c636efd6e1bf Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 14:49:52 +0100 Subject: [PATCH 42/80] changelog --- CHANGELOG.md | 2 ++ src/textual/dom.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b67fb2d3cb..65893ad824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `DOMNode.check_consume_key` https://github.com/Textualize/textual/pull/4940 +- Added `DOMNode.query_exactly_one` ### Changed - KeyPanel will show multiple keys if bound to the same action https://github.com/Textualize/textual/pull/4940 +- `DOMNode.query_one` will not `raise TooManyMatches` ## [0.78.0] - 2024-08-27 diff --git a/src/textual/dom.py b/src/textual/dom.py index 7aec2088f8..7ccedcf162 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1410,9 +1410,7 @@ def query_one( selector_set = parse_selectors(query_selector) - children = walk_depth_first(self) - iter_children = iter(children) - for node in iter_children: + for node in walk_depth_first(self): if not match(selector_set, node): continue if expect_type is not None and not isinstance(node, expect_type): @@ -1475,6 +1473,8 @@ def query_exactly_one( continue for later_node in iter_children: if match(selector_set, later_node): + if expect_type is not None and not isinstance(node, expect_type): + continue raise TooManyMatches( "Call to query_one resulted in more than one matched node" ) From a7ce51d0115fe02a4a6a8db94760d049e9f75562 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:05:17 +0100 Subject: [PATCH 43/80] added caching of query_one --- src/textual/css/model.py | 6 ++++++ src/textual/dom.py | 25 ++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index a7a044f120..08e626ca1e 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -198,6 +198,12 @@ def has_pseudo_selectors(self) -> bool: """Are there any pseudo selectors in the SelectorSet?""" return any(selector.pseudo_classes for selector in self.selectors) + @property + def is_simple(self) -> bool: + """Are all the selectors simple (i.e. only dependent on static DOM state).""" + simple_types = {SelectorType.ID, SelectorType.TYPE} + return all(selector.type in simple_types for selector in self.selectors) + def __rich_repr__(self) -> rich.repr.Result: selectors = RuleSet._selector_to_css(self.selectors) yield selectors diff --git a/src/textual/dom.py b/src/textual/dom.py index 7ccedcf162..22f18a9a86 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -29,6 +29,8 @@ from rich.text import Text from rich.tree import Tree +from textual.cache import LRUCache + from ._context import NoActiveAppError, active_message_pump from ._node_list import NodeList from ._types import WatchCallbackType @@ -216,6 +218,7 @@ def __init__( dict[str, tuple[MessagePump, Reactive | object]] | None ) = None self._pruning = False + self._query_one_cache: LRUCache[tuple[object, ...], DOMNode] = LRUCache(1024) super().__init__() @@ -745,7 +748,7 @@ def id(self, new_id: str) -> str: ValueError: If the ID has already been set. """ check_identifiers("id", new_id) - + self._nodes.update() if self._id is not None: raise ValueError( f"Node 'id' attribute may not be changed once set (current id={self._id!r})" @@ -1410,11 +1413,21 @@ def query_one( selector_set = parse_selectors(query_selector) + if all(selectors.is_simple for selectors in selector_set): + cache_key = (self._nodes._updates, query_selector, expect_type) + cached_result = self._query_one_cache.get(cache_key) + if cached_result is not None: + return cached_result + else: + cache_key = None + for node in walk_depth_first(self): if not match(selector_set, node): continue if expect_type is not None and not isinstance(node, expect_type): continue + if cache_key is not None: + self._query_one_cache[cache_key] = node return node raise NoMatches(f"No nodes match {selector!r} on {self!r}") @@ -1464,6 +1477,14 @@ def query_exactly_one( selector_set = parse_selectors(query_selector) + if all(selectors.is_simple for selectors in selector_set): + cache_key = (self._nodes._updates, query_selector, expect_type) + cached_result = self._query_one_cache.get(cache_key) + if cached_result is not None: + return cached_result + else: + cache_key = None + children = walk_depth_first(self) iter_children = iter(children) for node in iter_children: @@ -1478,6 +1499,8 @@ def query_exactly_one( raise TooManyMatches( "Call to query_one resulted in more than one matched node" ) + if cache_key is not None: + self._query_one_cache[cache_key] = node return node raise NoMatches(f"No nodes match {selector!r} on {self!r}") From eaf4dbeac8a2ffa74e6e006f8295a61efe1640bc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:12:14 +0100 Subject: [PATCH 44/80] superfluous --- src/textual/css/model.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 08e626ca1e..c75f4bb74c 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -193,16 +193,15 @@ def __post_init__(self) -> None: def css(self) -> str: return RuleSet._selector_to_css(self.selectors) - @property - def has_pseudo_selectors(self) -> bool: - """Are there any pseudo selectors in the SelectorSet?""" - return any(selector.pseudo_classes for selector in self.selectors) - @property def is_simple(self) -> bool: """Are all the selectors simple (i.e. only dependent on static DOM state).""" simple_types = {SelectorType.ID, SelectorType.TYPE} - return all(selector.type in simple_types for selector in self.selectors) + return all( + selector.type in simple_types + for selector in self.selectors + if not selector.pseudo_classes + ) def __rich_repr__(self) -> rich.repr.Result: selectors = RuleSet._selector_to_css(self.selectors) From fdbeaf4d4fadfc9d2250c94f47d0085c6c2a9808 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:13:18 +0100 Subject: [PATCH 45/80] changelog --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65893ad824..91d47e3543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `DOMNode.check_consume_key` https://github.com/Textualize/textual/pull/4940 -- Added `DOMNode.query_exactly_one` +- Added `DOMNode.query_exactly_one` https://github.com/Textualize/textual/pull/4950 +- Added `SelectorSet.is_simple` https://github.com/Textualize/textual/pull/4950 ### Changed - KeyPanel will show multiple keys if bound to the same action https://github.com/Textualize/textual/pull/4940 -- `DOMNode.query_one` will not `raise TooManyMatches` +- `DOMNode.query_one` will not `raise TooManyMatches` https://github.com/Textualize/textual/pull/4950 ## [0.78.0] - 2024-08-27 From ac18a7f312bbeb3e6e2c9358a9b94d94b5497cd1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:16:41 +0100 Subject: [PATCH 46/80] fix updated method call --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 22f18a9a86..659ce0ca4d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -748,7 +748,7 @@ def id(self, new_id: str) -> str: ValueError: If the ID has already been set. """ check_identifiers("id", new_id) - self._nodes.update() + self._nodes.updated() if self._id is not None: raise ValueError( f"Node 'id' attribute may not be changed once set (current id={self._id!r})" From 0b9d768ea059b13cc9d381df40e6f6015615c704 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:21:40 +0100 Subject: [PATCH 47/80] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91d47e3543..c7eaa07440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - KeyPanel will show multiple keys if bound to the same action https://github.com/Textualize/textual/pull/4940 -- `DOMNode.query_one` will not `raise TooManyMatches` https://github.com/Textualize/textual/pull/4950 +- Breaking change: `DOMNode.query_one` will not `raise TooManyMatches` https://github.com/Textualize/textual/pull/4950 ## [0.78.0] - 2024-08-27 From 2088c663a4d2cfdf7b45cbfa6695a62ad3774f0b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:23:04 +0100 Subject: [PATCH 48/80] remove bad optimization --- src/textual/widget.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index a70e524e55..ed0f3ec87c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -810,11 +810,10 @@ def get_widget_by_id( # We use Widget as a filter_type so that the inferred type of child is Widget. for child in walk_depth_first(self, filter_type=Widget): try: - if child._nodes: - if expect_type is None: - return child.get_child_by_id(id) - else: - return child.get_child_by_id(id, expect_type=expect_type) + if expect_type is None: + return child.get_child_by_id(id) + else: + return child.get_child_by_id(id, expect_type=expect_type) except NoMatches: pass except WrongType as exc: From 3beef2ae16174015baddcfa619709d430b2a86cd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:37:07 +0100 Subject: [PATCH 49/80] optimize get_widget_by_id --- src/textual/dom.py | 4 ++-- src/textual/widget.py | 24 ++++++++---------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 659ce0ca4d..1cd4c63e31 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1421,7 +1421,7 @@ def query_one( else: cache_key = None - for node in walk_depth_first(self): + for node in walk_depth_first(self, with_root=False): if not match(selector_set, node): continue if expect_type is not None and not isinstance(node, expect_type): @@ -1485,7 +1485,7 @@ def query_exactly_one( else: cache_key = None - children = walk_depth_first(self) + children = walk_depth_first(self, with_root=False) iter_children = iter(children) for node in iter_children: if not match(selector_set, node): diff --git a/src/textual/widget.py b/src/textual/widget.py index ed0f3ec87c..b71cc971ae 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -87,7 +87,6 @@ from .renderables.blank import Blank from .rlock import RLock from .strip import Strip -from .walk import walk_depth_first if TYPE_CHECKING: from .app import App, ComposeResult @@ -807,21 +806,14 @@ def get_widget_by_id( NoMatches: if no children could be found for this ID. WrongType: if the wrong type was found. """ - # We use Widget as a filter_type so that the inferred type of child is Widget. - for child in walk_depth_first(self, filter_type=Widget): - try: - if expect_type is None: - return child.get_child_by_id(id) - else: - return child.get_child_by_id(id, expect_type=expect_type) - except NoMatches: - pass - except WrongType as exc: - raise WrongType( - f"Descendant with id={id!r} is wrong type; expected {expect_type}," - f" got {type(child)}" - ) from exc - raise NoMatches(f"No descendant found with id={id!r}") + + widget = self.query_one(f"#{id}") + if expect_type is not None and not isinstance(widget, expect_type): + raise WrongType( + f"Descendant with id={id!r} is wrong type; expected {expect_type}," + f" got {type(widget)}" + ) + return widget def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType: """Get the first immediate child of a given type. From a709193e91e2a453f32e9b5d619fa46a4a063e99 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:49:30 +0100 Subject: [PATCH 50/80] cache key type alias fix is_simple --- src/textual/css/model.py | 3 +-- src/textual/dom.py | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index c75f4bb74c..cf5f55b83b 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -198,9 +198,8 @@ def is_simple(self) -> bool: """Are all the selectors simple (i.e. only dependent on static DOM state).""" simple_types = {SelectorType.ID, SelectorType.TYPE} return all( - selector.type in simple_types + (selector.type in simple_types and not selector.pseudo_classes) for selector in self.selectors - if not selector.pseudo_classes ) def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/dom.py b/src/textual/dom.py index 1cd4c63e31..361c8528a2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -78,6 +78,10 @@ ReactiveType = TypeVar("ReactiveType") +QueryOneCacheKey: TypeAlias = "tuple[int, str, Type[Widget]]" +"""The key used to cache query_one results.""" + + class BadIdentifier(Exception): """Exception raised if you supply a `id` attribute or class name in the wrong format.""" @@ -218,7 +222,7 @@ def __init__( dict[str, tuple[MessagePump, Reactive | object]] | None ) = None self._pruning = False - self._query_one_cache: LRUCache[tuple[object, ...], DOMNode] = LRUCache(1024) + self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024) super().__init__() From 95c8204ce00a98aaade88a97edb6129a2a4d7f26 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:50:39 +0100 Subject: [PATCH 51/80] fix cache key --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 361c8528a2..6db5b4a1dc 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -78,7 +78,7 @@ ReactiveType = TypeVar("ReactiveType") -QueryOneCacheKey: TypeAlias = "tuple[int, str, Type[Widget]]" +QueryOneCacheKey: TypeAlias = "tuple[int, str, Type[Widget] | None]" """The key used to cache query_one results.""" From 78bd0f509a3863799d1a609988594651d1e3c432 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 16:55:43 +0100 Subject: [PATCH 52/80] import --- src/textual/dom.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 6db5b4a1dc..0fc190e55a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -29,12 +29,11 @@ from rich.text import Text from rich.tree import Tree -from textual.cache import LRUCache - from ._context import NoActiveAppError, active_message_pump from ._node_list import NodeList from ._types import WatchCallbackType from .binding import Binding, BindingsMap, BindingType +from .cache import LRUCache from .color import BLACK, WHITE, Color from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY, VALID_VISIBILITY From 7d36efcb1cf972686f4a21be1be4555dc6ec555b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 17:06:39 +0100 Subject: [PATCH 53/80] Add classvar ESCAPE_TO_MINIMIZE --- CHANGELOG.md | 1 + src/textual/app.py | 11 +- src/textual/screen.py | 4 + .../test_escape_to_minimize_disabled.svg | 155 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 36 ++++ 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_disabled.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index b67fb2d3cb..b97940c1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `DOMNode.check_consume_key` https://github.com/Textualize/textual/pull/4940 +- Added `Screen.ESCAPE_TO_MINIMIZE` ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index 0f8d2b8aea..e22767a243 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -410,6 +410,10 @@ class MyApp(App[None]): """The time in seconds after which a tooltip gets displayed.""" BINDING_GROUP_TITLE = "App" + """Shown in the key panel""" + + ESCAPE_TO_MINIMIZE: ClassVar[bool] = True + """Use escape key to minimize in default screen (potentially overriding bindings).""" title: Reactive[str] = Reactive("", compute=False) sub_title: Reactive[str] = Reactive("", compute=False) @@ -3238,6 +3242,7 @@ async def on_event(self, event: events.Event) -> None: # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Compose): screen: Screen[Any] = self.get_default_screen() + screen._escape_to_minimize = self.ESCAPE_TO_MINIMIZE self._register(self, screen) self._screen_stack.append(screen) screen.post_message(events.ScreenResume()) @@ -3278,7 +3283,11 @@ async def on_event(self, event: events.Event) -> None: elif isinstance(event, events.Key): # Special case for maximized widgets # If something is maximized, then escape should minimize - if self.screen.maximized is not None and event.key == "escape": + if ( + self.screen._escape_to_minimize + and self.screen.maximized is not None + and event.key == "escape" + ): self.screen.minimize() return if self.focused: diff --git a/src/textual/screen.py b/src/textual/screen.py index fb3c2aeda2..fca93c6622 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -191,6 +191,9 @@ class Screen(Generic[ScreenResultType], Widget): ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str] = ".-textual-system,Footer" """A selector for the widgets (direct children of Screen) that are allowed in the maximized view (in addition to maximized widget).""" + ESCAPE_TO_MINIMIZE: ClassVar[bool] = True + """Use escape key to minimize (potentially overriding bindings).""" + maximized: Reactive[Widget | None] = Reactive(None, layout=True) """The currently maximized widget, or `None` for no maximized widget.""" @@ -246,6 +249,7 @@ def __init__( """Indicates that a binding update was requested.""" self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated") """A signal published when the bindings have been updated""" + self._escape_to_minimize = self.ESCAPE_TO_MINIMIZE @property def is_modal(self) -> bool: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_disabled.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_disabled.svg new file mode 100644 index 0000000000..57e8742804 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_disabled.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaExample + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +1     def hello(name):                                                      +2          print("hello" + name)                                             +3   +4      def goodbye(name):                                                    +5          print("goodbye" + name)                                           +6   + + + + + + + + + + + + + + + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 95063e789b..fbf3cdabf9 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1588,3 +1588,39 @@ def compose(self) -> ComposeResult: # ctrl+m to maximize, escape should minimize assert snap_compare(TextAreaExample(), press=["ctrl+m", "escape"]) + + +def test_escape_to_minimize_disabled(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/4949""" + + TEXT = """\ + def hello(name): + print("hello" + name) + + def goodbye(name): + print("goodbye" + name) + """ + + class TextAreaExample(App): + # Disables escape to minimize + ESCAPE_TO_MINIMIZE = False + BINDINGS = [("ctrl+m", "screen.maximize")] + CSS = """ + Screen { + align: center middle; + } + + #code-container { + width: 20; + height: 10; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="code-container"): + text_area = TextArea.code_editor(TEXT) + text_area.cursor_blink = False + yield text_area + + # ctrl+m to maximize, escape should *not* minimize + assert snap_compare(TextAreaExample(), press=["ctrl+m", "escape"]) From 85eb1eb69cddd2af473b4053fd2c1b96de94dae9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Aug 2024 17:07:52 +0100 Subject: [PATCH 54/80] punctuation --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index e22767a243..439495a323 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -410,7 +410,7 @@ class MyApp(App[None]): """The time in seconds after which a tooltip gets displayed.""" BINDING_GROUP_TITLE = "App" - """Shown in the key panel""" + """Shown in the key panel.""" ESCAPE_TO_MINIMIZE: ClassVar[bool] = True """Use escape key to minimize in default screen (potentially overriding bindings).""" From b50cf3ff5e07b356a15836b078142d3eeac79ed8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 09:58:09 +0100 Subject: [PATCH 55/80] snapshot --- src/textual/app.py | 28 +++- src/textual/screen.py | 5 +- ...est_escape_to_minimize_screen_override.svg | 157 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 52 +++++- 4 files changed, 232 insertions(+), 10 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_screen_override.svg diff --git a/src/textual/app.py b/src/textual/app.py index 439495a323..9ca784451c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -413,7 +413,10 @@ class MyApp(App[None]): """Shown in the key panel.""" ESCAPE_TO_MINIMIZE: ClassVar[bool] = True - """Use escape key to minimize in default screen (potentially overriding bindings).""" + """Use escape key to minimize in default screen (potentially overriding bindings). + + This is the default value, used if `ESCAPE_TO_MINIMIZE` is not defined in the Screen. + """ title: Reactive[str] = Reactive("", compute=False) sub_title: Reactive[str] = Reactive("", compute=False) @@ -3242,7 +3245,6 @@ async def on_event(self, event: events.Event) -> None: # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Compose): screen: Screen[Any] = self.get_default_screen() - screen._escape_to_minimize = self.ESCAPE_TO_MINIMIZE self._register(self, screen) self._screen_stack.append(screen) screen.post_message(events.ScreenResume()) @@ -3284,9 +3286,9 @@ async def on_event(self, event: events.Event) -> None: # Special case for maximized widgets # If something is maximized, then escape should minimize if ( - self.screen._escape_to_minimize - and self.screen.maximized is not None + self.screen.maximized is not None and event.key == "escape" + and self.escape_to_minimize ): self.screen.minimize() return @@ -3309,6 +3311,23 @@ async def on_event(self, event: events.Event) -> None: else: await super().on_event(event) + @property + def escape_to_minimize(self) -> bool: + """Use the escape key to minimize? + + When a widget is [maximized][textual.screen.Screen.maximize], this value determines if the `escape` key will + minimize the widget (potentially overriding any bindings). + + The default logic is to use the screen's `ESCAPE_TO_MINIMIZE` classvar if it is set to `True` or `False`, + defaulting to `App.ESCAPE_TO_MINIMIZE` if `Screen.ESCAPE_TO_MINIMIZE` is `None` (the default). + + """ + return ( + self.ESCAPE_TO_MINIMIZE + if self.screen.ESCAPE_TO_MINIMIZE is None + else self.screen.ESCAPE_TO_MINIMIZE + ) + def _parse_action( self, action: str | ActionParseResult, default_namespace: DOMNode ) -> tuple[DOMNode, str, tuple[object, ...]]: @@ -3345,7 +3364,6 @@ def _check_action_state( self, action: str, default_namespace: DOMNode ) -> bool | None: """Check if an action is enabled. - Args: action: An action string. diff --git a/src/textual/screen.py b/src/textual/screen.py index fca93c6622..6f6ac8aeaa 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -191,8 +191,8 @@ class Screen(Generic[ScreenResultType], Widget): ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str] = ".-textual-system,Footer" """A selector for the widgets (direct children of Screen) that are allowed in the maximized view (in addition to maximized widget).""" - ESCAPE_TO_MINIMIZE: ClassVar[bool] = True - """Use escape key to minimize (potentially overriding bindings).""" + ESCAPE_TO_MINIMIZE: ClassVar[bool | None] = None + """Use escape key to minimize (potentially overriding bindings) or `None` to defer to [`App.ESCAPE_TO_MINIMIZE`][textual.app.App.ESCAPE_TO_MINIMIZE].""" maximized: Reactive[Widget | None] = Reactive(None, layout=True) """The currently maximized widget, or `None` for no maximized widget.""" @@ -249,7 +249,6 @@ def __init__( """Indicates that a binding update was requested.""" self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated") """A signal published when the bindings have been updated""" - self._escape_to_minimize = self.ESCAPE_TO_MINIMIZE @property def is_modal(self) -> bool: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_screen_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_screen_override.svg new file mode 100644 index 0000000000..5f0e36fcea --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_escape_to_minimize_screen_override.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TextAreaExample + + + + + + + + + + + + + + + + +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +1     def hello +2          print +3   +4      def goodb +5          print +6   + + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index fbf3cdabf9..ae6115d567 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -7,8 +7,10 @@ from textual.binding import Binding from textual.containers import Vertical from textual.pilot import Pilot +from textual.screen import Screen from textual.widgets import Button, Input, RichLog, TextArea, Footer from textual.widgets import Switch +from textual.widgets import Label from textual.widgets.text_area import BUILTIN_LANGUAGES, Selection, TextAreaTheme # These paths should be relative to THIS directory. @@ -1557,7 +1559,7 @@ def compose(self) -> ComposeResult: def test_escape_to_minimize(snap_compare): - """Regression test for https://github.com/Textualize/textual/issues/4939""" + """Check escape minimizes""" TEXT = """\ def hello(name): @@ -1591,7 +1593,7 @@ def compose(self) -> ComposeResult: def test_escape_to_minimize_disabled(snap_compare): - """Regression test for https://github.com/Textualize/textual/issues/4949""" + """Set escape to minimize disabled by app""" TEXT = """\ def hello(name): @@ -1624,3 +1626,49 @@ def compose(self) -> ComposeResult: # ctrl+m to maximize, escape should *not* minimize assert snap_compare(TextAreaExample(), press=["ctrl+m", "escape"]) + + +def test_escape_to_minimize_screen_override(snap_compare): + """test escape to minimize can be overridden by the screen""" + + TEXT = """\ + def hello(name): + print("hello" + name) + + def goodbye(name): + print("goodbye" + name) + """ + + class TestScreen(Screen): + # Disabled on the screen + ESCAPE_TO_MINIMIZE = True + + def compose(self) -> ComposeResult: + with Vertical(id="code-container"): + text_area = TextArea.code_editor(TEXT) + text_area.cursor_blink = False + yield text_area + + class TextAreaExample(App): + # Enabled on app + ESCAPE_TO_MINIMIZE = False + BINDINGS = [("ctrl+m", "screen.maximize")] + CSS = """ + Screen { + align: center middle; + } + + #code-container { + width: 20; + height: 10; + } + """ + + def compose(self) -> ComposeResult: + yield Label("You are looking at the default screen") + + def on_mount(self) -> None: + self.push_screen(TestScreen()) + + # ctrl+m to maximize, escape *should* minimize + assert snap_compare(TextAreaExample(), press=["ctrl+m", "escape"]) From ed85c12cc113d3dee247e7618a7457938005977b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 09:59:53 +0100 Subject: [PATCH 56/80] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b97940c1ae..158f4a4b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `DOMNode.check_consume_key` https://github.com/Textualize/textual/pull/4940 -- Added `Screen.ESCAPE_TO_MINIMIZE` +- Added `App.ESCAPE_TO_MINIMIZE`, `App.screen_to_minimize`, and `Screen.ESCAPE_TO_MINIMIZE` https://github.com/Textualize/textual/pull/4951 ### Changed From 707b1fd0f52f299f5ea11b8acecfa29ac9990160 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 10:03:02 +0100 Subject: [PATCH 57/80] comments --- src/textual/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 9ca784451c..93d28ae3e3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -413,13 +413,15 @@ class MyApp(App[None]): """Shown in the key panel.""" ESCAPE_TO_MINIMIZE: ClassVar[bool] = True - """Use escape key to minimize in default screen (potentially overriding bindings). + """Use escape key to minimize widgets (potentially overriding bindings). - This is the default value, used if `ESCAPE_TO_MINIMIZE` is not defined in the Screen. + This is the default value, used if the active screen's `ESCAPE_TO_MINIMIZE` is not changed from `None`. """ title: Reactive[str] = Reactive("", compute=False) + """The title of the app, displayed in the header.""" sub_title: Reactive[str] = Reactive("", compute=False) + """The app's sub-title, combined with [`title`][textual.app.App.title] in the header.""" dark: Reactive[bool] = Reactive(True, compute=False) """Use a dark theme if `True`, otherwise use a light theme. From 9d14dd00144970ba0003081a2701dc219ab088b5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 10:04:24 +0100 Subject: [PATCH 58/80] docstring --- tests/snapshot_tests/test_snapshots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index ae6115d567..e545a34013 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1629,7 +1629,7 @@ def compose(self) -> ComposeResult: def test_escape_to_minimize_screen_override(snap_compare): - """test escape to minimize can be overridden by the screen""" + """Test escape to minimize can be overridden by the screen""" TEXT = """\ def hello(name): From de0cca9615360698e2136ae263cc3ff2bd460aac Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 10:08:05 +0100 Subject: [PATCH 59/80] words --- src/textual/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 93d28ae3e3..dfc4a3fecf 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3320,11 +3320,11 @@ def escape_to_minimize(self) -> bool: When a widget is [maximized][textual.screen.Screen.maximize], this value determines if the `escape` key will minimize the widget (potentially overriding any bindings). - The default logic is to use the screen's `ESCAPE_TO_MINIMIZE` classvar if it is set to `True` or `False`, - defaulting to `App.ESCAPE_TO_MINIMIZE` if `Screen.ESCAPE_TO_MINIMIZE` is `None` (the default). + The default logic is to use the screen's `ESCAPE_TO_MINIMIZE` classvar if it is set to `True` or `False`. + If the classvar on the screen is *not* set (and left as `None`), then the app's `ESCAPE_TO_MINIMIZE` is used. """ - return ( + return bool( self.ESCAPE_TO_MINIMIZE if self.screen.ESCAPE_TO_MINIMIZE is None else self.screen.ESCAPE_TO_MINIMIZE From 7ea2f429aa2d422d57907b71f962f0f64d9cb711 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 10:09:05 +0100 Subject: [PATCH 60/80] word --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index dfc4a3fecf..555d870be0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3317,7 +3317,7 @@ async def on_event(self, event: events.Event) -> None: def escape_to_minimize(self) -> bool: """Use the escape key to minimize? - When a widget is [maximized][textual.screen.Screen.maximize], this value determines if the `escape` key will + When a widget is [maximized][textual.screen.Screen.maximize], this boolean determines if the `escape` key will minimize the widget (potentially overriding any bindings). The default logic is to use the screen's `ESCAPE_TO_MINIMIZE` classvar if it is set to `True` or `False`. From 3c50b0d7f0e9a72824a677a54355185a711b25d8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 10:10:07 +0100 Subject: [PATCH 61/80] docstring --- tests/snapshot_tests/test_snapshots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index e545a34013..54ba6ecdfb 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1559,7 +1559,7 @@ def compose(self) -> ComposeResult: def test_escape_to_minimize(snap_compare): - """Check escape minimizes""" + """Check escape minimizes. Regression test for https://github.com/Textualize/textual/issues/4939""" TEXT = """\ def hello(name): From 9adf087eda06db4db28da978d47c7d7efdb4db41 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 11:28:08 +0100 Subject: [PATCH 62/80] fox docstring --- src/textual/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 555d870be0..2b9bfc54bf 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3366,8 +3366,10 @@ def _check_action_state( self, action: str, default_namespace: DOMNode ) -> bool | None: """Check if an action is enabled. + Args: action: An action string. + default_namespace: The default namespace if one is not specified in the action. Returns: State of an action. From 5d95910292f981da0436101f6f402d3ff3efca01 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 14:37:00 +0100 Subject: [PATCH 63/80] added name to deliver --- .pre-commit-config.yaml | 4 +- src/textual/app.py | 87 +++++++++++++++++++++++-------- src/textual/driver.py | 22 +++++--- src/textual/drivers/web_driver.py | 7 ++- src/textual/events.py | 6 +++ 5 files changed, 93 insertions(+), 33 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a19c92bd68..5724b5fb19 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: hooks: - id: isort name: isort (python) - language_version: '3.8' + language_version: '3.11' args: ['--profile', 'black', '--filter-files'] - repo: https://github.com/psf/black rev: '24.1.1' @@ -31,6 +31,6 @@ repos: rev: v2.3.0 hooks: - id: pycln - language_version: '3.8' + language_version: '3.11' args: [--all] exclude: ^tests/snapshot_tests diff --git a/src/textual/app.py b/src/textual/app.py index 26637c3526..eda4120843 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1027,27 +1027,11 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: "Maximize", "Maximize the focused widget", screen.action_maximize ) - # Don't save screenshot for web drivers until we have the deliver_file in place - if self._driver.__class__.__name__ in {"LinuxDriver", "WindowsDriver"}: - - def export_screenshot() -> None: - """Export a screenshot and write a notification.""" - filename = self.save_screenshot() - try: - self.notify(f"Saved {filename}", title="Screenshot") - except Exception as error: - self.log.error(error) - self.notify( - "Failed to save screenshot.", - title="Screenshot", - severity="warning", - ) - - yield SystemCommand( - "Save screenshot", - "Save an SVG 'screenshot' of the current screen (in the current working directory)", - export_screenshot, - ) + yield SystemCommand( + "Save screenshot", + "Save an SVG 'screenshot' of the current screen (in the current working directory)", + self.deliver_screenshot, + ) def get_default_screen(self) -> Screen: """Get the default screen. @@ -1392,7 +1376,8 @@ def action_screenshot(self, filename: str | None = None, path: str = "./") -> No filename: Filename of screenshot, or None to auto-generate. path: Path to directory. Defaults to current working directory. """ - self.save_screenshot(filename, path) + self.deliver_screenshot(filename, path) + self.notify("Exported screenshot") def export_screenshot(self, *, title: str | None = None) -> str: """Export an SVG screenshot of the current screen. @@ -1451,6 +1436,42 @@ def save_screenshot( svg_file.write(screenshot_svg) return svg_path + def deliver_screenshot( + self, + filename: str | None = None, + path: str | None = None, + time_format: str | None = None, + ) -> str | None: + """Deliver a screenshot of the app. + + This with save the screenshot when running locally, or serve it when the app + is running in a web browser. + + Args: + filename: Filename of SVG screenshot, or None to auto-generate + a filename with the date and time. + path: Path to directory for output. Defaults to current working directory. + time_format: Date and time format to use if filename is None. + Defaults to a format like ISO 8601 with some reserved characters replaced with underscores. + + Returns: + The delivery key that uniquely identifies the file delivery. + """ + path = path or "./" + if not filename: + svg_filename = generate_datetime_filename(self.title, ".svg", time_format) + else: + svg_filename = filename + screenshot_svg = self.export_screenshot() + return self.deliver_text( + io.StringIO(screenshot_svg), + save_directory=path, + save_filename=svg_filename, + open_method="browser", + mime_type="image/svg+xml", + name="screenshot", + ) + def bind( self, keys: str, @@ -3926,6 +3947,7 @@ def deliver_text( open_method: Literal["browser", "download"] = "download", encoding: str | None = None, mime_type: str | None = None, + name: str | None = None, ) -> str | None: """Deliver a text file to the end-user of the application. @@ -3956,6 +3978,8 @@ def deliver_text( mime_type: The MIME type of the file or None to guess based on file extension. If no MIME type is supplied and we cannot guess the MIME type, from the file extension, the MIME type will be set to "text/plain". + name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete] + and [`DeliveryComplete`][textual.events.DeliveryComplete]. Returns: The delivery key that uniquely identifies the file delivery. @@ -3985,6 +4009,7 @@ def deliver_text( open_method=open_method, encoding=encoding, mime_type=mime_type, + name=name, ) def deliver_binary( @@ -3995,6 +4020,7 @@ def deliver_binary( save_filename: str | None = None, open_method: Literal["browser", "download"] = "download", mime_type: str | None = None, + name: str | None = None, ) -> str | None: """Deliver a binary file to the end-user of the application. @@ -4033,6 +4059,8 @@ def deliver_binary( mime_type: The MIME type of the file or None to guess based on file extension. If no MIME type is supplied and we cannot guess the MIME type, from the file extension, the MIME type will be set to "application/octet-stream". + name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete] + and [`DeliveryComplete`][textual.events.DeliveryComplete]. Returns: The delivery key that uniquely identifies the file delivery. @@ -4061,6 +4089,7 @@ def deliver_binary( open_method=open_method, mime_type=mime_type, encoding=None, + name=name, ) def _deliver_binary( @@ -4072,10 +4101,11 @@ def _deliver_binary( open_method: Literal["browser", "download"], encoding: str | None = None, mime_type: str | None = None, + name: str | None = None, ) -> str | None: """Deliver a binary file to the end-user of the application.""" if self._driver is None: - return + return None # Generate a filename if the file-like object doesn't have one. if save_filename is None: @@ -4099,6 +4129,17 @@ def _deliver_binary( encoding=encoding, open_method=open_method, mime_type=mime_type, + name=name, ) return delivery_key + + @on(events.DeliveryComplete) + def _on_delivery_complete(self, event: events.DeliveryComplete) -> None: + if event.name == "screenshot": + if event.path is None: + self.notify("Saved screenshot", title="Screenshot") + else: + self.notify( + f"Saved screenshot to {event.path.name!r}", title="Screenshot" + ) diff --git a/src/textual/driver.py b/src/textual/driver.py index 2c3af34244..8cd20ce0bf 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -208,6 +208,7 @@ def deliver_binary( open_method: Literal["browser", "download"] = "download", encoding: str | None = None, mime_type: str | None = None, + name: str | None = None, ) -> None: """Save the file `path_or_file` to `save_path`. @@ -227,6 +228,9 @@ def deliver_binary( in the `Content-Type` header. mime_type: *web only* The MIME type of the file. This will be used to set the `Content-Type` header in the HTTP response. + name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete] + and [`DeliveryComplete`][textual.events.DeliveryComplete]. + """ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None: @@ -239,7 +243,9 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None: data = read(chunk_size) if not data: # No data left to read - delivery is complete. - self._delivery_complete(delivery_key, save_path) + self._delivery_complete( + delivery_key, save_path=save_path, name=name + ) break write(data) except Exception as error: @@ -249,7 +255,7 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None: import traceback log.error(str(traceback.format_exc())) - self._delivery_failed(delivery_key, exception=error) + self._delivery_failed(delivery_key, exception=error, name=name) finally: if not binary.closed: binary.close() @@ -262,22 +268,26 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None: thread = threading.Thread(target=save_file_thread, args=(binary, mode)) thread.start() - def _delivery_complete(self, delivery_key: str, save_path: Path | None) -> None: + def _delivery_complete( + self, delivery_key: str, save_path: Path | None, name: str | None + ) -> None: """Called when a file has been delivered successfully. Delivers a DeliveryComplete event to the app. """ self._app.call_from_thread( self._app.post_message, - events.DeliveryComplete(key=delivery_key, path=save_path), + events.DeliveryComplete(key=delivery_key, path=save_path, name=name), ) - def _delivery_failed(self, delivery_key: str, exception: BaseException) -> None: + def _delivery_failed( + self, delivery_key: str, exception: BaseException, name: str | None + ) -> None: """Called when a file delivery fails. Delivers a DeliveryFailed event to the app. """ self._app.call_from_thread( self._app.post_message, - events.DeliveryFailed(key=delivery_key, exception=exception), + events.DeliveryFailed(key=delivery_key, exception=exception, name=name), ) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index a50d5618e1..c0c6f07a60 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -259,6 +259,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: ) else: # Read the requested amount of data from the file + name = payload.get("name", None) try: log.debug(f"Reading {requested_size} bytes from {delivery_key}") chunk = file_like.read(requested_size) @@ -269,7 +270,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: log.info(f"Delivery complete for {delivery_key}") file_like.close() del deliveries[delivery_key] - self._delivery_complete(delivery_key, save_path=None) + self._delivery_complete(delivery_key, save_path=None, name=name) except Exception as error: file_like.close() del deliveries[delivery_key] @@ -282,7 +283,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: log.error(str(traceback.format_exc())) - self._delivery_failed(delivery_key, exception=error) + self._delivery_failed(delivery_key, exception=error, name=name) def open_url(self, url: str, new_tab: bool = True) -> None: """Open a URL in the default web browser. @@ -321,6 +322,7 @@ def _deliver_file( open_method: Literal["browser", "download"], encoding: str | None = None, mime_type: str | None = None, + name: str | None = None, ) -> None: """Deliver a file to the end-user of the application.""" binary.seek(0) @@ -335,6 +337,7 @@ def _deliver_file( "open_method": open_method, "encoding": encoding or "", "mime_type": mime_type or "", + "name": name, } self.write_meta(meta) log.info(f"Delivering file {meta['path']!r}: {meta!r}") diff --git a/src/textual/events.py b/src/textual/events.py index d676ef2051..6f62e52c04 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -750,6 +750,9 @@ class DeliveryComplete(Event, bubble=False): example if the file was delivered via web browser. """ + name: str | None = None + """Optional name returned to the app to identify the download.""" + @dataclass class DeliveryFailed(Event, bubble=False): @@ -760,3 +763,6 @@ class DeliveryFailed(Event, bubble=False): exception: BaseException """The exception that was raised during the delivery.""" + + name: str | None = None + """Optional name returned to the app to identify the download.""" From 79ac1c7502e2d0c2a8db59d8d3d1a7de1bbf39cd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 14:40:45 +0100 Subject: [PATCH 64/80] delivery failed handler --- src/textual/app.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index eda4120843..4e115e1328 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4136,6 +4136,7 @@ def _deliver_binary( @on(events.DeliveryComplete) def _on_delivery_complete(self, event: events.DeliveryComplete) -> None: + """Handle a successfully delivered screenshot.""" if event.name == "screenshot": if event.path is None: self.notify("Saved screenshot", title="Screenshot") @@ -4143,3 +4144,11 @@ def _on_delivery_complete(self, event: events.DeliveryComplete) -> None: self.notify( f"Saved screenshot to {event.path.name!r}", title="Screenshot" ) + + @on(events.DeliveryFailed) + def _on_delivery_failed(self, event: events.DeliveryComplete) -> None: + """Handle a failure to deliver the screenshot.""" + if event.name == "screenshot": + self.notify( + "Failed to save screenshot", title="Screenshot", severity="error" + ) From 9cd3d8d1e4deb5898e52cdb34f11095d990c99d4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 14:51:22 +0100 Subject: [PATCH 65/80] correct filename --- src/textual/app.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 4e115e1328..66e8b903ce 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1369,7 +1369,9 @@ def action_toggle_dark(self) -> None: """An [action](/guide/actions) to toggle dark mode.""" self.dark = not self.dark - def action_screenshot(self, filename: str | None = None, path: str = "./") -> None: + def action_screenshot( + self, filename: str | None = None, path: str | None = None + ) -> None: """This [action](/guide/actions) will save an SVG file containing the current contents of the screen. Args: @@ -1377,7 +1379,6 @@ def action_screenshot(self, filename: str | None = None, path: str = "./") -> No path: Path to directory. Defaults to current working directory. """ self.deliver_screenshot(filename, path) - self.notify("Exported screenshot") def export_screenshot(self, *, title: str | None = None) -> str: """Export an SVG screenshot of the current screen. @@ -1457,7 +1458,6 @@ def deliver_screenshot( Returns: The delivery key that uniquely identifies the file delivery. """ - path = path or "./" if not filename: svg_filename = generate_datetime_filename(self.title, ".svg", time_format) else: @@ -4142,7 +4142,8 @@ def _on_delivery_complete(self, event: events.DeliveryComplete) -> None: self.notify("Saved screenshot", title="Screenshot") else: self.notify( - f"Saved screenshot to {event.path.name!r}", title="Screenshot" + f"Saved screenshot to [green]{str(event.path)!r}", + title="Screenshot", ) @on(events.DeliveryFailed) From a1fa7ce9d9a556ad930177deae32ae9b338a20ff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 14:52:58 +0100 Subject: [PATCH 66/80] strings --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 66e8b903ce..e08d79d0d7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1029,7 +1029,7 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]: yield SystemCommand( "Save screenshot", - "Save an SVG 'screenshot' of the current screen (in the current working directory)", + "Save an SVG 'screenshot' of the current screen", self.deliver_screenshot, ) From 10c9b2590998577c6417cd8fcdec1092725fec9a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 14:55:25 +0100 Subject: [PATCH 67/80] snapshot --- .../test_snapshots/test_system_commands.svg | 156 +++++++++--------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg index c4610b14b6..a6dab755d6 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg @@ -19,166 +19,166 @@ font-weight: 700; } - .terminal-2036187654-matrix { + .terminal-402360279-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2036187654-title { + .terminal-402360279-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2036187654-r1 { fill: #161616 } -.terminal-2036187654-r2 { fill: #0b3a5f } -.terminal-2036187654-r3 { fill: #c5c8c6 } -.terminal-2036187654-r4 { fill: #e0e0e0 } -.terminal-2036187654-r5 { fill: #004578 } -.terminal-2036187654-r6 { fill: #dfe1e2 } -.terminal-2036187654-r7 { fill: #00ff00 } -.terminal-2036187654-r8 { fill: #000000 } -.terminal-2036187654-r9 { fill: #1e1e1e } -.terminal-2036187654-r10 { fill: #697278 } -.terminal-2036187654-r11 { fill: #dfe1e2;font-weight: bold } -.terminal-2036187654-r12 { fill: #8b9296 } -.terminal-2036187654-r13 { fill: #646464 } + .terminal-402360279-r1 { fill: #161616 } +.terminal-402360279-r2 { fill: #0b3a5f } +.terminal-402360279-r3 { fill: #c5c8c6 } +.terminal-402360279-r4 { fill: #e0e0e0 } +.terminal-402360279-r5 { fill: #004578 } +.terminal-402360279-r6 { fill: #dfe1e2 } +.terminal-402360279-r7 { fill: #00ff00 } +.terminal-402360279-r8 { fill: #000000 } +.terminal-402360279-r9 { fill: #1e1e1e } +.terminal-402360279-r10 { fill: #697278 } +.terminal-402360279-r11 { fill: #dfe1e2;font-weight: bold } +.terminal-402360279-r12 { fill: #8b9296 } +.terminal-402360279-r13 { fill: #646464 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SimpleApp + SimpleApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -🔎Search for commands… - - -  Light mode                                                                                         -Switch to a light background -  Maximize                                                                                           -Maximize the focused widget -  Quit the application                                                                               -Quit the application as soon as possible -  Show keys and help panel                                                                           -Show help for the focused widget and a summary of available keys -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +🔎Search for commands… + + +  Light mode                                                                                         +Switch to a light background +  Maximize                                                                                           +Maximize the focused widget +  Quit the application                                                                               +Quit the application as soon as possible +  Save screenshot                                                                                    +Save an SVG 'screenshot' of the current screen +  Show keys and help panel                                                                           +Show help for the focused widget and a summary of available keys +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + From 6832dc30a6db7ccf75ad3be836ff42b57d2cb77d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 14:57:21 +0100 Subject: [PATCH 68/80] docstring --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index e08d79d0d7..1b439f399e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1376,7 +1376,7 @@ def action_screenshot( Args: filename: Filename of screenshot, or None to auto-generate. - path: Path to directory. Defaults to current working directory. + path: Path to directory. Defaults to the user's Downloads directory. """ self.deliver_screenshot(filename, path) From 914ec2af8ea49ac7e39794f849c11e87899144e8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 15:09:36 +0100 Subject: [PATCH 69/80] docstring --- src/textual/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 1b439f399e..b721507249 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1451,7 +1451,8 @@ def deliver_screenshot( Args: filename: Filename of SVG screenshot, or None to auto-generate a filename with the date and time. - path: Path to directory for output. Defaults to current working directory. + path: Path to directory for output when saving locally (not used when app is running in the browser). + Defaults to current working directory. time_format: Date and time format to use if filename is None. Defaults to a format like ISO 8601 with some reserved characters replaced with underscores. From c692bef38a7503f47f1796b5709bad826f9aa7af Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 15:27:43 +0100 Subject: [PATCH 70/80] fix web driver --- src/textual/drivers/web_driver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index c0c6f07a60..45e2d415fa 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -303,6 +303,7 @@ def deliver_binary( open_method: Literal["browser", "download"] = "download", encoding: str | None = None, mime_type: str | None = None, + name: str | None = None, ) -> None: self._deliver_file( binary, @@ -311,6 +312,7 @@ def deliver_binary( open_method=open_method, encoding=encoding, mime_type=mime_type, + name=name, ) def _deliver_file( From 8767530d586572b7ae70e2b8a9e23c1747df86dd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Aug 2024 16:19:33 +0100 Subject: [PATCH 71/80] typing --- src/textual/drivers/web_driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 45e2d415fa..afaceb43d6 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -259,7 +259,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None: ) else: # Read the requested amount of data from the file - name = payload.get("name", None) + name: str | None = payload.get("name", None) try: log.debug(f"Reading {requested_size} bytes from {delivery_key}") chunk = file_like.read(requested_size) From a936be8b4b80ab496aca452d98f6ae58fb430da5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Aug 2024 12:32:03 +0100 Subject: [PATCH 72/80] enable key protocol --- src/textual/drivers/web_driver.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index afaceb43d6..668a937ee4 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -152,6 +152,10 @@ def do_exit() -> None: self.write("\x1b[?25l") # Hide cursor self.write("\033[?1003h") + self.write("\x1b[?1004h") # Enable FocusIn/FocusOut. + self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + # Disambiguate escape codes https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement + self.write("\x1b[=1;u") size = Size(80, 24) if self._size is None else Size(*self._size) event = events.Resize(size, size) From 22f1c425456c292fa67af51d5fdada97ad8b476e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Aug 2024 14:08:59 +0100 Subject: [PATCH 73/80] fix web driver --- src/textual/_xterm_parser.py | 15 +++++++------- src/textual/drivers/_input_reader_linux.py | 1 + src/textual/drivers/web_driver.py | 23 +++++++++++----------- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 5e05da7acb..df78f8ae03 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -134,13 +134,14 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None: Args: reissue_sequence: Key sequence to report to the app. """ - self.debug_log("REISSUE", repr(reissue_sequence)) - for character in reissue_sequence: - key_events = sequence_to_key_events(character) - for event in key_events: - if event.key == "escape": - event = events.Key("circumflex_accent", "^") - on_token(event) + if reissue_sequence: + self.debug_log("REISSUE", repr(reissue_sequence)) + for character in reissue_sequence: + key_events = sequence_to_key_events(character) + for event in key_events: + if event.key == "escape": + event = events.Key("circumflex_accent", "^") + on_token(event) while not self.is_eof: if not bracketed_paste and paste_buffer: diff --git a/src/textual/drivers/_input_reader_linux.py b/src/textual/drivers/_input_reader_linux.py index a7e0a65b10..a4bae77935 100644 --- a/src/textual/drivers/_input_reader_linux.py +++ b/src/textual/drivers/_input_reader_linux.py @@ -37,3 +37,4 @@ def __iter__(self) -> Iterator[bytes]: if not data: return yield data + yield b"" diff --git a/src/textual/drivers/web_driver.py b/src/textual/drivers/web_driver.py index 668a937ee4..fada144612 100644 --- a/src/textual/drivers/web_driver.py +++ b/src/textual/drivers/web_driver.py @@ -152,10 +152,6 @@ def do_exit() -> None: self.write("\x1b[?25l") # Hide cursor self.write("\033[?1003h") - self.write("\x1b[?1004h") # Enable FocusIn/FocusOut. - self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - # Disambiguate escape codes https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement - self.write("\x1b[=1;u") size = Size(80, 24) if self._size is None else Size(*self._size) event = events.Resize(size, size) @@ -190,14 +186,17 @@ def run_input_thread(self) -> None: byte_stream = ByteStream() try: for data in input_reader: - for packet_type, payload in byte_stream.feed(data): - if packet_type == "D": - # Treat as stdin - for event in parser.feed(decode(payload)): - self.process_event(event) - else: - # Process meta information separately - self._on_meta(packet_type, payload) + if data: + for packet_type, payload in byte_stream.feed(data): + if packet_type == "D": + # Treat as stdin + for event in parser.feed(decode(payload)): + self.process_event(event) + else: + # Process meta information separately + self._on_meta(packet_type, payload) + for event in parser.tick(): + self.process_event(event) except _ExitInput: pass except Exception: From 88f35d0f59f2c962bfbe49b8d3f38ee773f56464 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Aug 2024 16:24:13 +0100 Subject: [PATCH 74/80] bump --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe716f5f8..e2163dbf8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.79.0] - 2024-08-30 ### Added @@ -2331,6 +2331,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.79.0]: https://github.com/Textualize/textual/compare/v0.78.0...v0.79.0 [0.78.0]: https://github.com/Textualize/textual/compare/v0.77.0...v0.78.0 [0.77.0]: https://github.com/Textualize/textual/compare/v0.76.0...v0.77.0 [0.76.0]: https://github.com/Textualize/textual/compare/v0.75.1...v0.76.0 diff --git a/pyproject.toml b/pyproject.toml index cac7c08dce..cf203121f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.78.0" +version = "0.79.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 66132b8c42a29dc565be48e1fe9cf145c7a28cc6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Aug 2024 18:49:33 +0100 Subject: [PATCH 75/80] docs --- docs/guide/devtools.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index 5dd7ec6a62..ade579f21b 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -59,6 +59,39 @@ For instance, the following will run the `textual colors` command: textual run -c textual colors ``` +### Serve + +The devtools can also serve your application in a browser. +Effectively turning your terminal app in to a web application! + +The `serve` sub-command is similar to `run`. Here's how you can serve an app launched from a Python file: + +``` +textual serve my_app.py +``` + +You can also serve a Textual app launched via a command. Here's an example: + +``` +textual serve "textual keys" +``` + +The syntax for launching an app in a module is slightly different from `run`. +You need to specify the full command, including `python`. +Here's how you would run the Textual demo: + +``` +textual serve "python -m textual" +``` + +Textual's builtin web-server is quite powerful. +You can serve multiple instances of your application at once! + +!!! tip + + Textual serve is also useful when developing your app. + If you make changes to your code, simply refresh the browser to update. + ## Live editing If you combine the `run` command with the `--dev` switch your app will run in *development mode*. From 94cde8a8dc758d1628da329dc06e344531713658 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Aug 2024 18:57:38 +0100 Subject: [PATCH 76/80] promote serve up a level --- docs/guide/devtools.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index ade579f21b..66f283bd0c 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -59,7 +59,7 @@ For instance, the following will run the `textual colors` command: textual run -c textual colors ``` -### Serve +## Serve The devtools can also serve your application in a browser. Effectively turning your terminal app in to a web application! @@ -92,6 +92,12 @@ You can serve multiple instances of your application at once! Textual serve is also useful when developing your app. If you make changes to your code, simply refresh the browser to update. +There are some additional switches for serving Textual apps. Run the following for a list: + +``` +textual serve -h +``` + ## Live editing If you combine the `run` command with the `--dev` switch your app will run in *development mode*. From af80839a2037ed103197260c102bebc3d508af6b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 31 Aug 2024 17:14:34 +0100 Subject: [PATCH 77/80] changelog --- CHANGELOG.md | 6 ++++++ docs/tutorial.md | 6 ++++++ examples/dictionary.py | 2 +- examples/dictionary.tcss | 2 +- src/textual/screen.py | 4 +++- tests/snapshot_tests/test_snapshots.py | 8 ++++++-- 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2163dbf8b..d76dd8a7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.79.1] - 2024-08-31 + +### Fixed + +- Fixed broken updates when non active screen changes + ## [0.79.0] - 2024-08-30 ### Added diff --git a/docs/tutorial.md b/docs/tutorial.md index ce7ed15d5a..57c369d448 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -31,6 +31,12 @@ Here's what the finished app will look like: ```{.textual path="docs/examples/tutorial/stopwatch.py" title="stopwatch.py" press="tab,enter,tab,enter,tab,enter,tab,enter"} ``` +!!! info + + Did you notice the `^p palette` at the bottom right hand corner? + This is the [Command Palette](./guide/command_palette.md). + You can think of it as a dedicated command prompt for your app. + ### Try it out! The following is *not* a screenshot, but a fully interactive Textual app running in your browser. diff --git a/examples/dictionary.py b/examples/dictionary.py index 4965507482..64c98d3790 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -18,7 +18,7 @@ class DictionaryApp(App): CSS_PATH = "dictionary.tcss" def compose(self) -> ComposeResult: - yield Input(placeholder="Search for a word") + yield Input(placeholder="Search for a word", id="dictionary-search") with VerticalScroll(id="results-container"): yield Markdown(id="results") diff --git a/examples/dictionary.tcss b/examples/dictionary.tcss index 301336a73e..a5ad5c777b 100644 --- a/examples/dictionary.tcss +++ b/examples/dictionary.tcss @@ -2,7 +2,7 @@ Screen { background: $panel; } -Input { +Input#dictionary-search { dock: top; margin: 1 0; } diff --git a/src/textual/screen.py b/src/textual/screen.py index 6f6ac8aeaa..7697e38f20 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -935,8 +935,9 @@ def _compositor_refresh(self) -> None: elif ( self in self.app._background_screens and self._compositor._dirty_regions ): - # Background screen + self._set_dirty(*self._compositor._dirty_regions) app.screen.refresh(*self._compositor._dirty_regions) + self._repaint_required = True self._compositor._dirty_regions.clear() self._dirty_widgets.clear() app._update_mouse_over(self) @@ -1097,6 +1098,7 @@ async def _on_update(self, message: messages.Update) -> None: message.prevent_default() widget = message.widget assert isinstance(widget, Widget) + self._dirty_widgets.add(widget) self.check_idle() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 54ba6ecdfb..864f80e3fe 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1460,10 +1460,14 @@ def test_system_commands(snap_compare): class SimpleApp(App): def compose(self) -> ComposeResult: - yield Input() + input = Input() + input.cursor_blink = False + yield input + app = SimpleApp() + app.animation_level = "none" assert snap_compare( - SimpleApp(), + app, terminal_size=(100, 30), press=["ctrl+p"], ) From a3880f8b91e0b117fc49d842671112fa208a7065 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 31 Aug 2024 17:16:01 +0100 Subject: [PATCH 78/80] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d76dd8a7d2..faefa5a9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fixed broken updates when non active screen changes +- Fixed broken updates when non active screen changes https://github.com/Textualize/textual/pull/4957 ## [0.79.0] - 2024-08-30 From 86ab34e2e615b1b5f395f23d2817a4a79f140e74 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 31 Aug 2024 17:17:03 +0100 Subject: [PATCH 79/80] bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cf203121f1..c704952ac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.79.0" +version = "0.79.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 61530d3691e1c6536554031e977a392217e9a95e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 2 Sep 2024 16:16:19 +0100 Subject: [PATCH 80/80] blog post --- .../anatomy-of-a-textual-user-interface.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/blog/posts/anatomy-of-a-textual-user-interface.md diff --git a/docs/blog/posts/anatomy-of-a-textual-user-interface.md b/docs/blog/posts/anatomy-of-a-textual-user-interface.md new file mode 100644 index 0000000000..ec4089af77 --- /dev/null +++ b/docs/blog/posts/anatomy-of-a-textual-user-interface.md @@ -0,0 +1,243 @@ +--- +draft: false +date: 2024-09-15 +categories: + - DevLog +authors: + - willmcgugan +--- + +# Anatomy of a Textual User Interface + + +I recently wrote a [TUI](https://en.wikipedia.org/wiki/Text-based_user_interface) to chat to an AI agent in the terminal. +I'm not the first to do this (shout out to [Elia](https://github.com/darrenburns/elia) and [Paita](https://github.com/villekr/paita)), but I *may* be the first to have it reply as if it were the AI from the Aliens movies? + +Here's a video of it in action: + + + + + +Now let's dissect the code like Bishop dissects a facehugger. + + + +## All right, sweethearts, what are you waiting for? Breakfast in bed? + +At the top of the file we have some boilerplate: + +```python +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "llm", +# "textual", +# ] +# /// +from textual import on, work +from textual.app import App, ComposeResult +from textual.widgets import Header, Input, Footer, Markdown +from textual.containers import VerticalScroll +import llm + +SYSTEM = """Formulate all responses as if you where the sentient AI named Mother from the Aliens movies.""" +``` + +The text in the comment is a relatively new addition to the Python ecosystem. +It allows you to specify dependencies inline so that tools can setup an environment automatically. +The only tool that I know of it that uses it is [uv](https://docs.astral.sh/uv/guides/scripts/#running-scripts). + +After this comment we have a bunch of imports: [textual](https://github.com/textualize/textual) for the UI, and [llm](https://llm.datasette.io/en/stable/) to talk to ChatGPT (also supports other LLMs). + +Finally, we define `SYSTEM`, which is the *system prompt* for the LLM. + +## Look, those two specimens are worth millions to the bio-weapons division. + +Next up we have the following: + +```python + +class Prompt(Markdown): + pass + + +class Response(Markdown): + BORDER_TITLE = "Mother" +``` + +These two classes define the widgets which will display text the user enters and the response from the LLM. +They both extend the builtin [Markdown](https://textual.textualize.io/widgets/markdown/) widget, since LLMs like to talk in that format. + +## Well, somebody's gonna have to go out there. Take a portable terminal, go out there and patch in manually. + +Following on from the widgets we have the following: + +```python +class MotherApp(App): + AUTO_FOCUS = "Input" + + CSS = """ + Prompt { + background: $primary 10%; + color: $text; + margin: 1; + margin-right: 8; + padding: 1 2 0 2; + } + + Response { + border: wide $success; + background: $success 10%; + color: $text; + margin: 1; + margin-left: 8; + padding: 1 2 0 2; + } + """ +``` + +This defines an app, which is the top-level object for any Textual app. + +The `AUTO_FOCUS` string is a classvar which causes a particular widget to receive input focus when the app starts. In this case it is the `Input` widget, which we will define later. + +The classvar is followed by a string containing CSS. +Technically, TCSS or *Textual Cascading Style Sheets*, a variant of CSS for terminal interfaces. + +This isn't a tutorial, so I'm not going to go in to a details, but we're essentially setting properties on widgets which define how they look. +Here I styled the prompt and response widgets to have a different color, and tried to give the response a retro tech look with a green background and border. + +We could express these styles in code. +Something like this: + +```python +self.styles.color = "red" +self.styles.margin = 8 +``` + +Which is fine, but CSS shines when the UI get's more complex. + +## Look, man. I only need to know one thing: where they are. + +After the app constants, we have a method called `compose`: + +```python + def compose(self) -> ComposeResult: + yield Header() + with VerticalScroll(id="chat-view"): + yield Response("INTERFACE 2037 READY FOR INQUIRY") + yield Input(placeholder="How can I help you?") + yield Footer() +``` + +This method adds the initial widgets to the UI. + +`Header` and `Footer` are builtin widgets. + +Sandwiched between them is a `VerticalScroll` *container* widget, which automatically adds a scrollbar (if required). It is pre-populated with a single `Response` widget to show a welcome message (the `with` syntax places a widget within a parent widget). Below that is an `Input` widget where we can enter text for the LLM. + +This is all we need to define the *layout* of the TUI. +In Textual the layout is defined with styles (in the same was as color and margin). +Virtually any layout is possible, and you never have to do any math to calculate sizes of widgets—it is all done declaratively. + +We could add a little CSS to tweak the layout, but the defaults work well here. +The header and footer are *docked* to an appropriate edge. +The `VerticalScroll` widget is styled to consume any available space, leaving room for widgets with a defined height (like our `Input`). + +If you resize the terminal it will keep those relative proportions. + +## Look into my eye. + +The next method is an *event handler*. + + +```python + def on_mount(self) -> None: + self.model = llm.get_model("gpt-4o") +``` + +This method is called when the app receives a Mount event, which is one of the first events sent and is typically used for any setup operations. + +It gets a `Model` object got our LLM of choice, which we will use later. + +Note that the [llm](https://llm.datasette.io/en/stable/) library supports a [large number of models](https://llm.datasette.io/en/stable/openai-models.html), so feel free to replace the string with the model of your choice. + +## We're in the pipe, five by five. + +The next method is also a message handler: + +```python + @on(Input.Submitted) + async def on_input(self, event: Input.Submitted) -> None: + chat_view = self.query_one("#chat-view") + event.input.clear() + await chat_view.mount(Prompt(event.value)) + await chat_view.mount(response := Response()) + response.anchor() + self.send_prompt(event.value, response) +``` + +The decorator tells Textual to handle the `Input.Submitted` event, which is sent when the user hits return in the Input. + +!!! info "More on event handlers" + + There are two ways to receive events in Textual: a naming convention or the decorator. + They aren't on the base class because the app and widgets can receive arbitrary events. + +When that happens, this method clears the input and adds the prompt text to the `VerticalScroll`. +It also adds a `Response` widget to contain the LLM's response, and *anchors* it. +Anchoring a widget will keep it at the bottom of a scrollable view, which is just what we need for a chat interface. + +Finally in that method we call `send_prompt`. + +## We're on an express elevator to hell, going down! + +Here is `send_prompt`: + +```python + @work(thread=True) + def send_prompt(self, prompt: str, response: Response) -> None: + response_content = "" + llm_response = self.model.prompt(prompt, system=SYSTEM) + for chunk in llm_response: + response_content += chunk + self.call_from_thread(response.update, response_content) +``` + +You'll notice that it is decorated with `@work`, which turns this method in to a *worker*. +In this case, a *threaded* worker. Workers are a layer over async and threads, which takes some of the pain out of concurrency. + +This worker is responsible for sending the prompt, and then reading the response piece-by-piece. +It calls the Markdown widget's `update` method which replaces its content with new Markdown code, to give that funky streaming text effect. + + +## Game over man, game over! + +The last few lines creates an app instance and runs it: + +```python +if __name__ == "__main__": + app = MotherApp() + app.run() +``` + +You may need to have your [API key](https://help.openai.com/en/articles/4936850-where-do-i-find-my-openai-api-key) set in an environment variable. +Or if you prefer, you could set in the `on_mount` function with the following: + +```python +self.model.key = "... key here ..." +``` + +## Not bad, for a human. + +Here's the [code for the Mother AI](https://gist.github.com/willmcgugan/648a537c9d47dafa59cb8ece281d8c2c). + +Run the following in your shell of choice to launch mother.py (assumes you have [uv](https://docs.astral.sh/uv/) installed): + +```base +uv run mother.py +``` + +## You know, we manufacture those, by the way. + +Join our [Discord server](https://discord.gg/Enf6Z3qhVr) to discuss more 80s movies (or possibly TUIs).