From 5156b3bc7070eafb199a3028622bcdda8d76bb94 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 25 Oct 2022 14:29:43 +0100 Subject: [PATCH 01/41] Always update flag for reactives --- .gitignore | 3 +++ src/textual/app.py | 1 + src/textual/reactive.py | 30 ++++++++++++++++++++++-------- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 4580aac9a..8e8a83d68 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ venv.bak/ # Snapshot testing report output directory tests/snapshot_tests/output + +# Sandbox folder - convenient place for us to develop small test apps without leaving the repo +sandbox/ diff --git a/src/textual/app.py b/src/textual/app.py index c044f6155..4c8501b36 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1021,6 +1021,7 @@ class App(Generic[ReturnType], DOMNode): self.stylesheet.add_source( self.CSS, path=app_css_path, is_default_css=False ) + print(self.stylesheet) except Exception as error: self._handle_exception(error) self._print_error_renderables() diff --git a/src/textual/reactive.py b/src/textual/reactive.py index c237955b4..25e5f4b0a 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -5,7 +5,6 @@ from inspect import isawaitable from typing import TYPE_CHECKING, Any, Callable, Generic, Type, TypeVar, Union from weakref import WeakSet - from . import events from ._callback import count_parameters, invoke from ._types import MessageTarget @@ -16,7 +15,6 @@ if TYPE_CHECKING: Reactable = Union[Widget, App] - ReactiveType = TypeVar("ReactiveType") @@ -37,7 +35,7 @@ class Reactive(Generic[ReactiveType]): layout (bool, optional): Perform a layout on change. Defaults to False. repaint (bool, optional): Perform a repaint on change. Defaults to True. init (bool, optional): Call watchers on initialize (post mount). Defaults to False. - + always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. """ def __init__( @@ -47,11 +45,13 @@ class Reactive(Generic[ReactiveType]): layout: bool = False, repaint: bool = True, init: bool = False, + always_update: bool = False, ) -> None: self._default = default self._layout = layout self._repaint = repaint self._init = init + self._always_update = always_update @classmethod def init( @@ -60,6 +60,7 @@ class Reactive(Generic[ReactiveType]): *, layout: bool = False, repaint: bool = True, + always_update: bool = False, ) -> Reactive: """A reactive variable that calls watchers and compute on initialize (post mount). @@ -67,11 +68,17 @@ class Reactive(Generic[ReactiveType]): default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. layout (bool, optional): Perform a layout on change. Defaults to False. repaint (bool, optional): Perform a repaint on change. Defaults to True. - + always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. Returns: Reactive: A Reactive instance which calls watchers or initialize. """ - return cls(default, layout=layout, repaint=repaint, init=True) + return cls( + default, + layout=layout, + repaint=repaint, + init=True, + always_update=always_update, + ) @classmethod def var( @@ -153,7 +160,7 @@ class Reactive(Generic[ReactiveType]): if callable(validate_function) and not first_set: value = validate_function(value) # If the value has changed, or this is the first time setting the value - if current_value != value or first_set: + if current_value != value or first_set or self._always_update: # Set the first set flag to False setattr(obj, f"__first_set_{self.internal_name}", False) # Store the internal value @@ -259,7 +266,7 @@ class reactive(Reactive[ReactiveType]): layout (bool, optional): Perform a layout on change. Defaults to False. repaint (bool, optional): Perform a repaint on change. Defaults to True. init (bool, optional): Call watchers on initialize (post mount). Defaults to True. - + always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. """ def __init__( @@ -269,8 +276,15 @@ class reactive(Reactive[ReactiveType]): layout: bool = False, repaint: bool = True, init: bool = True, + always_update: bool = False, ) -> None: - super().__init__(default, layout=layout, repaint=repaint, init=init) + super().__init__( + default, + layout=layout, + repaint=repaint, + init=init, + always_update=always_update, + ) class var(Reactive[ReactiveType]): From b6e19d5c301d08c359b4aa3a3fbcd01805ec9508 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 25 Oct 2022 14:39:39 +0100 Subject: [PATCH 02/41] Add note to docs about `always_update` --- docs/guide/reactivity.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index b4c0bdebc..18a0b5a02 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -165,7 +165,11 @@ If you click the buttons in the above example it will show the current count. Wh ## Watch methods -Watch methods are another superpower. Textual will call watch methods when reactive attributes are modified. Watch methods begin with `watch_` followed by the name of the attribute. If the watch method accepts a positional argument, it will be called with the new assigned value. If the watch method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value. +Watch methods are another superpower. +Textual will call watch methods when reactive attributes are modified. +Watch methods begin with `watch_` followed by the name of the attribute. +If the watch method accepts a positional argument, it will be called with the new assigned value. +If the watch method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value. The following app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example `"darkorchid"` or `"#52de44"`. @@ -192,6 +196,12 @@ The following app will display any color you type in to the input. Try it with a The color is parsed in `on_input_submitted` and assigned to `self.color`. Because `color` is reactive, Textual also calls `watch_color` with the old and new values. +!! warning + + Textual only calls watch methods if the value of a reactive attribute changes. + If the newly assigned value is the same as the previous value, the watch method is not called. + You can override this behaviour by passing `always_update=True`. + ## Compute methods Compute methods are the final superpower offered by the `reactive` descriptor. Textual runs compute methods to calculate the value of a reactive attribute. Compute methods begin with `compute_` followed by the name of the reactive value. From 91b2e03d6504dd15c3cbddae2dac32b476b8cc93 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 25 Oct 2022 15:32:50 +0100 Subject: [PATCH 03/41] Add Python 3.11 to CI, update install-poetry action --- .github/workflows/pythonpackage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index ee439baf0..5ac2288cb 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] defaults: run: shell: bash @@ -22,7 +22,7 @@ jobs: - name: Install and configure Poetry uses: snok/install-poetry@v1.1.6 with: - version: 1.1.6 + version: 1.3.3 virtualenvs-in-project: true - name: Install dependencies run: poetry install --extras "dev" From e99beb9ddaf09a72cd1061a8c51dc8aa0ce11628 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 25 Oct 2022 15:34:04 +0100 Subject: [PATCH 04/41] Remove debug print --- src/textual/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 4c8501b36..c044f6155 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1021,7 +1021,6 @@ class App(Generic[ReturnType], DOMNode): self.stylesheet.add_source( self.CSS, path=app_css_path, is_default_css=False ) - print(self.stylesheet) except Exception as error: self._handle_exception(error) self._print_error_renderables() From 7599accccf60761b9dcf026edf7aeb836e75aa15 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 25 Oct 2022 15:36:22 +0100 Subject: [PATCH 05/41] Fix GitHub action install-poetry version --- .github/workflows/pythonpackage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 5ac2288cb..ad5e23f6f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -20,9 +20,9 @@ jobs: python-version: ${{ matrix.python-version }} architecture: x64 - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.6 + uses: snok/install-poetry@v1.3.3 with: - version: 1.3.3 + version: 1.2.2 virtualenvs-in-project: true - name: Install dependencies run: poetry install --extras "dev" From 5933cbb7e8c72b0a9b01e3d517690faf63ca2276 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 25 Oct 2022 15:38:54 +0100 Subject: [PATCH 06/41] Revert build action to 3.11-rc.2 until Darwin build ready --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index ad5e23f6f..99cdc0dbd 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11-rc.2"] defaults: run: shell: bash From 98fdb9d1a11b89a5741448455f4f5140b0113cff Mon Sep 17 00:00:00 2001 From: UriyaHarpeness Date: Tue, 25 Oct 2022 19:32:14 +0300 Subject: [PATCH 07/41] Fix some inaccuracies in the tutorial. --- docs/examples/tutorial/stopwatch03.css | 3 ++- docs/examples/tutorial/stopwatch04.css | 2 +- docs/tutorial.md | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/examples/tutorial/stopwatch03.css b/docs/examples/tutorial/stopwatch03.css index b911dfeae..44df216e1 100644 --- a/docs/examples/tutorial/stopwatch03.css +++ b/docs/examples/tutorial/stopwatch03.css @@ -2,8 +2,9 @@ Stopwatch { layout: horizontal; background: $boost; height: 5; - padding: 1; margin: 1; + min-width: 50; + padding: 1; } TimeDisplay { diff --git a/docs/examples/tutorial/stopwatch04.css b/docs/examples/tutorial/stopwatch04.css index 2bc514b00..27022ecfb 100644 --- a/docs/examples/tutorial/stopwatch04.css +++ b/docs/examples/tutorial/stopwatch04.css @@ -2,8 +2,8 @@ Stopwatch { layout: horizontal; background: $boost; height: 5; - min-width: 50; margin: 1; + min-width: 50; padding: 1; } diff --git a/docs/tutorial.md b/docs/tutorial.md index 7fa4486cd..5851996a9 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -108,7 +108,7 @@ Let's examine `stopwatch01.py` in more detail. --8<-- "docs/examples/tutorial/stopwatch01.py" ``` -The first line imports the Textual `App` class, which we will use as the base class for our App. The second line imports two builtin widgets: `Footer` which shows a bar at the bottom of the screen with current keys, and `Header` which shows a title and the current time at the top of the screen. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build widgets in this tutorial. +The first line imports the Textual `App` class, which we will use as the base class for our App. The second line imports two builtin widgets: `Footer` which shows a bar at the bottom of the screen with bound keys, and `Header` which shows a title at the top of the screen. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build widgets in this tutorial. The following lines define the app itself: @@ -233,8 +233,9 @@ Stopwatch { layout: horizontal; background: $boost; height: 5; - padding: 1; margin: 1; + min-width: 50; + padding: 1; } ``` @@ -249,8 +250,9 @@ Here's how this CSS code changes how the `Stopwatch` widget is displayed. - `layout: horizontal` aligns child widgets horizontally from left to right. - `background: $boost` sets the background color to `$boost`. The `$` prefix picks a pre-defined color from the builtin theme. There are other ways to specify colors such as `"blue"` or `rgb(20,46,210)`. - `height: 5` sets the height of our widget to 5 lines of text. -- `padding: 1` sets a padding of 1 cell around the child widgets. - `margin: 1` sets a margin of 1 cell around the `Stopwatch` widget to create a little space between widgets in the list. +- `min-width: 50` sets the minimum width of our widget to 50 cells. +- `padding: 1` sets a padding of 1 cell around the child widgets. Here's the rest of `stopwatch03.css` which contains further declaration blocks: From ad47b9167ecc4c2598884f4d3ec122c96bacb44f Mon Sep 17 00:00:00 2001 From: UriyaHarpeness Date: Wed, 26 Oct 2022 08:27:19 +0300 Subject: [PATCH 08/41] Fix some typos in docs. --- docs/guide/devtools.md | 2 +- docs/guide/events.md | 14 +++++++------- docs/tutorial.md | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index 59e9a805f..fa0d1e7e9 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -44,7 +44,7 @@ If you combine the `run` command with the `--dev` switch your app will run in *d textual run --dev my_app.py ``` -One of the the features of *dev* mode is live editing of CSS files: any changes to your CSS will be reflected in the terminal a few milliseconds later. +One of the features of *dev* mode is live editing of CSS files: any changes to your CSS will be reflected in the terminal a few milliseconds later. This is a great feature for iterating on your app's look and feel. Open the CSS in your editor and have your app running in a terminal. Edits to your CSS will appear almost immediately after you save. diff --git a/docs/guide/events.md b/docs/guide/events.md index 51821aeca..2fa5195c2 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -4,7 +4,7 @@ We've used event handler methods in many of the examples in this guide. This cha ## Messages -Events are a particular kind of *message* sent by Textual in response to input and other state changes. Events are reserved for use by Textual but you can also create custom messages for the purpose of coordinating between widgets in your app. +Events are a particular kind of *message* sent by Textual in response to input and other state changes. Events are reserved for use by Textual, but you can also create custom messages for the purpose of coordinating between widgets in your app. More on that later, but for now keep in mind that events are also messages, and anything that is true of messages is true of events. @@ -12,7 +12,7 @@ More on that later, but for now keep in mind that events are also messages, and Every [App][textual.app.App] and [Widget][textual.widget.Widget] object contains a *message queue*. You can think of a message queue as orders at a restaurant. The chef takes an order and makes the dish. Orders that arrive while the chef is cooking are placed in a line. When the chef has finished a dish they pick up the next order in the line. -Textual processes messages in the same way. Messages are picked off a queue and processed (cooked) by a handler method. This guarantees messages and events are processed even if your code can not handle them right way. +Textual processes messages in the same way. Messages are picked off a queue and processed (cooked) by a handler method. This guarantees messages and events are processed even if your code can not handle them right away. This processing of messages is done within an asyncio Task which is started when you mount the widget. The task monitors a queue for new messages and dispatches them to the appropriate handler when they arrive. @@ -28,7 +28,7 @@ The widget's task will pick the first message from the queue (a key event for th --8<-- "docs/images/events/queue.excalidraw.svg" -When the `on_key` method returns, Textual will get the next event from the the queue and repeat the process for the remaining keys. At some point the queue will be empty and the widget is said to be in an *idle* state. +When the `on_key` method returns, Textual will get the next event from the queue and repeat the process for the remaining keys. At some point the queue will be empty and the widget is said to be in an *idle* state. !!! note @@ -75,7 +75,7 @@ As before, the event bubbles to its parent (the App class). --8<-- "docs/images/events/bubble3.excalidraw.svg" -The App class is always the root of the DOM, so there is no where for the event to bubble to. +The App class is always the root of the DOM, so there is nowhere for the event to bubble to. ### Stopping bubbling @@ -110,7 +110,7 @@ The message class is defined within the widget class itself. This is not strictl ## Sending events -In the previous example we used [emit()][textual.message_pump.MessagePump.emit] to send an event to it's parent. We could also have used [emit_no_wait()][textual.message_pump.MessagePump.emit_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used. +In the previous example we used [emit()][textual.message_pump.MessagePump.emit] to send an event to its parent. We could also have used [emit_no_wait()][textual.message_pump.MessagePump.emit_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used. There are other ways of sending (posting) messages, which you may need to use less frequently. @@ -127,7 +127,7 @@ Most of the logic in a Textual app will be written in message handlers. Let's ex Textual uses the following scheme to map messages classes on to a Python method. - Start with `"on_"`. -- Add the messages namespace (if any) converted from CamelCase to snake_case plus an underscore `"_"` +- Add the messages' namespace (if any) converted from CamelCase to snake_case plus an underscore `"_"` - Add the name of the class converted from CamelCase to snake_case.
@@ -156,7 +156,7 @@ This pattern is a convenience that saves writing out a parameter that may not be Message handlers may be coroutines. If you prefix your handlers with the `async` keyword, Textual will `await` them. This lets your handler use the `await` keyword for asynchronous APIs. -If your event handlers are coroutines it will allow multiple events to be processed concurrently, but bear in mind an individual widget (or app) will not be able to pick up a new message from its message queue until the handler has returned. This is rarely a problem in practice; as long has handlers return within a few milliseconds the UI will remain responsive. But slow handlers might make your app hard to use. +If your event handlers are coroutines it will allow multiple events to be processed concurrently, but bear in mind an individual widget (or app) will not be able to pick up a new message from its message queue until the handler has returned. This is rarely a problem in practice; as long as handlers return within a few milliseconds the UI will remain responsive. But slow handlers might make your app hard to use. !!! info diff --git a/docs/tutorial.md b/docs/tutorial.md index 5851996a9..b4f44b60c 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -165,7 +165,7 @@ The Stopwatch widget class also extends `Static`. This class has a `compose()` m #### The buttons -The Button constructor takes a label to be displayed in the button (`"Start"`, `"Stop"`, or `"Reset"`). Additionally some of the buttons set the following parameters: +The Button constructor takes a label to be displayed in the button (`"Start"`, `"Stop"`, or `"Reset"`). Additionally, some of the buttons set the following parameters: - `id` is an identifier we can use to tell the buttons apart in code and apply styles. More on that later. - `variant` is a string which selects a default style. The "success" variant makes the button green, and the "error" variant makes it red. @@ -290,7 +290,7 @@ The last 3 blocks have a slightly different format. When the declaration begins The buttons have a `dock` style which aligns the widget to a given edge. The start and stop buttons are docked to the left edge, while the reset button is docked to the right edge. -You may have noticed that the stop button (`#stop` in the CSS) has `display: none;`. This tells Textual to not show the button. We do this because we don't want to display the stop button when the timer is *not* running. Similarly we don't want to show the start button when the timer is running. We will cover how to manage such dynamic user interfaces in the next section. +You may have noticed that the stop button (`#stop` in the CSS) has `display: none;`. This tells Textual to not show the button. We do this because we don't want to display the stop button when the timer is *not* running. Similarly, we don't want to show the start button when the timer is running. We will cover how to manage such dynamic user interfaces in the next section. ### Dynamic CSS @@ -335,7 +335,7 @@ The following code will start or stop the stopwatches in response to clicking a --8<-- "docs/examples/tutorial/stopwatch04.py" ``` -The `on_button_pressed` method is an *event handler*. Event handlers are methods called by Textual in response to an *event* such as a key press, mouse click, etc. Event handlers begin with `on_` followed by the name of the event they will handler. Hence `on_button_pressed` will handle the button pressed event. +The `on_button_pressed` method is an *event handler*. Event handlers are methods called by Textual in response to an *event* such as a key press, mouse click, etc. Event handlers begin with `on_` followed by the name of the event they will handle. Hence `on_button_pressed` will handle the button pressed event. If you run `stopwatch04.py` now you will be able to toggle between the two states by clicking the first button: From ceed9186c94c98b5c644e019f216ab552bdabac8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 27 Oct 2022 10:44:06 +0100 Subject: [PATCH 09/41] Python 3.11 CI --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 99cdc0dbd..ad5e23f6f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11-rc.2"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] defaults: run: shell: bash From 8f888e7232b30fa16d3ae6829ad7708f0e795bc9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 27 Oct 2022 10:52:32 +0100 Subject: [PATCH 10/41] Add small delay before screenshot is taken --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index 43f1bd56c..0f1282c66 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -641,6 +641,7 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.02) await app._animator.wait_for_idle() + await asyncio.sleep(0.05) if screenshot: self._screenshot = self.export_screenshot( From ac3d756e5127cb51a7fb11014b84b20c82217122 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 27 Oct 2022 17:43:02 +0100 Subject: [PATCH 11/41] unmount event --- CHANGELOG.md | 4 + sandbox/will/pride.py | 30 ++ src/textual/app.py | 368 +++++++++++++++++++------ src/textual/events.py | 4 + src/textual/message_pump.py | 12 +- src/textual/messages.py | 10 + src/textual/widget.py | 3 + tests/css/test_styles.py | 87 ------ tests/snapshot_tests/test_snapshots.py | 22 +- tests/test_integration_scrolling.py | 116 -------- tests/test_screens.py | 2 +- tests/utilities/test_app.py | 353 ------------------------ 12 files changed, 360 insertions(+), 651 deletions(-) create mode 100644 sandbox/will/pride.py delete mode 100644 tests/test_integration_scrolling.py delete mode 100644 tests/utilities/test_app.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4f09490..b7d55e99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - DOMQuery now raises InvalidQueryFormat in response to invalid query strings, rather than cryptic CSS error +### Added + +- Added App.run_async method + ## [0.2.1] - 2022-10-23 ### Changed diff --git a/sandbox/will/pride.py b/sandbox/will/pride.py new file mode 100644 index 000000000..a51f474bb --- /dev/null +++ b/sandbox/will/pride.py @@ -0,0 +1,30 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class PrideApp(App): + """Displays a pride flag.""" + + COLORS = ["red", "orange", "yellow", "green", "blue", "purple"] + + def compose(self) -> ComposeResult: + for color in self.COLORS: + stripe = Static() + stripe.styles.height = "1fr" + stripe.styles.background = color + yield stripe + + +if __name__ == "__main__": + app = PrideApp() + + from rich import print + + async def run_app(): + async with app.run_async(quit_after=5) as result: + print(result) + print(app.tree) + + import asyncio + + asyncio.run(run_app()) diff --git a/src/textual/app.py b/src/textual/app.py index 43f1bd56c..0a82bf700 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from contextlib import asynccontextmanager import inspect import io import os @@ -241,6 +242,11 @@ class App(Generic[ReturnType], DOMNode): ) self._screenshot: str | None = None + @property + def return_value(self) -> ReturnType | None: + """Get the return type.""" + return self._return_value + def animate( self, attribute: str, @@ -314,7 +320,7 @@ class App(Generic[ReturnType], DOMNode): result (ReturnType | None, optional): Return value. Defaults to None. """ self._return_value = result - self._close_messages_no_wait() + self.post_message_no_wait(messages.ExitApp(sender=self)) @property def focused(self) -> Widget | None: @@ -566,6 +572,95 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) + @classmethod + async def _press_keys(cls, app: App, press: Iterable[str]) -> None: + """A task to send key events.""" + assert press + driver = app._driver + assert driver is not None + await asyncio.sleep(0.02) + for key in press: + if key == "_": + print("(pause 50ms)") + await asyncio.sleep(0.05) + elif key.startswith("wait:"): + _, wait_ms = key.split(":") + print(f"(pause {wait_ms}ms)") + await asyncio.sleep(float(wait_ms) / 1000) + else: + if len(key) == 1 and not key.isalnum(): + key = ( + unicodedata.name(key) + .lower() + .replace("-", "_") + .replace(" ", "_") + ) + original_key = REPLACED_KEYS.get(key, key) + char: str | None + try: + char = unicodedata.lookup(original_key.upper().replace("_", " ")) + except KeyError: + char = key if len(key) == 1 else None + print(f"press {key!r} (char={char!r})") + key_event = events.Key(app, key, char) + driver.send_event(key_event) + # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race + # condition between widget-level key handling and app/screen level handling. + # More information here: https://github.com/Textualize/textual/issues/1009 + # This conditional sleep can be removed after that issue is closed. + if key == "tab": + await asyncio.sleep(0.05) + await asyncio.sleep(0.02) + await app._animator.wait_for_idle() + print("EXITING") + app.exit() + + @asynccontextmanager + async def run_async( + self, + *, + headless: bool = False, + quit_after: float | None = None, + press: Iterable[str] | None = None, + ): + """Run the app asynchronously. This is an async context manager, which shuts down the app on exit. + + Example: + async def run_app(): + app = MyApp() + async with app.run_async() as result: + print(result) + + Args: + quit_after (float | None, optional): Quit after a given number of seconds, or None + to run forever. Defaults to None. + headless (bool, optional): Run in "headless" mode (don't write to stdout). + press (str, optional): An iterable of keys to simulate being pressed. + + """ + app = self + if headless: + app.features = cast( + "frozenset[FeatureFlag]", app.features.union({"headless"}) + ) + + if quit_after is not None: + app.set_timer(quit_after, app.exit) + + if press is not None: + + async def press_keys_task(): + asyncio.create_task(self._press_keys(app, press)) + + await app._process_messages(ready_callback=press_keys_task) + + else: + await app._process_messages() + + yield app.return_value + + await app._shutdown() + def run( self, *, @@ -589,72 +684,12 @@ class App(Generic[ReturnType], DOMNode): ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. """ - if headless: - self.features = cast( - "frozenset[FeatureFlag]", self.features.union({"headless"}) - ) - async def run_app() -> None: - if quit_after is not None: - self.set_timer(quit_after, self.shutdown) - if press is not None: - app = self - - async def press_keys() -> None: - """A task to send key events.""" - assert press - driver = app._driver - assert driver is not None - await asyncio.sleep(0.02) - for key in press: - if key == "_": - print("(pause 50ms)") - await asyncio.sleep(0.05) - elif key.startswith("wait:"): - _, wait_ms = key.split(":") - print(f"(pause {wait_ms}ms)") - await asyncio.sleep(float(wait_ms) / 1000) - else: - if len(key) == 1 and not key.isalnum(): - key = ( - unicodedata.name(key) - .lower() - .replace("-", "_") - .replace(" ", "_") - ) - original_key = REPLACED_KEYS.get(key, key) - try: - char = unicodedata.lookup( - original_key.upper().replace("_", " ") - ) - except KeyError: - char = key if len(key) == 1 else None - print(f"press {key!r} (char={char!r})") - key_event = events.Key(self, key, char) - driver.send_event(key_event) - # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race - # condition between widget-level key handling and app/screen level handling. - # More information here: https://github.com/Textualize/textual/issues/1009 - # This conditional sleep can be removed after that issue is closed. - if key == "tab": - await asyncio.sleep(0.05) - await asyncio.sleep(0.02) - - await app._animator.wait_for_idle() - - if screenshot: - self._screenshot = self.export_screenshot( - title=screenshot_title - ) - await self.shutdown() - - async def press_keys_task(): - """Press some keys in the background.""" - asyncio.create_task(press_keys()) - - await self._process_messages(ready_callback=press_keys_task) - else: - await self._process_messages() + async with self.run_async( + quit_after=quit_after, headless=headless, press=press + ): + if screenshot: + self._screenshot = self.export_screenshot(title=screenshot_title) if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: @@ -663,8 +698,107 @@ class App(Generic[ReturnType], DOMNode): # However, this works with Python<3.10: event_loop = asyncio.get_event_loop() event_loop.run_until_complete(run_app()) + return self.return_value - return self._return_value + # def run( + # self, + # *, + # quit_after: float | None = None, + # headless: bool = False, + # press: Iterable[str] | None = None, + # screenshot: bool = False, + # screenshot_title: str | None = None, + # ) -> ReturnType | None: + # """The main entry point for apps. + + # Args: + # quit_after (float | None, optional): Quit after a given number of seconds, or None + # to run forever. Defaults to None. + # headless (bool, optional): Run in "headless" mode (don't write to stdout). + # press (str, optional): An iterable of keys to simulate being pressed. + # screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. + # screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. + + # Returns: + # ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. + # """ + + # if headless: + # self.features = cast( + # "frozenset[FeatureFlag]", self.features.union({"headless"}) + # ) + + # async def run_app() -> None: + # if quit_after is not None: + # self.set_timer(quit_after, self.shutdown) + # if press is not None: + # app = self + + # async def press_keys() -> None: + # """A task to send key events.""" + # assert press + # driver = app._driver + # assert driver is not None + # await asyncio.sleep(0.02) + # for key in press: + # if key == "_": + # print("(pause 50ms)") + # await asyncio.sleep(0.05) + # elif key.startswith("wait:"): + # _, wait_ms = key.split(":") + # print(f"(pause {wait_ms}ms)") + # await asyncio.sleep(float(wait_ms) / 1000) + # else: + # if len(key) == 1 and not key.isalnum(): + # key = ( + # unicodedata.name(key) + # .lower() + # .replace("-", "_") + # .replace(" ", "_") + # ) + # original_key = REPLACED_KEYS.get(key, key) + # try: + # char = unicodedata.lookup( + # original_key.upper().replace("_", " ") + # ) + # except KeyError: + # char = key if len(key) == 1 else None + # print(f"press {key!r} (char={char!r})") + # key_event = events.Key(self, key, char) + # driver.send_event(key_event) + # # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race + # # condition between widget-level key handling and app/screen level handling. + # # More information here: https://github.com/Textualize/textual/issues/1009 + # # This conditional sleep can be removed after that issue is closed. + # if key == "tab": + # await asyncio.sleep(0.05) + # await asyncio.sleep(0.02) + + # await app._animator.wait_for_idle() + + # if screenshot: + # self._screenshot = self.export_screenshot( + # title=screenshot_title + # ) + # await self.shutdown() + + # async def press_keys_task(): + # """Press some keys in the background.""" + # asyncio.create_task(press_keys()) + + # await self._process_messages(ready_callback=press_keys_task) + # else: + # await self._process_messages() + + # if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: + # # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: + # asyncio.run(run_app()) + # else: + # # However, this works with Python<3.10: + # event_loop = asyncio.get_event_loop() + # event_loop.run_until_complete(run_app()) + + # return self._return_value async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" @@ -1037,7 +1171,7 @@ class App(Generic[ReturnType], DOMNode): self.log.system("[b green]STARTED[/]", self.css_monitor) async def run_process_messages(): - + """The main message look, invoke below.""" try: await self._dispatch_message(events.Compose(sender=self)) await self._dispatch_message(events.Mount(sender=self)) @@ -1066,7 +1200,6 @@ class App(Generic[ReturnType], DOMNode): await timer.stop() await self.animator.stop() - await self._close_all() self._running = True try: @@ -1104,11 +1237,11 @@ class App(Generic[ReturnType], DOMNode): driver.stop_application_mode() except Exception as error: self._handle_exception(error) - finally: - self._running = False - self._print_error_renderables() - if self.devtools is not None and self.devtools.is_connected: - await self._disconnect_devtools() + # finally: + # self._running = False + # self._print_error_renderables() + # if self.devtools is not None and self.devtools.is_connected: + # await self._disconnect_devtools() async def _pre_process(self) -> None: pass @@ -1133,7 +1266,7 @@ class App(Generic[ReturnType], DOMNode): """Used by docs plugin.""" svg = self.export_screenshot(title=screenshot_title) self._screenshot = svg # type: ignore - await self.shutdown() + self.exit() self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") @@ -1232,17 +1365,40 @@ class App(Generic[ReturnType], DOMNode): return widget in self._registry async def _close_all(self) -> None: - while self._registry: - child = self._registry.pop() + """Close all message pumps.""" + + # Close all screens on the stack + for screen in self._screen_stack: + await self._prune_node(screen) + + # Close pre-defined screens + for screen in self.SCREENS.values(): + if screen._running: + await self._prune_node(screen) + + # Close any remaining nodes + # Should be empty by now + remaining_nodes = list(self._registry) + for child in remaining_nodes: await child._close_messages() - async def shutdown(self): - await self._disconnect_devtools() + async def _shutdown(self) -> None: driver = self._driver if driver is not None: driver.disable_input() + await self._close_all() await self._close_messages() + await self._dispatch_message(events.UnMount(sender=self)) + + self._running = False + self._print_error_renderables() + if self.devtools is not None and self.devtools.is_connected: + await self._disconnect_devtools() + + async def _on_exit_app(self) -> None: + await self._message_queue.put(None) + def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: if self._screen_stack: self.screen.refresh(repaint=repaint, layout=layout) @@ -1496,18 +1652,64 @@ class App(Generic[ReturnType], DOMNode): [to_remove for to_remove in remove_widgets if to_remove.can_focus], ) - for child in remove_widgets: - await child._close_messages() - self._unregister(child) + await self._prune_node(widget) + + # for child in remove_widgets: + # await child._close_messages() + # self._unregister(child) if parent is not None: parent.refresh(layout=True) + def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: + """Walk children depth first, generating widgets and a list of their siblings. + + Returns: + Iterable[list[Widget]]: + + """ + stack: list[Widget] = [root] + pop = stack.pop + push = stack.append + + while stack: + widget = pop() + if widget.children: + yield list(widget.children) + for child in widget.children: + push(child) + + async def _prune_node(self, root: Widget) -> None: + """Remove a node and its children. Children are removed before parents. + + Args: + root (Widget): Node to remove. + """ + # Pruning a node that has been removed is a no-op + if root not in self._registry: + return + + node_children = list(self._walk_children(root)) + + for children in reversed(node_children): + # Closing children can be done asynchronously. + close_messages = [ + child._close_messages() for child in children if child._running + ] + # TODO: What if a message pump refuses to exit? + if close_messages: + await asyncio.gather(*close_messages) + for child in children: + self._unregister(child) + + await root._close_messages() + self._unregister(root) + async def action_check_bindings(self, key: str) -> None: await self.check_bindings(key) async def action_quit(self) -> None: """Quit the app as soon as possible.""" - await self.shutdown() + self.exit() async def action_bang(self) -> None: 1 / 0 diff --git a/src/textual/events.py b/src/textual/events.py index ee84b929f..f5742ac03 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -123,6 +123,10 @@ class Mount(Event, bubble=False, verbose=True): """Sent when a widget is *mounted* and may receive messages.""" +class UnMount(Mount, bubble=False, verbose=False): + """Sent when a widget is unmounted and may not longer receive messages.""" + + class Remove(Event, bubble=False): """Sent to a widget to ask it to remove itself from the DOM.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7d7712a81..2102602e1 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -267,7 +267,10 @@ class MessagePump(metaclass=MessagePumpMeta): def _close_messages_no_wait(self) -> None: """Request the message queue to exit.""" - self._message_queue.put_nowait(None) + self._message_queue.put_nowait(messages.CloseMessages(sender=self)) + + async def _on_close_messages(self, message: messages.CloseMessages) -> None: + await self._close_messages() async def _close_messages(self) -> None: """Close message queue, and optionally wait for queue to finish processing.""" @@ -278,6 +281,7 @@ class MessagePump(metaclass=MessagePumpMeta): for timer in stop_timers: await timer.stop() self._timers.clear() + await self._message_queue.put(events.UnMount(sender=self)) await self._message_queue.put(None) if self._task is not None and asyncio.current_task() != self._task: # Ensure everything is closed before returning @@ -370,7 +374,7 @@ class MessagePump(metaclass=MessagePumpMeta): self.app._handle_exception(error) break - log("CLOSED", self) + # log("CLOSED", self) async def _dispatch_message(self, message: Message) -> None: """Dispatch a message received from the message queue. @@ -424,6 +428,7 @@ class MessagePump(metaclass=MessagePumpMeta): handler_name = message._handler_name # Look through the MRO to find a handler + dispatched = False for cls, method in self._get_dispatch_methods(handler_name, message): log.event.verbosity(message.verbose)( message, @@ -431,7 +436,10 @@ class MessagePump(metaclass=MessagePumpMeta): self, f"method=<{cls.__name__}.{handler_name}>", ) + dispatched = True await invoke(method, message) + if not dispatched: + log.event.verbose(message, ">>>", self, "method=None") # Bubble messages up the DOM (if enabled on the message) if message.bubble and self._parent and not message._stop_propagation: diff --git a/src/textual/messages.py b/src/textual/messages.py index 2b1ff4792..f23a1a8a8 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -13,6 +13,16 @@ if TYPE_CHECKING: from .widget import Widget +@rich.repr.auto +class CloseMessages(Message, verbose=True): + """Requests message pump to close.""" + + +@rich.repr.auto +class ExitApp(Message, verbose=True): + """Exit the app.""" + + @rich.repr.auto class Update(Message, verbose=True): def __init__(self, sender: MessagePump, widget: Widget): diff --git a/src/textual/widget.py b/src/textual/widget.py index 363c1f518..b28a8bd33 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2049,3 +2049,6 @@ class Widget(DOMNode): self.scroll_page_up() return True return False + + # def _on_un_mount(self) -> None: + # self.log.debug("UNMOUNTED", self) diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index a7c574ecc..8819a6839 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -18,8 +18,6 @@ from textual.css.styles import Styles, RenderStyles from textual.dom import DOMNode from textual.widget import Widget -from tests.utilities.test_app import AppTest - def test_styles_reset(): styles = Styles() @@ -206,88 +204,3 @@ def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_in with pytest.raises(StyleValueError): widget.styles.width = size_dimension_input - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "overflow_y,scrollbar_gutter,scrollbar_size,text_length,expected_text_widget_width,expects_vertical_scrollbar", - ( - # ------------------------------------------------ - # ----- Let's start with `overflow-y: auto`: - # short text: full width, no scrollbar - ["auto", "auto", 1, "short_text", 80, False], - # long text: reduced width, scrollbar - ["auto", "auto", 1, "long_text", 78, True], - # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar - ["auto", "stable", 1, "short_text", 78, False], - # long text, `scrollbar-gutter: stable`: reduced width, scrollbar - ["auto", "stable", 1, "long_text", 78, True], - # ------------------------------------------------ - # ----- And now let's see the behaviour with `overflow-y: scroll`: - # short text: reduced width, scrollbar - ["scroll", "auto", 1, "short_text", 78, True], - # long text: reduced width, scrollbar - ["scroll", "auto", 1, "long_text", 78, True], - # short text, `scrollbar-gutter: stable`: reduced width, scrollbar - ["scroll", "stable", 1, "short_text", 78, True], - # long text, `scrollbar-gutter: stable`: reduced width, scrollbar - ["scroll", "stable", 1, "long_text", 78, True], - # ------------------------------------------------ - # ----- Finally, let's check the behaviour with `overflow-y: hidden`: - # short text: full width, no scrollbar - ["hidden", "auto", 1, "short_text", 80, False], - # long text: full width, no scrollbar - ["hidden", "auto", 1, "long_text", 80, False], - # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar - ["hidden", "stable", 1, "short_text", 78, False], - # long text, `scrollbar-gutter: stable`: reduced width, no scrollbar - ["hidden", "stable", 1, "long_text", 78, False], - # ------------------------------------------------ - # ----- Bonus round with a custom scrollbar size, now that we can set this: - ["auto", "auto", 3, "short_text", 80, False], - ["auto", "auto", 3, "long_text", 77, True], - ["scroll", "auto", 3, "short_text", 77, True], - ["scroll", "stable", 3, "short_text", 77, True], - ["hidden", "auto", 3, "long_text", 80, False], - ["hidden", "stable", 3, "short_text", 77, False], - ), -) -async def test_scrollbar_gutter( - overflow_y: str, - scrollbar_gutter: str, - scrollbar_size: int, - text_length: Literal["short_text", "long_text"], - expected_text_widget_width: int, - expects_vertical_scrollbar: bool, -): - from rich.text import Text - from textual.geometry import Size - - class TextWidget(Widget): - def render(self) -> Text: - text_multiplier = 10 if text_length == "long_text" else 2 - return Text( - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a." - * text_multiplier - ) - - container = Widget() - container.styles.height = 3 - container.styles.overflow_y = overflow_y - container.styles.scrollbar_gutter = scrollbar_gutter - if scrollbar_size > 1: - container.styles.scrollbar_size_vertical = scrollbar_size - - text_widget = TextWidget() - text_widget.styles.height = "auto" - container._add_child(text_widget) - - class MyTestApp(AppTest): - def compose(self) -> ComposeResult: - yield container - - app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10)) - await app.boot_and_shutdown() - - assert text_widget.outer_size.width == expected_text_widget_width - assert container.scrollbars_enabled[0] is expects_vertical_scrollbar diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2f04c9314..0c2f6f5f5 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -8,6 +8,7 @@ from textual.widgets import Input, Button # --- Layout related stuff --- + def test_grid_layout_basic(snap_compare): assert snap_compare("docs/examples/guide/layout/grid_layout1.py") @@ -41,6 +42,7 @@ def test_dock_layout_sidebar(snap_compare): # When adding a new widget, ideally we should also create a snapshot test # from these examples which test rendering and simple interactions with it. + def test_checkboxes(snap_compare): """Tests checkboxes but also acts a regression test for using width: auto in a Horizontal layout context.""" @@ -65,20 +67,20 @@ def test_input_and_focus(snap_compare): assert snap_compare("docs/examples/widgets/input.py", press=press) # Assert that the state of the Input is what we'd expect - app: App = snap_compare.app - input: Input = app.query_one(Input) - assert input.value == "Darren" - assert input.cursor_position == 6 - assert input.view_position == 0 + # app: App = snap_compare.app + # input: Input = app.query_one(Input) + # assert input.value == "Darren" + # assert input.cursor_position == 6 + # assert input.view_position == 0 def test_buttons_render(snap_compare): # Testing button rendering. We press tab to focus the first button too. assert snap_compare("docs/examples/widgets/button.py", press=["tab"]) - app = snap_compare.app - button: Button = app.query_one(Button) - assert app.focused is button + # app = snap_compare.app + # button: Button = app.query_one(Button) + # assert app.focused is button def test_datatable_render(snap_compare): @@ -99,7 +101,9 @@ def test_header_render(snap_compare): # If any of these change, something has likely broken, so snapshot each of them. PATHS = [ - str(PurePosixPath(path)) for path in Path("docs/examples/styles").iterdir() if path.suffix == ".py" + str(PurePosixPath(path)) + for path in Path("docs/examples/styles").iterdir() + if path.suffix == ".py" ] diff --git a/tests/test_integration_scrolling.py b/tests/test_integration_scrolling.py deleted file mode 100644 index 0cd862977..000000000 --- a/tests/test_integration_scrolling.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -from typing import Sequence, cast - -import pytest - -from tests.utilities.test_app import AppTest -from textual.app import ComposeResult -from textual.geometry import Size -from textual.widget import Widget -from textual.widgets import Placeholder - -pytestmark = pytest.mark.integration_test - -SCREEN_SIZE = Size(100, 30) - - -@pytest.mark.skip("Needs a rethink") -@pytest.mark.asyncio -@pytest.mark.parametrize( - ( - "screen_size", - "placeholders_count", - "scroll_to_placeholder_id", - "scroll_to_animate", - "waiting_duration", - "last_screen_expected_placeholder_ids", - ), - ( - [SCREEN_SIZE, 10, None, None, 0.01, (0, 1, 2, 3, 4)], - [SCREEN_SIZE, 10, "placeholder_3", False, 0.01, (0, 1, 2, 3, 4)], - [SCREEN_SIZE, 10, "placeholder_5", False, 0.01, (1, 2, 3, 4, 5)], - [SCREEN_SIZE, 10, "placeholder_7", False, 0.01, (3, 4, 5, 6, 7)], - [SCREEN_SIZE, 10, "placeholder_9", False, 0.01, (5, 6, 7, 8, 9)], - # N.B. Scroll duration is hard-coded to 0.2 in the `scroll_to_widget` method atm - # Waiting for this duration should allow us to see the scroll finished: - [SCREEN_SIZE, 10, "placeholder_9", True, 0.21, (5, 6, 7, 8, 9)], - # After having waited for approximately half of the scrolling duration, we should - # see the middle Placeholders as we're scrolling towards the last of them. - [SCREEN_SIZE, 10, "placeholder_9", True, 0.1, (4, 5, 6, 7, 8)], - ), -) -async def test_scroll_to_widget( - screen_size: Size, - placeholders_count: int, - scroll_to_animate: bool | None, - scroll_to_placeholder_id: str | None, - waiting_duration: float | None, - last_screen_expected_placeholder_ids: Sequence[int], -): - class VerticalContainer(Widget): - DEFAULT_CSS = """ - VerticalContainer { - layout: vertical; - overflow: hidden auto; - } - VerticalContainer Placeholder { - margin: 1 0; - height: 5; - } - """ - - class MyTestApp(AppTest): - DEFAULT_CSS = """ - Placeholder { - height: 5; /* minimal height to see the name of a Placeholder */ - } - """ - - def compose(self) -> ComposeResult: - placeholders = [ - Placeholder(id=f"placeholder_{i}", name=f"Placeholder #{i}") - for i in range(placeholders_count) - ] - - yield VerticalContainer(*placeholders, id="root") - - app = MyTestApp(size=screen_size, test_name="scroll_to_widget") - - async with app.in_running_state(waiting_duration_after_yield=waiting_duration or 0): - if scroll_to_placeholder_id: - target_widget_container = cast(Widget, app.query("#root").first()) - target_widget = cast( - Widget, app.query(f"#{scroll_to_placeholder_id}").first() - ) - target_widget_container.scroll_to_widget( - target_widget, animate=scroll_to_animate - ) - - last_display_capture = app.last_display_capture - - placeholders_visibility_by_id = { - id_: f"placeholder_{id_}" in last_display_capture - for id_ in range(placeholders_count) - } - print(placeholders_visibility_by_id) - # Let's start by checking placeholders that should be visible: - for placeholder_id in last_screen_expected_placeholder_ids: - assert placeholders_visibility_by_id[placeholder_id] is True, ( - f"Placeholder '{placeholder_id}' should be visible but isn't" - f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}" - ) - - # Ok, now for placeholders that should *not* be visible: - # We're simply going to check that all the placeholders that are not in - # `last_screen_expected_placeholder_ids` are not on the screen: - last_screen_expected_out_of_viewport_placeholder_ids = sorted( - tuple( - set(range(placeholders_count)) - set(last_screen_expected_placeholder_ids) - ) - ) - for placeholder_id in last_screen_expected_out_of_viewport_placeholder_ids: - assert placeholders_visibility_by_id[placeholder_id] is False, ( - f"Placeholder '{placeholder_id}' should not be visible but is" - f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}" - ) diff --git a/tests/test_screens.py b/tests/test_screens.py index cea3179e3..0841faf51 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -89,4 +89,4 @@ async def test_screens(): screen1.remove() screen2.remove() screen3.remove() - await app.shutdown() + await app._shutdown() diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py deleted file mode 100644 index 25678f28d..000000000 --- a/tests/utilities/test_app.py +++ /dev/null @@ -1,353 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import io -from math import ceil -from pathlib import Path -from time import monotonic -from typing import AsyncContextManager, cast, ContextManager -from unittest import mock - -from rich.console import Console - -from textual import events, errors -from textual._ansi_sequences import SYNC_START -from textual._clock import _Clock -from textual._context import active_app -from textual.app import App, ComposeResult -from textual.app import WINDOWS -from textual.driver import Driver -from textual.geometry import Size, Region - - -# N.B. These classes would better be named TestApp/TestConsole/TestDriver/etc, -# but it makes pytest emit warning as it will try to collect them as classes containing test cases :-/ - - -class AppTest(App): - def __init__(self, *, test_name: str, size: Size): - # Tests will log in "/tests/test.[test name].log": - log_path = Path(__file__).parent.parent / f"test.{test_name}.log" - super().__init__( - driver_class=DriverTest, - ) - - # Let's disable all features by default - self.features = frozenset() - - # We need this so the "start buffeting"` is always sent for a screen refresh, - # whatever the environment: - # (we use it to slice the output into distinct full screens displays) - self._sync_available = True - - self._size = size - self._console = ConsoleTest(width=size.width, height=size.height) - self._error_console = ConsoleTest(width=size.width, height=size.height) - - def log_tree(self) -> None: - """Handy shortcut when testing stuff""" - self.log(self.tree) - - def compose(self) -> ComposeResult: - raise NotImplementedError( - "Create a subclass of TestApp and override its `compose()` method, rather than using TestApp directly" - ) - - def in_running_state( - self, - *, - time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks - waiting_duration_after_initialisation: float = 1, - waiting_duration_after_yield: float = 0, - ) -> AsyncContextManager[ClockMock]: - async def run_app() -> None: - await self._process_messages() - - @contextlib.asynccontextmanager - async def get_running_state_context_manager(): - with mock_textual_timers( - ticks_granularity_fps=time_mocking_ticks_granularity_fps - ) as clock_mock: - run_task = asyncio.create_task(run_app()) - - # We have to do this because `run_app()` is running in its own async task, and our test is going to - # run in this one - so the app must also be the active App in our current context: - self._set_active() - - await clock_mock.advance_clock(waiting_duration_after_initialisation) - # make sure the App has entered its main loop at this stage: - assert self._driver is not None - - await self.force_full_screen_update() - - # And now it's time to pass the torch on to the test function! - # We provide the `move_clock_forward` function to it, - # so it can also do some time-based Textual stuff if it needs to: - yield clock_mock - - await clock_mock.advance_clock(waiting_duration_after_yield) - - # Make sure our screen is up-to-date before exiting the context manager, - # so tests using our `last_display_capture` for example can assert things on a fully refreshed screen: - await self.force_full_screen_update() - - # End of simulated time: we just shut down ourselves: - assert not run_task.done() - await self.shutdown() - await run_task - - return get_running_state_context_manager() - - async def boot_and_shutdown( - self, - *, - waiting_duration_after_initialisation: float = 0.001, - waiting_duration_before_shutdown: float = 0, - ): - """Just a commodity shortcut for `async with app.in_running_state(): pass`, for simple cases""" - async with self.in_running_state( - waiting_duration_after_initialisation=waiting_duration_after_initialisation, - waiting_duration_after_yield=waiting_duration_before_shutdown, - ): - pass - - def get_char_at(self, x: int, y: int) -> str: - """Get the character at the given cell or empty string - - Args: - x (int): X position within the Layout - y (int): Y position within the Layout - - Returns: - str: The character at the cell (x, y) within the Layout - """ - # N.B. Basically a copy-paste-and-slightly-adapt of `Compositor.get_style_at()` - try: - widget, region = self.get_widget_at(x, y) - except errors.NoWidget: - return "" - if widget not in self.screen._compositor.visible_widgets: - return "" - - x -= region.x - y -= region.y - lines = widget.render_lines(Region(0, y, region.width, 1)) - if not lines: - return "" - end = 0 - for segment in lines[0]: - end += segment.cell_length - if x < end: - return segment.text[0] - return "" - - async def force_full_screen_update( - self, *, repaint: bool = True, layout: bool = True - ) -> None: - try: - screen = self.screen - except IndexError: - return # the app may not have a screen yet - - # We artificially tell the Compositor that the whole area should be refreshed - screen._compositor._dirty_regions = { - Region(0, 0, screen.outer_size.width, screen.outer_size.height), - } - screen.refresh(repaint=repaint, layout=layout) - # We also have to make sure we have at least one dirty widget, or `screen._on_update()` will early return: - screen._dirty_widgets.add(screen) - screen._on_timer_update() - - await let_asyncio_process_some_events() - - def _handle_exception(self, error: Exception) -> None: - # In tests we want the errors to be raised, rather than printed to a Console - raise error - - def run(self): - raise NotImplementedError( - "Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`" - ) - - @property - def active_app(self) -> App | None: - return active_app.get() - - @property - def total_capture(self) -> str | None: - return self.console.file.getvalue() - - @property - def last_display_capture(self) -> str | None: - total_capture = self.total_capture - if not total_capture: - return None - screen_captures = total_capture.split(SYNC_START) - for single_screen_capture in reversed(screen_captures): - if len(single_screen_capture) > 30: - # let's return the last occurrence of a screen that seem to be properly "fully-paint" - return single_screen_capture - return None - - @property - def console(self) -> ConsoleTest: - return self._console - - @console.setter - def console(self, console: Console) -> None: - """This is a no-op, the console is always a TestConsole""" - return - - @property - def error_console(self) -> ConsoleTest: - return self._error_console - - @error_console.setter - def error_console(self, console: Console) -> None: - """This is a no-op, the error console is always a TestConsole""" - return - - -class ConsoleTest(Console): - def __init__(self, *, width: int, height: int): - file = io.StringIO() - super().__init__( - color_system="256", - file=file, - width=width, - height=height, - force_terminal=False, - legacy_windows=False, - ) - - @property - def file(self) -> io.StringIO: - return cast(io.StringIO, self._file) - - @property - def is_dumb_terminal(self) -> bool: - return False - - -class DriverTest(Driver): - def start_application_mode(self) -> None: - size = Size(self.console.size.width, self.console.size.height) - event = events.Resize(self._target, size, size) - asyncio.run_coroutine_threadsafe( - self._target.post_message(event), - loop=asyncio.get_running_loop(), - ) - - def disable_input(self) -> None: - pass - - def stop_application_mode(self) -> None: - pass - - -# It seems that we have to give _way more_ time to `asyncio` on Windows in order to see our different awaiters -# properly triggered when we pause our own "move clock forward" loop. -# It could be caused by the fact that the time resolution for `asyncio` on this platform seems rather low: -# > The resolution of the monotonic clock on Windows is usually around 15.6 msec. -# > The best resolution is 0.5 msec. -# @link https://docs.python.org/3/library/asyncio-platforms.html: -ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD = 0.025 if WINDOWS else 0.005 - - -async def let_asyncio_process_some_events() -> None: - await asyncio.sleep(ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD) - - -class ClockMock(_Clock): - # To avoid issues with floats we will store the current time as an integer internally. - # Tenths of microseconds should be a good enough granularity: - TIME_RESOLUTION = 10_000_000 - - def __init__( - self, - *, - ticks_granularity_fps: int = 60, - ): - self._ticks_granularity_fps = ticks_granularity_fps - self._single_tick_duration = int(self.TIME_RESOLUTION / ticks_granularity_fps) - self._start_time: int = -1 - self._current_time: int = -1 - # For each call to our `sleep` method we will store an asyncio.Event - # and the time at which we should trigger it: - self._pending_sleep_events: dict[int, list[asyncio.Event]] = {} - - def get_time_no_wait(self) -> float: - if self._current_time == -1: - self._start_clock() - - return self._current_time / self.TIME_RESOLUTION - - async def sleep(self, seconds: float) -> None: - event = asyncio.Event() - internal_waiting_duration = int(seconds * self.TIME_RESOLUTION) - target_event_monotonic_time = self._current_time + internal_waiting_duration - self._pending_sleep_events.setdefault(target_event_monotonic_time, []).append( - event - ) - # Ok, let's wait for this Event - # (which can only be "unlocked" by calls to `advance_clock()`) - await event.wait() - - async def advance_clock(self, seconds: float) -> None: - """ - Artificially advance the Textual clock forward. - - Args: - seconds: for each second we will artificially tick `ticks_granularity_fps` times - """ - if self._current_time == -1: - self._start_clock() - - ticks_count = ceil(seconds * self._ticks_granularity_fps) - activated_timers_count_total = 0 # useful when debugging this code :-) - for tick_counter in range(ticks_count): - self._current_time += self._single_tick_duration - activated_timers_count = self._check_sleep_timers_to_activate() - activated_timers_count_total += activated_timers_count - # Now that we likely unlocked some occurrences of `await sleep(duration)`, - # let's give an opportunity to asyncio-related stuff to happen: - if activated_timers_count: - await let_asyncio_process_some_events() - - await let_asyncio_process_some_events() - - def _start_clock(self) -> None: - # N.B. `start_time` is not actually used, but it is useful to have when we set breakpoints there :-) - self._start_time = self._current_time = int(monotonic() * self.TIME_RESOLUTION) - - def _check_sleep_timers_to_activate(self) -> int: - activated_timers_count = 0 - activated_events_times_to_clear: list[int] = [] - for (monotonic_time, target_events) in self._pending_sleep_events.items(): - if self._current_time < monotonic_time: - continue # not time for you yet, dear awaiter... - # Right, let's release these waiting events! - for event in target_events: - event.set() - activated_timers_count += len(target_events) - # ...and let's mark it for removal: - activated_events_times_to_clear.append(monotonic_time) - - for event_time_to_clear in activated_events_times_to_clear: - del self._pending_sleep_events[event_time_to_clear] - - return activated_timers_count - - -def mock_textual_timers( - *, - ticks_granularity_fps: int = 60, -) -> ContextManager[ClockMock]: - @contextlib.contextmanager - def mock_textual_timers_context_manager(): - clock_mock = ClockMock(ticks_granularity_fps=ticks_granularity_fps) - with mock.patch("textual._clock._clock", new=clock_mock): - yield clock_mock - - return mock_textual_timers_context_manager() From ce26c0eba890ad04ebdd53c50ce40014d1e0fc0f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 27 Oct 2022 17:57:31 +0100 Subject: [PATCH 12/41] log unmount --- src/textual/events.py | 2 +- src/textual/message_pump.py | 6 ++---- src/textual/widget.py | 3 --- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/textual/events.py b/src/textual/events.py index f5742ac03..7a88af264 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -119,7 +119,7 @@ class Compose(Event, bubble=False, verbose=True): """Sent to a widget to request it to compose and mount children.""" -class Mount(Event, bubble=False, verbose=True): +class Mount(Event, bubble=False, verbose=False): """Sent when a widget is *mounted* and may receive messages.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 2102602e1..e1a67df4a 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -266,7 +266,7 @@ class MessagePump(metaclass=MessagePumpMeta): self.app.screen._invoke_later(message.callback) def _close_messages_no_wait(self) -> None: - """Request the message queue to exit.""" + """Request the message queue to immediately exit.""" self._message_queue.put_nowait(messages.CloseMessages(sender=self)) async def _on_close_messages(self, message: messages.CloseMessages) -> None: @@ -374,8 +374,6 @@ class MessagePump(metaclass=MessagePumpMeta): self.app._handle_exception(error) break - # log("CLOSED", self) - async def _dispatch_message(self, message: Message) -> None: """Dispatch a message received from the message queue. @@ -439,7 +437,7 @@ class MessagePump(metaclass=MessagePumpMeta): dispatched = True await invoke(method, message) if not dispatched: - log.event.verbose(message, ">>>", self, "method=None") + log.event.verbosity(message.verbose)(message, ">>>", self, "method=None") # Bubble messages up the DOM (if enabled on the message) if message.bubble and self._parent and not message._stop_propagation: diff --git a/src/textual/widget.py b/src/textual/widget.py index b28a8bd33..363c1f518 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2049,6 +2049,3 @@ class Widget(DOMNode): self.scroll_page_up() return True return False - - # def _on_un_mount(self) -> None: - # self.log.debug("UNMOUNTED", self) From bb80aeb7f92dc0dd68ceac186ea38bf9040f956d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 28 Oct 2022 21:02:23 +0100 Subject: [PATCH 13/41] added pilot --- sandbox/will/dictionary.css | 26 ++++ sandbox/will/dictionary.py | 82 ++++++++++ sandbox/will/pride.py | 5 +- src/textual/__init__.py | 4 +- src/textual/_doc.py | 30 +++- src/textual/_pilot.py | 29 ++++ src/textual/app.py | 202 +++++++++---------------- src/textual/driver.py | 5 + src/textual/drivers/headless_driver.py | 4 + 9 files changed, 244 insertions(+), 143 deletions(-) create mode 100644 sandbox/will/dictionary.css create mode 100644 sandbox/will/dictionary.py create mode 100644 src/textual/_pilot.py diff --git a/sandbox/will/dictionary.css b/sandbox/will/dictionary.css new file mode 100644 index 000000000..6bca8b9f5 --- /dev/null +++ b/sandbox/will/dictionary.css @@ -0,0 +1,26 @@ +Screen { + background: $panel; +} + +Input { + dock: top; + margin: 1 0; +} + +#results { + width: auto; + min-height: 100%; + padding: 0 1; +} + +#results-container { + background: $background 50%; + margin: 0 0 1 0; + height: 100%; + overflow: hidden auto; + border: tall $background; +} + +#results-container:focus { + border: tall $accent; +} diff --git a/sandbox/will/dictionary.py b/sandbox/will/dictionary.py new file mode 100644 index 000000000..4a427c394 --- /dev/null +++ b/sandbox/will/dictionary.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import asyncio + +try: + import httpx +except ImportError: + raise ImportError("Please install httpx with 'pip install httpx' ") + +from rich.markdown import Markdown + +from textual.app import App, ComposeResult +from textual.containers import Content +from textual.widgets import Static, Input + + +class DictionaryApp(App): + """Searches ab dictionary API as-you-type.""" + + CSS_PATH = "dictionary.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Search for a word") + yield Content(Static(id="results"), id="results-container") + + def on_mount(self) -> None: + """Called when app starts.""" + # Give the input focus, so we can start typing straight away + self.query_one(Input).focus() + + async def on_input_changed(self, message: Input.Changed) -> None: + """A coroutine to handle a text changed message.""" + if message.value: + # Look up the word in the background + asyncio.create_task(self.lookup_word(message.value)) + else: + # Clear the results + self.query_one("#results", Static).update() + + async def lookup_word(self, word: str) -> None: + """Looks up a word.""" + url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" + async with httpx.AsyncClient() as client: + results = (await client.get(url)).json() + + if word == self.query_one(Input).value: + markdown = self.make_word_markdown(results) + self.query_one("#results", Static).update(Markdown(markdown)) + + def make_word_markdown(self, results: object) -> str: + """Convert the results in to markdown.""" + lines = [] + if isinstance(results, dict): + lines.append(f"# {results['title']}") + lines.append(results["message"]) + elif isinstance(results, list): + for result in results: + lines.append(f"# {result['word']}") + lines.append("") + for meaning in result.get("meanings", []): + lines.append(f"_{meaning['partOfSpeech']}_") + lines.append("") + for definition in meaning.get("definitions", []): + lines.append(f" - {definition['definition']}") + lines.append("---") + + return "\n".join(lines) + + +if __name__ == "__main__": + app = DictionaryApp() + + async def run(): + async with app.run_managed() as pilot: + await pilot.press(*"Hello") + await pilot.pause(2) + await pilot.press(*" World!") + await pilot.pause(3) + + import asyncio + + asyncio.run(run()) diff --git a/sandbox/will/pride.py b/sandbox/will/pride.py index a51f474bb..788f6be03 100644 --- a/sandbox/will/pride.py +++ b/sandbox/will/pride.py @@ -21,9 +21,8 @@ if __name__ == "__main__": from rich import print async def run_app(): - async with app.run_async(quit_after=5) as result: - print(result) - print(app.tree) + async with app.run_managed() as pilot: + await pilot.pause(5) import asyncio diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 3935489be..1a712e0c8 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -51,7 +51,9 @@ class Logger: try: app = active_app.get() except LookupError: - raise LoggerError("Unable to log without an active app.") from None + print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()]) + print(*print_args) + return if app.devtools is None or not app.devtools.is_connected: return diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 17918e351..4b536b9d8 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import os import shlex from typing import Iterable @@ -69,19 +70,32 @@ def take_svg_screenshot( os.environ["LINES"] = str(rows) if app is None: + assert app_path is not None app = import_app(app_path) + assert app is not None + if title is None: title = app.title - app.run( - quit_after=5, - press=press or ["ctrl+c"], - headless=True, - screenshot=True, - screenshot_title=title, - ) - svg = app._screenshot + svg: str = "" + + async def run_app(app: App) -> None: + nonlocal svg + async with app.run_managed(headless=True) as pilot: + await pilot.press(*press) + svg = app.export_screenshot(title=title) + + asyncio.run(run_app(app)) + + # app.run( + # quit_after=5, + # press=press or ["ctrl+c"], + # headless=True, + # screenshot=True, + # screenshot_title=title, + # ) + return svg diff --git a/src/textual/_pilot.py b/src/textual/_pilot.py new file mode 100644 index 000000000..bc720c2ea --- /dev/null +++ b/src/textual/_pilot.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import asyncio +from typing import Iterable, TYPE_CHECKING + +if TYPE_CHECKING: + from .app import App + + +class Pilot: + def __init__(self, app: App) -> None: + self._app = app + + async def press(self, *keys: str) -> None: + """Simulate key-presses. + + Args: + *key: Keys to press. + + """ + await self._app._press_keys(keys) + + async def pause(self, delay: float = 50 / 1000) -> None: + """Insert a pause. + + Args: + delay (float, optional): Seconds to pause. Defaults to 50ms. + """ + await asyncio.sleep(delay) diff --git a/src/textual/app.py b/src/textual/app.py index 0a82bf700..68753fd20 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -13,7 +13,17 @@ from contextlib import redirect_stderr, redirect_stdout from datetime import datetime from pathlib import Path, PurePath from time import perf_counter -from typing import Any, Generic, Iterable, Type, TYPE_CHECKING, TypeVar, cast, Union +from typing import ( + Any, + Callable, + Generic, + Iterable, + Type, + TYPE_CHECKING, + TypeVar, + cast, + Union, +) from weakref import WeakSet, WeakValueDictionary from ._ansi_sequences import SYNC_END, SYNC_START @@ -171,7 +181,7 @@ class App(Generic[ReturnType], DOMNode): if no_color is not None: self._filter = Monochrome() self.console = Console( - file=(_NullFile() if self.is_headless else sys.__stdout__), + file=sys.__stdout__ if sys.__stdout__ is not None else _NullFile(), markup=False, highlight=False, emoji=False, @@ -301,7 +311,7 @@ class App(Generic[ReturnType], DOMNode): bool: True if the app is in headless mode. """ - return "headless" in self.features + return False if self._driver is None else self._driver.is_headless @property def screen_stack(self) -> list[Screen]: @@ -572,10 +582,9 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) - @classmethod - async def _press_keys(cls, app: App, press: Iterable[str]) -> None: + async def _press_keys(self, press: Iterable[str]) -> None: """A task to send key events.""" - assert press + app = self driver = app._driver assert driver is not None await asyncio.sleep(0.02) @@ -612,16 +621,44 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.05) await asyncio.sleep(0.02) await app._animator.wait_for_idle() - print("EXITING") - app.exit() @asynccontextmanager + async def run_managed(self, headless: bool = False): + """Context manager to run the app. + + Args: + headless (bool, optional): Enable headless mode. Defaults to False. + + """ + + from ._pilot import Pilot + + ready_event = asyncio.Event() + + async def on_ready(): + ready_event.set() + + async def run_app(app: App) -> None: + await app._process_messages(ready_callback=on_ready, headless=headless) + + self._set_active() + asyncio.create_task(run_app(self)) + + # Wait for the app to be ready + await ready_event.wait() + + # Yield a pilot object + yield Pilot(self) + + await self._shutdown() + async def run_async( self, *, headless: bool = False, quit_after: float | None = None, press: Iterable[str] | None = None, + ready_callback: Callable | None = None, ): """Run the app asynchronously. This is an async context manager, which shuts down the app on exit. @@ -639,33 +676,26 @@ class App(Generic[ReturnType], DOMNode): """ app = self - if headless: - app.features = cast( - "frozenset[FeatureFlag]", app.features.union({"headless"}) - ) if quit_after is not None: app.set_timer(quit_after, app.exit) - if press is not None: - - async def press_keys_task(): + async def app_ready() -> None: + """Called by the message loop when the app is ready.""" + if press: asyncio.create_task(self._press_keys(app, press)) + if ready_callback is not None: + await invoke(ready_callback) - await app._process_messages(ready_callback=press_keys_task) - - else: - await app._process_messages() - - yield app.return_value - + await app._process_messages(ready_callback=app_ready, headless=headless) await app._shutdown() + return app.return_value def run( self, *, - quit_after: float | None = None, headless: bool = False, + quit_after: float | None = None, press: Iterable[str] | None = None, screenshot: bool = False, screenshot_title: str | None = None, @@ -673,9 +703,9 @@ class App(Generic[ReturnType], DOMNode): """The main entry point for apps. Args: + headless (bool, optional): Run in "headless" mode (don't write to stdout). quit_after (float | None, optional): Quit after a given number of seconds, or None to run forever. Defaults to None. - headless (bool, optional): Run in "headless" mode (don't write to stdout). press (str, optional): An iterable of keys to simulate being pressed. screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. @@ -685,12 +715,19 @@ class App(Generic[ReturnType], DOMNode): """ async def run_app() -> None: - async with self.run_async( - quit_after=quit_after, headless=headless, press=press - ): + """Run the app.""" + + def take_screenshot() -> None: if screenshot: self._screenshot = self.export_screenshot(title=screenshot_title) + await self.run_async( + quit_after=quit_after, + headless=headless, + press=press, + ready_callback=take_screenshot, + ) + if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: asyncio.run(run_app()) @@ -700,106 +737,6 @@ class App(Generic[ReturnType], DOMNode): event_loop.run_until_complete(run_app()) return self.return_value - # def run( - # self, - # *, - # quit_after: float | None = None, - # headless: bool = False, - # press: Iterable[str] | None = None, - # screenshot: bool = False, - # screenshot_title: str | None = None, - # ) -> ReturnType | None: - # """The main entry point for apps. - - # Args: - # quit_after (float | None, optional): Quit after a given number of seconds, or None - # to run forever. Defaults to None. - # headless (bool, optional): Run in "headless" mode (don't write to stdout). - # press (str, optional): An iterable of keys to simulate being pressed. - # screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. - # screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. - - # Returns: - # ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. - # """ - - # if headless: - # self.features = cast( - # "frozenset[FeatureFlag]", self.features.union({"headless"}) - # ) - - # async def run_app() -> None: - # if quit_after is not None: - # self.set_timer(quit_after, self.shutdown) - # if press is not None: - # app = self - - # async def press_keys() -> None: - # """A task to send key events.""" - # assert press - # driver = app._driver - # assert driver is not None - # await asyncio.sleep(0.02) - # for key in press: - # if key == "_": - # print("(pause 50ms)") - # await asyncio.sleep(0.05) - # elif key.startswith("wait:"): - # _, wait_ms = key.split(":") - # print(f"(pause {wait_ms}ms)") - # await asyncio.sleep(float(wait_ms) / 1000) - # else: - # if len(key) == 1 and not key.isalnum(): - # key = ( - # unicodedata.name(key) - # .lower() - # .replace("-", "_") - # .replace(" ", "_") - # ) - # original_key = REPLACED_KEYS.get(key, key) - # try: - # char = unicodedata.lookup( - # original_key.upper().replace("_", " ") - # ) - # except KeyError: - # char = key if len(key) == 1 else None - # print(f"press {key!r} (char={char!r})") - # key_event = events.Key(self, key, char) - # driver.send_event(key_event) - # # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race - # # condition between widget-level key handling and app/screen level handling. - # # More information here: https://github.com/Textualize/textual/issues/1009 - # # This conditional sleep can be removed after that issue is closed. - # if key == "tab": - # await asyncio.sleep(0.05) - # await asyncio.sleep(0.02) - - # await app._animator.wait_for_idle() - - # if screenshot: - # self._screenshot = self.export_screenshot( - # title=screenshot_title - # ) - # await self.shutdown() - - # async def press_keys_task(): - # """Press some keys in the background.""" - # asyncio.create_task(press_keys()) - - # await self._process_messages(ready_callback=press_keys_task) - # else: - # await self._process_messages() - - # if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: - # # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: - # asyncio.run(run_app()) - # else: - # # However, this works with Python<3.10: - # event_loop = asyncio.get_event_loop() - # event_loop.run_until_complete(run_app()) - - # return self._return_value - async def _on_css_change(self) -> None: """Called when the CSS changes (if watch_css is True).""" if self.css_path is not None: @@ -1125,7 +1062,7 @@ class App(Generic[ReturnType], DOMNode): self._exit_renderables.clear() async def _process_messages( - self, ready_callback: CallbackType | None = None + self, ready_callback: CallbackType | None = None, headless: bool = False ) -> None: self._set_active() @@ -1185,8 +1122,11 @@ class App(Generic[ReturnType], DOMNode): await self.animator.start() await self._ready() + if ready_callback is not None: - await ready_callback() + ready_result = ready_callback() + if inspect.isawaitable(ready_result): + await ready_result self._running = True @@ -1209,13 +1149,13 @@ class App(Generic[ReturnType], DOMNode): driver: Driver driver_class = cast( "type[Driver]", - HeadlessDriver if self.is_headless else self.driver_class, + HeadlessDriver if headless else self.driver_class, ) driver = self._driver = driver_class(self.console, self) driver.start_application_mode() try: - if self.is_headless: + if headless: await run_process_messages() else: if self.devtools is not None: diff --git a/src/textual/driver.py b/src/textual/driver.py index 559c5e014..0eb3767c3 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -21,6 +21,11 @@ class Driver(ABC): self._loop = asyncio.get_running_loop() self._mouse_down_time = _clock.get_time_no_wait() + @property + def is_headless(self) -> bool: + """Check if the driver is 'headless'""" + return False + def send_event(self, event: events.Event) -> None: asyncio.run_coroutine_threadsafe( self._target.post_message(event), loop=self._loop diff --git a/src/textual/drivers/headless_driver.py b/src/textual/drivers/headless_driver.py index cdde957b9..15c31a9ae 100644 --- a/src/textual/drivers/headless_driver.py +++ b/src/textual/drivers/headless_driver.py @@ -9,6 +9,10 @@ from .. import events class HeadlessDriver(Driver): """A do-nothing driver for testing.""" + @property + def is_headless(self) -> bool: + return True + def _get_terminal_size(self) -> tuple[int, int]: width: int | None = 80 height: int | None = 25 From 4370198bf23b3b4cc0e8b25c598799ce5aa8e442 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 28 Oct 2022 21:43:23 +0100 Subject: [PATCH 14/41] added auto pilot --- sandbox/will/dictionary.py | 16 ++--- src/textual/_doc.py | 24 +++---- src/textual/app.py | 105 ++++++++-------------------- src/textual/{_pilot.py => pilot.py} | 14 +++- 4 files changed, 61 insertions(+), 98 deletions(-) rename src/textual/{_pilot.py => pilot.py} (68%) diff --git a/sandbox/will/dictionary.py b/sandbox/will/dictionary.py index 4a427c394..5bb6d8e69 100644 --- a/sandbox/will/dictionary.py +++ b/sandbox/will/dictionary.py @@ -70,13 +70,13 @@ class DictionaryApp(App): if __name__ == "__main__": app = DictionaryApp() - async def run(): - async with app.run_managed() as pilot: - await pilot.press(*"Hello") - await pilot.pause(2) - await pilot.press(*" World!") - await pilot.pause(3) + from textual.pilot import Pilot - import asyncio + async def auto_pilot(pilot: Pilot) -> None: + await pilot.press(*"Hello") + await pilot.pause(2) + await pilot.press(*" World!") + await pilot.pause(3) + pilot.app.exit() - asyncio.run(run()) + app.run(auto_pilot=auto_pilot) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 4b536b9d8..c57b368b4 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -6,6 +6,7 @@ import shlex from typing import Iterable from textual.app import App +from textual.pilot import Pilot from textual._import_app import import_app @@ -78,23 +79,16 @@ def take_svg_screenshot( if title is None: title = app.title - svg: str = "" + svg: str | None = "" - async def run_app(app: App) -> None: - nonlocal svg - async with app.run_managed(headless=True) as pilot: - await pilot.press(*press) - svg = app.export_screenshot(title=title) + async def auto_pilot(pilot: Pilot) -> None: + app = pilot.app + await pilot.press(*press) + svg = app.export_screenshot(title=title) + app.exit(svg) - asyncio.run(run_app(app)) - - # app.run( - # quit_after=5, - # press=press or ["ctrl+c"], - # headless=True, - # screenshot=True, - # screenshot_title=title, - # ) + svg = app.run(headless=True, auto_pilot=auto_pilot) + assert svg is not None return svg diff --git a/src/textual/app.py b/src/textual/app.py index 68753fd20..a6cd98fab 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -15,7 +15,9 @@ from pathlib import Path, PurePath from time import perf_counter from typing import ( Any, + Awaitable, Callable, + Coroutine, Generic, Iterable, Type, @@ -62,7 +64,12 @@ from .widget import AwaitMount, Widget if TYPE_CHECKING: from .devtools.client import DevtoolsClient + from .pilot import Pilot +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias PLATFORM = platform.system() WINDOWS = PLATFORM == "Windows" @@ -100,6 +107,10 @@ ComposeResult = Iterable[Widget] RenderResult = RenderableType +# AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Awaitable[None]]" +AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" + + class AppError(Exception): pass @@ -582,13 +593,13 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) - async def _press_keys(self, press: Iterable[str]) -> None: + async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" app = self driver = app._driver assert driver is not None await asyncio.sleep(0.02) - for key in press: + for key in keys: if key == "_": print("(pause 50ms)") await asyncio.sleep(0.05) @@ -622,70 +633,30 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.02) await app._animator.wait_for_idle() - @asynccontextmanager - async def run_managed(self, headless: bool = False): - """Context manager to run the app. - - Args: - headless (bool, optional): Enable headless mode. Defaults to False. - - """ - - from ._pilot import Pilot - - ready_event = asyncio.Event() - - async def on_ready(): - ready_event.set() - - async def run_app(app: App) -> None: - await app._process_messages(ready_callback=on_ready, headless=headless) - - self._set_active() - asyncio.create_task(run_app(self)) - - # Wait for the app to be ready - await ready_event.wait() - - # Yield a pilot object - yield Pilot(self) - - await self._shutdown() - async def run_async( self, *, headless: bool = False, - quit_after: float | None = None, - press: Iterable[str] | None = None, - ready_callback: Callable | None = None, - ): - """Run the app asynchronously. This is an async context manager, which shuts down the app on exit. - - Example: - async def run_app(): - app = MyApp() - async with app.run_async() as result: - print(result) + auto_pilot: AutopilotCallbackType, + ) -> ReturnType | None: + """Run the app asynchronously. Args: - quit_after (float | None, optional): Quit after a given number of seconds, or None - to run forever. Defaults to None. - headless (bool, optional): Run in "headless" mode (don't write to stdout). - press (str, optional): An iterable of keys to simulate being pressed. + headless (bool, optional): Run in headless mode (no output). Defaults to False. + auto_pilot (AutopilotCallbackType): An auto pilot coroutine. + Returns: + ReturnType | None: App return value. """ - app = self + from .pilot import Pilot - if quit_after is not None: - app.set_timer(quit_after, app.exit) + app = self async def app_ready() -> None: """Called by the message loop when the app is ready.""" - if press: - asyncio.create_task(self._press_keys(app, press)) - if ready_callback is not None: - await invoke(ready_callback) + if auto_pilot is not None: + pilot = Pilot(app) + asyncio.create_task(auto_pilot(pilot)) await app._process_messages(ready_callback=app_ready, headless=headless) await app._shutdown() @@ -695,37 +666,23 @@ class App(Generic[ReturnType], DOMNode): self, *, headless: bool = False, - quit_after: float | None = None, - press: Iterable[str] | None = None, - screenshot: bool = False, - screenshot_title: str | None = None, + auto_pilot: AutopilotCallbackType, ) -> ReturnType | None: - """The main entry point for apps. + """Run the app. Args: - headless (bool, optional): Run in "headless" mode (don't write to stdout). - quit_after (float | None, optional): Quit after a given number of seconds, or None - to run forever. Defaults to None. - press (str, optional): An iterable of keys to simulate being pressed. - screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. - screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. + headless (bool, optional): Run in headless mode (no output). Defaults to False. + auto_pilot (AutopilotCallbackType): An auto pilot coroutine. Returns: - ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. + ReturnType | None: App return value. """ async def run_app() -> None: """Run the app.""" - - def take_screenshot() -> None: - if screenshot: - self._screenshot = self.export_screenshot(title=screenshot_title) - await self.run_async( - quit_after=quit_after, headless=headless, - press=press, - ready_callback=take_screenshot, + auto_pilot=auto_pilot, ) if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: diff --git a/src/textual/_pilot.py b/src/textual/pilot.py similarity index 68% rename from src/textual/_pilot.py rename to src/textual/pilot.py index bc720c2ea..79472facf 100644 --- a/src/textual/_pilot.py +++ b/src/textual/pilot.py @@ -1,16 +1,28 @@ from __future__ import annotations +import rich.repr + import asyncio -from typing import Iterable, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from .app import App +@rich.repr.auto(angular=True) class Pilot: + """Pilot object to drive an app.""" + def __init__(self, app: App) -> None: self._app = app + def __rich_repr__(self) -> rich.repr.Result: + yield "app", "self._app" + + @property + def app(self) -> App: + return self._app + async def press(self, *keys: str) -> None: """Simulate key-presses. From 269ff4883e1c7b0432d10b6040a31d23a89dc378 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 10:03:26 +0100 Subject: [PATCH 15/41] test fix --- src/textual/_doc.py | 2 -- tests/test_auto_refresh.py | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index c57b368b4..b3021c4fc 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -79,8 +79,6 @@ def take_svg_screenshot( if title is None: title = app.title - svg: str | None = "" - async def auto_pilot(pilot: Pilot) -> None: app = pilot.app await pilot.press(*press) diff --git a/tests/test_auto_refresh.py b/tests/test_auto_refresh.py index d5122a7a4..c9eacf469 100644 --- a/tests/test_auto_refresh.py +++ b/tests/test_auto_refresh.py @@ -1,6 +1,8 @@ +import asyncio from time import time from textual.app import App +from textual.pilot import Pilot class RefreshApp(App[float]): @@ -22,7 +24,10 @@ class RefreshApp(App[float]): def test_auto_refresh(): app = RefreshApp() - elapsed = app.run(quit_after=1, headless=True) + async def quit_after(pilot: Pilot) -> None: + await asyncio.sleep(1) + + elapsed = app.run(auto_pilot=quit_after, headless=True) assert elapsed is not None # CI can run slower, so we need to give this a bit of margin assert 0.2 <= elapsed < 0.8 From 264b4fe73340258b4bc0ae59aeaec9c437fa81c1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 10:53:50 +0100 Subject: [PATCH 16/41] run test context manager --- CHANGELOG.md | 5 +++++ src/textual/app.py | 42 +++++++++++++++++++++++++++++++++++++++++- src/textual/pilot.py | 15 ++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d55e99b..81cbdbf06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - DOMQuery now raises InvalidQueryFormat in response to invalid query strings, rather than cryptic CSS error +- Dropped quit_after, screenshot, and screenshot_title from App.run, which can all be done via auto_pilot +- Widgets are now closed in reversed DOM order ### Added +- Added Unmount event - Added App.run_async method +- Added App.run_test context manager +- Added auto_pilot to App.run and App.run_async ## [0.2.1] - 2022-10-23 diff --git a/src/textual/app.py b/src/textual/app.py index a6cd98fab..7d2f93fb3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -633,6 +633,38 @@ class App(Generic[ReturnType], DOMNode): await asyncio.sleep(0.02) await app._animator.wait_for_idle() + @asynccontextmanager + async def run_test(self, *, headless: bool = True): + """An asynchronous context manager for testing app. + + Args: + headless (bool, optional): Run in headless mode (no output or input). Defaults to True. + + """ + from .pilot import Pilot + + app = self + app_ready_event = asyncio.Event() + + def on_app_ready() -> None: + """Called when app is ready to process events.""" + app_ready_event.set() + + async def run_app(app) -> None: + await app._process_messages(ready_callback=on_app_ready, headless=headless) + + # Launch the app in the "background" + asyncio.create_task(run_app(app)) + + # Wait until the app has performed all startup routines. + await app_ready_event.wait() + + # Context manager returns pilot object to manipulate the app + yield Pilot(app) + + # Shutdown the app cleanly + await app._shutdown() + async def run_async( self, *, @@ -655,8 +687,16 @@ class App(Generic[ReturnType], DOMNode): async def app_ready() -> None: """Called by the message loop when the app is ready.""" if auto_pilot is not None: + + async def run_auto_pilot(pilot) -> None: + try: + await auto_pilot(pilot) + except Exception: + app.exit() + raise + pilot = Pilot(app) - asyncio.create_task(auto_pilot(pilot)) + asyncio.create_task(run_auto_pilot(pilot)) await app._process_messages(ready_callback=app_ready, headless=headless) await app._shutdown() diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 79472facf..58b2743d1 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -17,10 +17,15 @@ class Pilot: self._app = app def __rich_repr__(self) -> rich.repr.Result: - yield "app", "self._app" + yield "app", self._app @property def app(self) -> App: + """Get a reference to the application. + + Returns: + App: The App instance. + """ return self._app async def press(self, *keys: str) -> None: @@ -39,3 +44,11 @@ class Pilot: delay (float, optional): Seconds to pause. Defaults to 50ms. """ await asyncio.sleep(delay) + + async def exit(self, result: object) -> None: + """Exit the app with the given result. + + Args: + result (object): The app result returned by `run` or `run_async`. + """ + self.app.exit(result) From 2afb00f5b357d72f84c56adcf9762dd27370a5e3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 11:44:31 +0100 Subject: [PATCH 17/41] test fix --- src/textual/app.py | 30 +++++++++++++++++++------- src/textual/message_pump.py | 5 ++++- src/textual/widget.py | 1 - tests/snapshot_tests/conftest.py | 14 +++++++----- tests/snapshot_tests/test_snapshots.py | 11 ---------- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 7d2f93fb3..11f481789 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from asyncio import Task from contextlib import asynccontextmanager import inspect import io @@ -15,7 +16,6 @@ from pathlib import Path, PurePath from time import perf_counter from typing import ( Any, - Awaitable, Callable, Coroutine, Generic, @@ -654,7 +654,7 @@ class App(Generic[ReturnType], DOMNode): await app._process_messages(ready_callback=on_app_ready, headless=headless) # Launch the app in the "background" - asyncio.create_task(run_app(app)) + app_task = asyncio.create_task(run_app(app)) # Wait until the app has performed all startup routines. await app_ready_event.wait() @@ -664,12 +664,13 @@ class App(Generic[ReturnType], DOMNode): # Shutdown the app cleanly await app._shutdown() + await app_task async def run_async( self, *, headless: bool = False, - auto_pilot: AutopilotCallbackType, + auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app asynchronously. @@ -684,8 +685,11 @@ class App(Generic[ReturnType], DOMNode): app = self + auto_pilot_task: Task | None = None + async def app_ready() -> None: """Called by the message loop when the app is ready.""" + nonlocal auto_pilot_task if auto_pilot is not None: async def run_auto_pilot(pilot) -> None: @@ -696,17 +700,25 @@ class App(Generic[ReturnType], DOMNode): raise pilot = Pilot(app) - asyncio.create_task(run_auto_pilot(pilot)) + auto_pilot_task = asyncio.create_task(run_auto_pilot(pilot)) + + try: + await app._process_messages( + ready_callback=None if auto_pilot is None else app_ready, + headless=headless, + ) + finally: + if auto_pilot_task is not None: + await auto_pilot_task + await app._shutdown() - await app._process_messages(ready_callback=app_ready, headless=headless) - await app._shutdown() return app.return_value def run( self, *, headless: bool = False, - auto_pilot: AutopilotCallbackType, + auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app. @@ -1287,8 +1299,10 @@ class App(Generic[ReturnType], DOMNode): parent (Widget): The parent of the Widget. widget (Widget): The Widget to start. """ + widget._attach(parent) widget._start_messages() + self.app._registry.add(widget) def is_mounted(self, widget: Widget) -> bool: """Check if a widget is mounted. @@ -1321,6 +1335,7 @@ class App(Generic[ReturnType], DOMNode): async def _shutdown(self) -> None: driver = self._driver + self._running = False if driver is not None: driver.disable_input() await self._close_all() @@ -1328,7 +1343,6 @@ class App(Generic[ReturnType], DOMNode): await self._dispatch_message(events.UnMount(sender=self)) - self._running = False self._print_error_renderables() if self.devtools is not None and self.devtools.is_connected: await self._disconnect_devtools() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index e1a67df4a..c0c1e6160 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -155,7 +155,9 @@ class MessagePump(metaclass=MessagePumpMeta): return self._pending_message finally: self._pending_message = None + message = await self._message_queue.get() + if message is None: self._closed = True raise MessagePumpClosed("The message pump is now closed") @@ -289,7 +291,8 @@ class MessagePump(metaclass=MessagePumpMeta): def _start_messages(self) -> None: """Start messages task.""" - self._task = asyncio.create_task(self._process_messages()) + if self.app._running: + self._task = asyncio.create_task(self._process_messages()) async def _process_messages(self) -> None: self._running = True diff --git a/src/textual/widget.py b/src/textual/widget.py index 363c1f518..d83403482 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -595,7 +595,6 @@ class Widget(DOMNode): vertical=False, name="horizontal", thickness=self.scrollbar_size_horizontal ) self._horizontal_scrollbar.display = False - self.app._start_widget(self, scroll_bar) return scroll_bar diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py index 0a0f0d55c..5a5862df7 100644 --- a/tests/snapshot_tests/conftest.py +++ b/tests/snapshot_tests/conftest.py @@ -59,7 +59,6 @@ def snap_compare( """ node = request.node app = import_app(app_path) - compare.app = app actual_screenshot = take_svg_screenshot( app=app, press=press, @@ -69,7 +68,9 @@ def snap_compare( if result is False: # The split and join below is a mad hack, sorry... - node.stash[TEXTUAL_SNAPSHOT_SVG_KEY] = "\n".join(str(snapshot).splitlines()[1:-1]) + node.stash[TEXTUAL_SNAPSHOT_SVG_KEY] = "\n".join( + str(snapshot).splitlines()[1:-1] + ) node.stash[TEXTUAL_ACTUAL_SVG_KEY] = actual_screenshot node.stash[TEXTUAL_APP_KEY] = app else: @@ -85,6 +86,7 @@ class SvgSnapshotDiff: """Model representing a diff between current screenshot of an app, and the snapshot on disk. This is ultimately intended to be used in a Jinja2 template.""" + snapshot: Optional[str] actual: Optional[str] test_name: str @@ -119,7 +121,7 @@ def pytest_sessionfinish( snapshot=str(snapshot_svg), actual=str(actual_svg), file_similarity=100 - * difflib.SequenceMatcher( + * difflib.SequenceMatcher( a=str(snapshot_svg), b=str(actual_svg) ).ratio(), test_name=name, @@ -176,7 +178,9 @@ def pytest_terminal_summary( if diffs: snapshot_report_location = config._textual_snapshot_html_report console.rule("[b red]Textual Snapshot Report", style="red") - console.print(f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" - f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n") + console.print( + f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" + f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n" + ) console.print(f"[dim]{snapshot_report_location}\n") console.rule(style="red") diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 0c2f6f5f5..a4a4accaa 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -66,22 +66,11 @@ def test_input_and_focus(snap_compare): ] assert snap_compare("docs/examples/widgets/input.py", press=press) - # Assert that the state of the Input is what we'd expect - # app: App = snap_compare.app - # input: Input = app.query_one(Input) - # assert input.value == "Darren" - # assert input.cursor_position == 6 - # assert input.view_position == 0 - def test_buttons_render(snap_compare): # Testing button rendering. We press tab to focus the first button too. assert snap_compare("docs/examples/widgets/button.py", press=["tab"]) - # app = snap_compare.app - # button: Button = app.query_one(Button) - # assert app.focused is button - def test_datatable_render(snap_compare): press = ["tab", "down", "down", "right", "up", "left"] From 12c553d1953fe8cd539bab33ed8af3670f2a2311 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 13:29:32 +0100 Subject: [PATCH 18/41] shutdown scrollbas --- CHANGELOG.md | 5 +++++ sandbox/will/scroll_remove.py | 18 ++++++++++++++++++ src/textual/app.py | 5 +---- src/textual/cli/cli.py | 8 +++++++- src/textual/widget.py | 17 ++++++++++++++++- tests/test_auto_pilot.py | 23 +++++++++++++++++++++++ tests/test_test_runner.py | 21 +++++++++++++++++++++ 7 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 sandbox/will/scroll_remove.py create mode 100644 tests/test_auto_pilot.py create mode 100644 tests/test_test_runner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 81cbdbf06..5d605f010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.2.2] - Unreleased +### Fixed + +- Fixed issue where scrollbars weren't being unmounted + ### Changed - DOMQuery now raises InvalidQueryFormat in response to invalid query strings, rather than cryptic CSS error @@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added App.run_async method - Added App.run_test context manager - Added auto_pilot to App.run and App.run_async +- Added Widget._get_virtual_dom to get scrollbars ## [0.2.1] - 2022-10-23 diff --git a/sandbox/will/scroll_remove.py b/sandbox/will/scroll_remove.py new file mode 100644 index 000000000..abad13947 --- /dev/null +++ b/sandbox/will/scroll_remove.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult + +from textual.containers import Container + + +class ScrollApp(App): + + def compose(self) -> ComposeResult: + yield Container( + Container(), Container(), + id="top") + + def key_r(self) -> None: + self.query_one("#top").remove() + +if __name__ == "__main__": + app = ScrollApp() + app.run() diff --git a/src/textual/app.py b/src/textual/app.py index 11f481789..14b46c1aa 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1605,9 +1605,6 @@ class App(Generic[ReturnType], DOMNode): await self._prune_node(widget) - # for child in remove_widgets: - # await child._close_messages() - # self._unregister(child) if parent is not None: parent.refresh(layout=True) @@ -1625,7 +1622,7 @@ class App(Generic[ReturnType], DOMNode): while stack: widget = pop() if widget.children: - yield list(widget.children) + yield [*widget.children, *widget._get_virtual_dom()] for child in widget.children: push(child) diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index 3b1df1f4b..414575d82 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import click from importlib_metadata import version +from textual.pilot import Pilot from textual._import_app import import_app, AppFail @@ -84,7 +85,12 @@ def run_app(import_name: str, dev: bool, press: str) -> None: sys.exit(1) press_keys = press.split(",") if press else None - result = app.run(press=press_keys) + + async def run_press_keys(pilot: Pilot) -> None: + if press_keys is not None: + await pilot.press(*press_keys) + + result = app.run(auto_pilot=run_press_keys) if result is not None: from rich.console import Console diff --git a/src/textual/widget.py b/src/textual/widget.py index d83403482..e43c52f17 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -359,6 +359,20 @@ class Widget(DOMNode): """Clear arrangement cache, forcing a new arrange operation.""" self._arrangement = None + def _get_virtual_dom(self) -> Iterable[Widget]: + """Get widgets not part of the DOM. + + Returns: + Iterable[Widget]: An iterable of Widgets. + + """ + if self._horizontal_scrollbar is not None: + yield self._horizontal_scrollbar + if self._vertical_scrollbar is not None: + yield self._vertical_scrollbar + if self._scrollbar_corner is not None: + yield self._scrollbar_corner + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> AwaitMount: """Mount child widgets (making this widget a container). @@ -587,6 +601,7 @@ class Widget(DOMNode): Returns: ScrollBar: ScrollBar Widget. """ + from .scrollbar import ScrollBar if self._horizontal_scrollbar is not None: @@ -600,7 +615,7 @@ class Widget(DOMNode): def _refresh_scrollbars(self) -> None: """Refresh scrollbar visibility.""" - if not self.is_scrollable: + if not self.is_scrollable or not self.container_size: return styles = self.styles diff --git a/tests/test_auto_pilot.py b/tests/test_auto_pilot.py new file mode 100644 index 000000000..dde2ad18c --- /dev/null +++ b/tests/test_auto_pilot.py @@ -0,0 +1,23 @@ +from textual.app import App +from textual.pilot import Pilot +from textual import events + + +def test_auto_pilot() -> None: + + keys_pressed: list[str] = [] + + class TestApp(App): + def on_key(self, event: events.Key) -> None: + keys_pressed.append(event.key) + + async def auto_pilot(pilot: Pilot) -> None: + + await pilot.press("tab", *"foo") + await pilot.pause(1 / 100) + await pilot.exit("bar") + + app = TestApp() + result = app.run(headless=True, auto_pilot=auto_pilot) + assert result == "bar" + assert keys_pressed == ["tab", "f", "o", "o"] diff --git a/tests/test_test_runner.py b/tests/test_test_runner.py new file mode 100644 index 000000000..515f87341 --- /dev/null +++ b/tests/test_test_runner.py @@ -0,0 +1,21 @@ +from textual.app import App +from textual import events + + +async def test_run_test() -> None: + """Test the run_test context manager.""" + keys_pressed: list[str] = [] + + class TestApp(App[str]): + def on_key(self, event: events.Key) -> None: + keys_pressed.append(event.key) + + app = TestApp() + async with app.run_test() as pilot: + assert str(pilot) == "" + await pilot.press("tab", *"foo") + await pilot.pause(1 / 100) + await pilot.exit("bar") + + assert app.return_value == "bar" + assert keys_pressed == ["tab", "f", "o", "o"] From deb00c21e885bda47625eab72b4ce37c175b6e02 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 13:44:05 +0100 Subject: [PATCH 19/41] added pilot to docs --- docs/reference/pilot.md | 1 + mkdocs.yml | 1 + 2 files changed, 2 insertions(+) create mode 100644 docs/reference/pilot.md diff --git a/docs/reference/pilot.md b/docs/reference/pilot.md new file mode 100644 index 000000000..e1db65812 --- /dev/null +++ b/docs/reference/pilot.md @@ -0,0 +1 @@ +::: textual.pilot diff --git a/mkdocs.yml b/mkdocs.yml index 78e6c97b3..ffc3f0f82 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,6 +107,7 @@ nav: - "reference/index.md" - "reference/message_pump.md" - "reference/message.md" + - "reference/pilot.md" - "reference/query.md" - "reference/reactive.md" - "reference/screen.md" From 5b9bb575f02f020a180fde4474b24238cc1bb9db Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 14:46:15 +0100 Subject: [PATCH 20/41] words --- src/textual/app.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 14b46c1aa..294ebee42 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -107,7 +107,6 @@ ComposeResult = Iterable[Widget] RenderResult = RenderableType -# AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Awaitable[None]]" AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" @@ -1117,7 +1116,7 @@ class App(Generic[ReturnType], DOMNode): self.log.system("[b green]STARTED[/]", self.css_monitor) async def run_process_messages(): - """The main message look, invoke below.""" + """The main message loop, invoke below.""" try: await self._dispatch_message(events.Compose(sender=self)) await self._dispatch_message(events.Mount(sender=self)) @@ -1186,11 +1185,6 @@ class App(Generic[ReturnType], DOMNode): driver.stop_application_mode() except Exception as error: self._handle_exception(error) - # finally: - # self._running = False - # self._print_error_renderables() - # if self.devtools is not None and self.devtools.is_connected: - # await self._disconnect_devtools() async def _pre_process(self) -> None: pass @@ -1320,7 +1314,10 @@ class App(Generic[ReturnType], DOMNode): # Close all screens on the stack for screen in self._screen_stack: - await self._prune_node(screen) + if screen._running: + await self._prune_node(screen) + + self._screen_stack.clear() # Close pre-defined screens for screen in self.SCREENS.values(): From 9f88f9e3ebf44572e75a91fa0ffe95f9dec59c12 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 19:55:54 +0100 Subject: [PATCH 21/41] remove sandbox --- sandbox/will/dictionary.css | 26 ----------- sandbox/will/dictionary.py | 82 ----------------------------------- sandbox/will/pride.py | 29 ------------- sandbox/will/scroll_remove.py | 18 -------- 4 files changed, 155 deletions(-) delete mode 100644 sandbox/will/dictionary.css delete mode 100644 sandbox/will/dictionary.py delete mode 100644 sandbox/will/pride.py delete mode 100644 sandbox/will/scroll_remove.py diff --git a/sandbox/will/dictionary.css b/sandbox/will/dictionary.css deleted file mode 100644 index 6bca8b9f5..000000000 --- a/sandbox/will/dictionary.css +++ /dev/null @@ -1,26 +0,0 @@ -Screen { - background: $panel; -} - -Input { - dock: top; - margin: 1 0; -} - -#results { - width: auto; - min-height: 100%; - padding: 0 1; -} - -#results-container { - background: $background 50%; - margin: 0 0 1 0; - height: 100%; - overflow: hidden auto; - border: tall $background; -} - -#results-container:focus { - border: tall $accent; -} diff --git a/sandbox/will/dictionary.py b/sandbox/will/dictionary.py deleted file mode 100644 index 5bb6d8e69..000000000 --- a/sandbox/will/dictionary.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -import asyncio - -try: - import httpx -except ImportError: - raise ImportError("Please install httpx with 'pip install httpx' ") - -from rich.markdown import Markdown - -from textual.app import App, ComposeResult -from textual.containers import Content -from textual.widgets import Static, Input - - -class DictionaryApp(App): - """Searches ab dictionary API as-you-type.""" - - CSS_PATH = "dictionary.css" - - def compose(self) -> ComposeResult: - yield Input(placeholder="Search for a word") - yield Content(Static(id="results"), id="results-container") - - def on_mount(self) -> None: - """Called when app starts.""" - # Give the input focus, so we can start typing straight away - self.query_one(Input).focus() - - async def on_input_changed(self, message: Input.Changed) -> None: - """A coroutine to handle a text changed message.""" - if message.value: - # Look up the word in the background - asyncio.create_task(self.lookup_word(message.value)) - else: - # Clear the results - self.query_one("#results", Static).update() - - async def lookup_word(self, word: str) -> None: - """Looks up a word.""" - url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" - async with httpx.AsyncClient() as client: - results = (await client.get(url)).json() - - if word == self.query_one(Input).value: - markdown = self.make_word_markdown(results) - self.query_one("#results", Static).update(Markdown(markdown)) - - def make_word_markdown(self, results: object) -> str: - """Convert the results in to markdown.""" - lines = [] - if isinstance(results, dict): - lines.append(f"# {results['title']}") - lines.append(results["message"]) - elif isinstance(results, list): - for result in results: - lines.append(f"# {result['word']}") - lines.append("") - for meaning in result.get("meanings", []): - lines.append(f"_{meaning['partOfSpeech']}_") - lines.append("") - for definition in meaning.get("definitions", []): - lines.append(f" - {definition['definition']}") - lines.append("---") - - return "\n".join(lines) - - -if __name__ == "__main__": - app = DictionaryApp() - - from textual.pilot import Pilot - - async def auto_pilot(pilot: Pilot) -> None: - await pilot.press(*"Hello") - await pilot.pause(2) - await pilot.press(*" World!") - await pilot.pause(3) - pilot.app.exit() - - app.run(auto_pilot=auto_pilot) diff --git a/sandbox/will/pride.py b/sandbox/will/pride.py deleted file mode 100644 index 788f6be03..000000000 --- a/sandbox/will/pride.py +++ /dev/null @@ -1,29 +0,0 @@ -from textual.app import App, ComposeResult -from textual.widgets import Static - - -class PrideApp(App): - """Displays a pride flag.""" - - COLORS = ["red", "orange", "yellow", "green", "blue", "purple"] - - def compose(self) -> ComposeResult: - for color in self.COLORS: - stripe = Static() - stripe.styles.height = "1fr" - stripe.styles.background = color - yield stripe - - -if __name__ == "__main__": - app = PrideApp() - - from rich import print - - async def run_app(): - async with app.run_managed() as pilot: - await pilot.pause(5) - - import asyncio - - asyncio.run(run_app()) diff --git a/sandbox/will/scroll_remove.py b/sandbox/will/scroll_remove.py deleted file mode 100644 index abad13947..000000000 --- a/sandbox/will/scroll_remove.py +++ /dev/null @@ -1,18 +0,0 @@ -from textual.app import App, ComposeResult - -from textual.containers import Container - - -class ScrollApp(App): - - def compose(self) -> ComposeResult: - yield Container( - Container(), Container(), - id="top") - - def key_r(self) -> None: - self.query_one("#top").remove() - -if __name__ == "__main__": - app = ScrollApp() - app.run() From 2092d42bbf323e35e51bde7aa6e2ea78c9ed1425 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 20:21:25 +0100 Subject: [PATCH 22/41] more typing --- 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 294ebee42..42653aa34 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -691,7 +691,9 @@ class App(Generic[ReturnType], DOMNode): nonlocal auto_pilot_task if auto_pilot is not None: - async def run_auto_pilot(pilot) -> None: + async def run_auto_pilot( + auto_pilot: AutopilotCallbackType, pilot: Pilot + ) -> None: try: await auto_pilot(pilot) except Exception: @@ -699,7 +701,7 @@ class App(Generic[ReturnType], DOMNode): raise pilot = Pilot(app) - auto_pilot_task = asyncio.create_task(run_auto_pilot(pilot)) + auto_pilot_task = asyncio.create_task(run_auto_pilot(auto_pilot, pilot)) try: await app._process_messages( From c5b2e6982e1834a9cb72e51e052d5520a1a066fe Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:09:16 +0100 Subject: [PATCH 23/41] pass size as a parameter --- src/textual/_doc.py | 14 ++++++------- src/textual/app.py | 28 ++++++++++++++++++++------ src/textual/driver.py | 8 +++++++- src/textual/drivers/headless_driver.py | 2 ++ src/textual/drivers/linux_driver.py | 9 +++++++-- src/textual/drivers/windows_driver.py | 9 +++++++-- tests/snapshot_tests/conftest.py | 4 ++-- 7 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index b3021c4fc..32a074625 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -30,7 +30,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str rows = int(attrs.get("lines", 24)) columns = int(attrs.get("columns", 80)) svg = take_svg_screenshot( - None, path, press, title, terminal_size=(rows, columns) + None, path, press, title, terminal_size=(columns, rows) ) finally: os.chdir(cwd) @@ -49,7 +49,7 @@ def take_svg_screenshot( app_path: str | None = None, press: Iterable[str] = ("_",), title: str | None = None, - terminal_size: tuple[int, int] = (24, 80), + terminal_size: tuple[int, int] = (80, 24), ) -> str: """ @@ -65,10 +65,6 @@ def take_svg_screenshot( the screenshot was taken. """ - rows, columns = terminal_size - - os.environ["COLUMNS"] = str(columns) - os.environ["LINES"] = str(rows) if app is None: assert app_path is not None @@ -85,7 +81,11 @@ def take_svg_screenshot( svg = app.export_screenshot(title=title) app.exit(svg) - svg = app.run(headless=True, auto_pilot=auto_pilot) + svg = app.run( + headless=True, + auto_pilot=auto_pilot, + size=terminal_size, + ) assert svg is not None return svg diff --git a/src/textual/app.py b/src/textual/app.py index 42653aa34..c2be19198 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -444,7 +444,11 @@ class App(Generic[ReturnType], DOMNode): Returns: Size: Size of the terminal """ - return Size(*self.console.size) + if self._driver is not None and self._driver._size is not None: + width, height = self._driver._size + else: + width, height = self.console.size + return Size(width, height) @property def log(self) -> Logger: @@ -526,10 +530,11 @@ class App(Generic[ReturnType], DOMNode): to use app title. Defaults to None. """ - + assert self._driver is not None, "App must be running" + width, height = self.size console = Console( - width=self.console.width, - height=self.console.height, + width=width, + height=height, file=io.StringIO(), force_terminal=True, color_system="truecolor", @@ -669,12 +674,15 @@ class App(Generic[ReturnType], DOMNode): self, *, headless: bool = False, + size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app asynchronously. Args: headless (bool, optional): Run in headless mode (no output). Defaults to False. + size (tuple[int, int] | None, optional): Force terminal size to `(WIDTH, HEIGHT)`, + or None to auto-detect. Defaults to None. auto_pilot (AutopilotCallbackType): An auto pilot coroutine. Returns: @@ -707,6 +715,7 @@ class App(Generic[ReturnType], DOMNode): await app._process_messages( ready_callback=None if auto_pilot is None else app_ready, headless=headless, + terminal_size=size, ) finally: if auto_pilot_task is not None: @@ -719,12 +728,15 @@ class App(Generic[ReturnType], DOMNode): self, *, headless: bool = False, + size: tuple[int, int] | None = None, auto_pilot: AutopilotCallbackType | None = None, ) -> ReturnType | None: """Run the app. Args: headless (bool, optional): Run in headless mode (no output). Defaults to False. + size (tuple[int, int] | None, optional): Force terminal size to `(WIDTH, HEIGHT)`, + or None to auto-detect. Defaults to None. auto_pilot (AutopilotCallbackType): An auto pilot coroutine. Returns: @@ -735,6 +747,7 @@ class App(Generic[ReturnType], DOMNode): """Run the app.""" await self.run_async( headless=headless, + size=size, auto_pilot=auto_pilot, ) @@ -1072,7 +1085,10 @@ class App(Generic[ReturnType], DOMNode): self._exit_renderables.clear() async def _process_messages( - self, ready_callback: CallbackType | None = None, headless: bool = False + self, + ready_callback: CallbackType | None = None, + headless: bool = False, + terminal_size: tuple[int, int] | None = None, ) -> None: self._set_active() @@ -1161,7 +1177,7 @@ class App(Generic[ReturnType], DOMNode): "type[Driver]", HeadlessDriver if headless else self.driver_class, ) - driver = self._driver = driver_class(self.console, self) + driver = self._driver = driver_class(self.console, self, size=terminal_size) driver.start_application_mode() try: diff --git a/src/textual/driver.py b/src/textual/driver.py index 0eb3767c3..5e470b697 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -13,11 +13,17 @@ if TYPE_CHECKING: class Driver(ABC): def __init__( - self, console: "Console", target: "MessageTarget", debug: bool = False + self, + console: "Console", + target: "MessageTarget", + *, + debug: bool = False, + size: tuple[int, int] | None = None, ) -> None: self.console = console self._target = target self._debug = debug + self._size = size self._loop = asyncio.get_running_loop() self._mouse_down_time = _clock.get_time_no_wait() diff --git a/src/textual/drivers/headless_driver.py b/src/textual/drivers/headless_driver.py index 15c31a9ae..87a0940e7 100644 --- a/src/textual/drivers/headless_driver.py +++ b/src/textual/drivers/headless_driver.py @@ -14,6 +14,8 @@ class HeadlessDriver(Driver): return True def _get_terminal_size(self) -> tuple[int, int]: + if self._size is not None: + return self._size width: int | None = 80 height: int | None = 25 import shutil diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index e8c7bd00a..f0e75e71e 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -30,9 +30,14 @@ class LinuxDriver(Driver): """Powers display and input for Linux / MacOS""" def __init__( - self, console: "Console", target: "MessageTarget", debug: bool = False + self, + console: "Console", + target: "MessageTarget", + *, + debug: bool = False, + size: tuple[int, int] | None = None, ) -> None: - super().__init__(console, target, debug) + super().__init__(console, target, debug=debug, size=size) self.fileno = sys.stdin.fileno() self.attrs_before: list[Any] | None = None self.exit_event = Event() diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index fb51973ea..b14af7ab5 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -18,9 +18,14 @@ class WindowsDriver(Driver): """Powers display and input for Windows.""" def __init__( - self, console: "Console", target: "MessageTarget", debug: bool = False + self, + console: "Console", + target: "MessageTarget", + *, + debug: bool = False, + size: tuple[int, int] | None = None, ) -> None: - super().__init__(console, target, debug) + super().__init__(console, target, debug=debug, size=size) self.in_fileno = sys.stdin.fileno() self.out_fileno = sys.stdout.fileno() diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py index 5a5862df7..a399c5533 100644 --- a/tests/snapshot_tests/conftest.py +++ b/tests/snapshot_tests/conftest.py @@ -41,7 +41,7 @@ def snap_compare( def compare( app_path: str, press: Iterable[str] = ("_",), - terminal_size: tuple[int, int] = (24, 80), + terminal_size: tuple[int, int] = (80, 24), ) -> bool: """ Compare a current screenshot of the app running at app_path, with @@ -52,7 +52,7 @@ def snap_compare( Args: app_path (str): The path of the app. press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. - terminal_size (tuple[int, int]): A pair of integers (rows, columns), representing terminal size. + terminal_size (tuple[int, int]): A pair of integers (WIDTH, SIZE), representing terminal size. Returns: bool: True if the screenshot matches the snapshot. From dcbe88833f3e0c4fd947d5c76a1d6683a657665d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:14:01 +0100 Subject: [PATCH 24/41] posssible speedup of screenshots --- src/textual/_doc.py | 2 +- src/textual/pilot.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 32a074625..d7a531261 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -47,7 +47,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str def take_svg_screenshot( app: App | None = None, app_path: str | None = None, - press: Iterable[str] = ("_",), + press: Iterable[str] = (), title: str | None = None, terminal_size: tuple[int, int] = (80, 24), ) -> str: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 58b2743d1..6f3f046a4 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -35,7 +35,8 @@ class Pilot: *key: Keys to press. """ - await self._app._press_keys(keys) + if keys: + await self._app._press_keys(keys) async def pause(self, delay: float = 50 / 1000) -> None: """Insert a pause. From 6b075399a060a0e90287096a25f394ff5996733f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:16:22 +0100 Subject: [PATCH 25/41] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d605f010..69b41ae86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added App.run_test context manager - Added auto_pilot to App.run and App.run_async - Added Widget._get_virtual_dom to get scrollbars +- Added size parameter to run and run_async ## [0.2.1] - 2022-10-23 From f95a61c11540ea244268083cdac5591f9175720e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:30:02 +0100 Subject: [PATCH 26/41] remove import --- src/textual/_doc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index d7a531261..36dc4c255 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import os import shlex from typing import Iterable From 02658dedd2c8f1b9c164eadef1f5670689c9173c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 29 Oct 2022 21:32:20 +0100 Subject: [PATCH 27/41] remove delay --- src/textual/_doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 36dc4c255..2d6bce67b 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -19,7 +19,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str path = cmd[0] _press = attrs.get("press", None) - press = [*_press.split(",")] if _press else ["_"] + press = [*_press.split(",")] if _press else [] title = attrs.get("title") print(f"screenshotting {path!r}") From cfd5d532dd6877ad79064cbbe65a0caff5ad561e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 30 Oct 2022 08:43:23 +0000 Subject: [PATCH 28/41] test for unmount --- src/textual/app.py | 55 ++++++++++++++++++++++++------------- src/textual/events.py | 2 +- src/textual/message_pump.py | 2 +- tests/test_unmount.py | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 tests/test_unmount.py diff --git a/src/textual/app.py b/src/textual/app.py index c2be19198..bbc30baa7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -638,11 +638,18 @@ class App(Generic[ReturnType], DOMNode): await app._animator.wait_for_idle() @asynccontextmanager - async def run_test(self, *, headless: bool = True): + async def run_test( + self, + *, + headless: bool = True, + size: tuple[int, int] | None = (80, 24), + ): """An asynchronous context manager for testing app. Args: headless (bool, optional): Run in headless mode (no output or input). Defaults to True. + size (tuple[int, int] | None, optional): Force terminal size to `(WIDTH, HEIGHT)`, + or None to auto-detect. Defaults to None. """ from .pilot import Pilot @@ -655,7 +662,11 @@ class App(Generic[ReturnType], DOMNode): app_ready_event.set() async def run_app(app) -> None: - await app._process_messages(ready_callback=on_app_ready, headless=headless) + await app._process_messages( + ready_callback=on_app_ready, + headless=headless, + terminal_size=size, + ) # Launch the app in the "background" app_task = asyncio.create_task(run_app(app)) @@ -1135,24 +1146,30 @@ class App(Generic[ReturnType], DOMNode): async def run_process_messages(): """The main message loop, invoke below.""" + + async def invoke_ready_callback() -> None: + if ready_callback is not None: + ready_result = ready_callback() + if inspect.isawaitable(ready_result): + await ready_result + try: - await self._dispatch_message(events.Compose(sender=self)) - await self._dispatch_message(events.Mount(sender=self)) + try: + await self._dispatch_message(events.Compose(sender=self)) + await self._dispatch_message(events.Mount(sender=self)) + finally: + self._mounted_event.set() + + Reactive._initialize_object(self) + + self.stylesheet.update(self) + self.refresh() + + await self.animator.start() + finally: - self._mounted_event.set() - - Reactive._initialize_object(self) - - self.stylesheet.update(self) - self.refresh() - - await self.animator.start() - await self._ready() - - if ready_callback is not None: - ready_result = ready_callback() - if inspect.isawaitable(ready_result): - await ready_result + await self._ready() + await invoke_ready_callback() self._running = True @@ -1356,7 +1373,7 @@ class App(Generic[ReturnType], DOMNode): await self._close_all() await self._close_messages() - await self._dispatch_message(events.UnMount(sender=self)) + await self._dispatch_message(events.Unmount(sender=self)) self._print_error_renderables() if self.devtools is not None and self.devtools.is_connected: diff --git a/src/textual/events.py b/src/textual/events.py index 7a88af264..046ded120 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -123,7 +123,7 @@ class Mount(Event, bubble=False, verbose=False): """Sent when a widget is *mounted* and may receive messages.""" -class UnMount(Mount, bubble=False, verbose=False): +class Unmount(Mount, bubble=False, verbose=False): """Sent when a widget is unmounted and may not longer receive messages.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c0c1e6160..4ede28b75 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -283,7 +283,7 @@ class MessagePump(metaclass=MessagePumpMeta): for timer in stop_timers: await timer.stop() self._timers.clear() - await self._message_queue.put(events.UnMount(sender=self)) + await self._message_queue.put(events.Unmount(sender=self)) await self._message_queue.put(None) if self._task is not None and asyncio.current_task() != self._task: # Ensure everything is closed before returning diff --git a/tests/test_unmount.py b/tests/test_unmount.py new file mode 100644 index 000000000..632998d5b --- /dev/null +++ b/tests/test_unmount.py @@ -0,0 +1,50 @@ +from textual.app import App, ComposeResult +from textual import events +from textual.containers import Container +from textual.screen import Screen + + +async def test_unmount(): + """Text unmount events are received in reverse DOM order.""" + unmount_ids: list[str] = [] + + class UnmountWidget(Container): + def on_unmount(self, event: events.Unmount): + unmount_ids.append(f"{self.__class__.__name__}#{self.id}") + + class MyScreen(Screen): + def compose(self) -> ComposeResult: + yield UnmountWidget( + UnmountWidget( + UnmountWidget(id="bar1"), UnmountWidget(id="bar2"), id="bar" + ), + UnmountWidget( + UnmountWidget(id="baz1"), UnmountWidget(id="baz2"), id="baz" + ), + id="top", + ) + + def on_unmount(self, event: events.Unmount): + unmount_ids.append(f"{self.__class__.__name__}#{self.id}") + + class UnmountApp(App): + async def on_mount(self) -> None: + self.push_screen(MyScreen(id="main")) + + app = UnmountApp() + async with app.run_test() as pilot: + await pilot.pause() # TODO remove when push_screen is awaitable + await pilot.exit(None) + + expected = [ + "UnmountWidget#bar1", + "UnmountWidget#bar2", + "UnmountWidget#baz1", + "UnmountWidget#baz2", + "UnmountWidget#bar", + "UnmountWidget#baz", + "UnmountWidget#top", + "MyScreen#main", + ] + + assert unmount_ids == expected From 170be7eb2a5a7cb65765986d9db1407532cfe16f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 30 Oct 2022 15:24:48 +0000 Subject: [PATCH 29/41] allow waiting of screen operations --- src/textual/app.py | 45 +++++++++++++++++++++++++++++++------------ src/textual/demo.css | 3 ++- src/textual/screen.py | 8 ++++++-- src/textual/widget.py | 12 +++++++----- tests/test_unmount.py | 3 +-- 5 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index bbc30baa7..d4d9d3693 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -860,8 +860,6 @@ class App(Generic[ReturnType], DOMNode): def get_screen(self, screen: Screen | str) -> Screen: """Get an installed screen. - If the screen isn't running, it will be registered before it is run. - Args: screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). @@ -878,10 +876,30 @@ class App(Generic[ReturnType], DOMNode): raise KeyError(f"No screen called {screen!r} installed") from None else: next_screen = screen - if not next_screen.is_running: - self._register(self, next_screen) return next_screen + def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]: + """Get an installed screen and a await mount object. + + If the screen isn't running, it will be registered before it is run. + + Args: + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). + + Raises: + KeyError: If the named screen doesn't exist. + + Returns: + tuple[Screen, AwaitMount]: A screen instance and an awaitable that awaits the children mounting. + + """ + _screen = self.get_screen(screen) + if not _screen.is_running: + widgets = self._register(self, _screen) + return (_screen, AwaitMount(widgets)) + else: + return (_screen, AwaitMount([])) + def _replace_screen(self, screen: Screen) -> Screen: """Handle the replaced screen. @@ -899,19 +917,20 @@ class App(Generic[ReturnType], DOMNode): self.log.system(f"{screen} REMOVED") return screen - def push_screen(self, screen: Screen | str) -> None: + def push_screen(self, screen: Screen | str) -> AwaitMount: """Push a new screen on the screen stack. Args: screen (Screen | str): A Screen instance or the name of an installed screen. """ - next_screen = self.get_screen(screen) + next_screen, await_mount = self._get_screen(screen) self._screen_stack.append(next_screen) self.screen.post_message_no_wait(events.ScreenResume(self)) self.log.system(f"{self.screen} is current (PUSHED)") + return await_mount - def switch_screen(self, screen: Screen | str) -> None: + def switch_screen(self, screen: Screen | str) -> AwaitMount: """Switch to another screen by replacing the top of the screen stack with a new screen. Args: @@ -920,12 +939,14 @@ class App(Generic[ReturnType], DOMNode): """ if self.screen is not screen: self._replace_screen(self._screen_stack.pop()) - next_screen = self.get_screen(screen) + next_screen, await_mount = self._get_screen(screen) self._screen_stack.append(next_screen) self.screen.post_message_no_wait(events.ScreenResume(self)) self.log.system(f"{self.screen} is current (SWITCHED)") + return await_mount + return AwaitMount([]) - def install_screen(self, screen: Screen, name: str | None = None) -> str: + def install_screen(self, screen: Screen, name: str | None = None) -> AwaitMount: """Install a screen. Args: @@ -937,7 +958,7 @@ class App(Generic[ReturnType], DOMNode): ScreenError: If the screen can't be installed. Returns: - str: The name of the screen + AwaitMount: An awaitable that awaits the mounting of the screen and its children. """ if name is None: name = nanoid.generate() @@ -948,9 +969,9 @@ class App(Generic[ReturnType], DOMNode): "Can't install screen; {screen!r} has already been installed" ) self._installed_screens[name] = screen - self.get_screen(name) # Ensures screen is running + _screen, await_mount = self._get_screen(name) # Ensures screen is running self.log.system(f"{screen} INSTALLED name={name!r}") - return name + return await_mount def uninstall_screen(self, screen: Screen | str) -> str | None: """Uninstall a screen. If the screen was not previously installed then this diff --git a/src/textual/demo.css b/src/textual/demo.css index 08a2e4e9b..fd968f51e 100644 --- a/src/textual/demo.css +++ b/src/textual/demo.css @@ -114,13 +114,14 @@ DarkSwitch { } DarkSwitch .label { - + width: 1fr; padding: 1 2; color: $text-muted; } DarkSwitch Checkbox { background: $boost; + dock: left; } diff --git a/src/textual/screen.py b/src/textual/screen.py index 70b971125..f0d6752b9 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -12,7 +12,6 @@ from ._callback import invoke from ._compositor import Compositor, MapGeometry from .timer import Timer from ._types import CallbackType -from .dom import DOMNode from .geometry import Offset, Region, Size from .reactive import Reactive from .renderables.blank import Blank @@ -61,7 +60,12 @@ class Screen(Widget): @property def is_current(self) -> bool: """Check if this screen is current (i.e. visible to user).""" - return self.app.screen is self + from .app import ScreenStackError + + try: + return self.app.screen is self + except ScreenStackError: + return False @property def update_timer(self) -> Timer: diff --git a/src/textual/widget.py b/src/textual/widget.py index e43c52f17..a26b5f8b4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -82,11 +82,13 @@ class AwaitMount: def __await__(self) -> Generator[None, None, None]: async def await_mount() -> None: - aws = [ - create_task(widget._mounted_event.wait()) for widget in self._widgets - ] - if aws: - await wait(aws) + if self._widgets: + aws = [ + create_task(widget._mounted_event.wait()) + for widget in self._widgets + ] + if aws: + await wait(aws) return await_mount().__await__() diff --git a/tests/test_unmount.py b/tests/test_unmount.py index 632998d5b..331514405 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -29,11 +29,10 @@ async def test_unmount(): class UnmountApp(App): async def on_mount(self) -> None: - self.push_screen(MyScreen(id="main")) + await self.push_screen(MyScreen(id="main")) app = UnmountApp() async with app.run_test() as pilot: - await pilot.pause() # TODO remove when push_screen is awaitable await pilot.exit(None) expected = [ From 4524e6dd30690e7df2bdc2c93fff4e8077da8f4e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 10:09:08 +0000 Subject: [PATCH 30/41] Refactor of fr units --- sandbox/will/fr.py | 96 +++++++++++ src/textual/_arrange.py | 5 +- src/textual/_resolve.py | 89 +++++++++- src/textual/app.py | 7 +- src/textual/box_model.py | 15 +- src/textual/layouts/grid.py | 4 +- src/textual/layouts/horizontal.py | 25 ++- src/textual/layouts/vertical.py | 17 +- src/textual/widget.py | 18 +- .../__snapshots__/test_snapshots.ambr | 156 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 10 +- tests/snapshots/test_fr.py | 55 ++++++ tests/test_box_model.py | 32 ++-- 13 files changed, 468 insertions(+), 61 deletions(-) create mode 100644 sandbox/will/fr.py create mode 100644 tests/snapshots/test_fr.py diff --git a/sandbox/will/fr.py b/sandbox/will/fr.py new file mode 100644 index 000000000..e82b895db --- /dev/null +++ b/sandbox/will/fr.py @@ -0,0 +1,96 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Static + + +class StaticText(Static): + pass + + +class Header(Static): + pass + + +class Footer(Static): + pass + + +class FrApp(App): + + CSS = """ + Screen { + layout: horizontal; + align: center middle; + + } + + Vertical { + + } + + Header { + background: $boost; + + content-align: center middle; + text-align: center; + color: $text; + height: 3; + border: tall $warning; + } + + Horizontal { + height: 1fr; + align: center middle; + } + + Footer { + background: $boost; + + content-align: center middle; + text-align: center; + + color: $text; + height: 6; + border: tall $warning; + } + + StaticText { + background: $boost; + height: 8; + content-align: center middle; + text-align: center; + color: $text; + } + + #foo { + width: 10; + border: tall $primary; + } + + #bar { + width: 1fr; + border: tall $error; + + } + + #baz { + width: 20; + border: tall $success; + } + + """ + + def compose(self) -> ComposeResult: + yield Vertical( + Header("HEADER"), + Horizontal( + StaticText("foo", id="foo"), + StaticText("bar", id="bar"), + StaticText("baz", id="baz"), + ), + Footer("FOOTER"), + ) + + +app = FrApp() +app.run() diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 2d89fad11..2bf706e0f 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -60,10 +60,9 @@ def arrange( for dock_widget in dock_widgets: edge = dock_widget.styles.dock - fraction_unit = Fraction( - size.height if edge in ("top", "bottom") else size.width + box_model = dock_widget._get_box_model( + size, viewport, Fraction(size.width), Fraction(size.height) ) - box_model = dock_widget._get_box_model(size, viewport, fraction_unit) widget_width_fraction, widget_height_fraction, margin = box_model widget_width = int(widget_width_fraction) + margin.width diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index cf10dfcb5..945489e8d 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -1,12 +1,23 @@ from __future__ import annotations +import sys from fractions import Fraction from itertools import accumulate -from typing import cast, Sequence +from typing import cast, Sequence, TYPE_CHECKING +from .box_model import BoxModel from .css.scalar import Scalar from .geometry import Size +if TYPE_CHECKING: + from .widget import Widget + + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + def resolve( dimensions: Sequence[Scalar], @@ -71,3 +82,79 @@ def resolve( ] return results + + +def resolve_box_models( + dimensions: list[Scalar | None], + widgets: list[Widget], + size: Size, + parent_size: Size, + dimension: Literal["width", "height"] = "width", +) -> list[BoxModel]: + """Resolve box models for a list of dimensions + + Args: + dimensions (list[Scalar | None]): A list of Scalars or Nones for each dimension. + widgets (list[Widget]): Widgets in resolve. + size (Size): size of container. + parent_size (Size): Size of parent. + dimensions (Literal["width", "height"]): Which dimension to resolve. + + Returns: + list[BoxModel]: List of resolved box models. + """ + + fraction_width = Fraction(size.width) + fraction_height = Fraction(size.height) + box_models: list[BoxModel | None] = [ + ( + None + if dimension is not None and dimension.is_fraction + else widget._get_box_model( + size, parent_size, fraction_width, fraction_height + ) + ) + for (dimension, widget) in zip(dimensions, widgets) + ] + + if dimension == "width": + total_remaining = sum( + box_model.width for box_model in box_models if box_model is not None + ) + remaining_space = max(0, size.width - total_remaining) + else: + total_remaining = sum( + box_model.height for box_model in box_models if box_model is not None + ) + remaining_space = max(0, size.height - total_remaining) + + fraction_unit = Fraction( + remaining_space, + int( + sum( + dimension.value + for dimension in dimensions + if dimension and dimension.is_fraction + ) + ) + or 1, + ) + if dimension == "width": + width_fraction = fraction_unit + height_fraction = Fraction(size.height) + else: + height_fraction = fraction_unit + width_fraction = Fraction(size.width) + + box_models = [ + box_model + or widget._get_box_model( + size, + parent_size, + width_fraction, + height_fraction, + ) + for widget, box_model in zip(widgets, box_models) + ] + + return cast(list[BoxModel], box_models) diff --git a/src/textual/app.py b/src/textual/app.py index 038b821e3..5cb21b91e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1139,7 +1139,12 @@ class App(Generic[ReturnType], DOMNode): self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") async def _on_compose(self) -> None: - widgets = list(self.compose()) + try: + widgets = list(self.compose()) + except TypeError as error: + raise TypeError( + f"{self!r} compose() returned an invalid response; {error}" + ) from None await self.mount_all(widgets) def _on_idle(self) -> None: diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 7271bef54..7911aeb8a 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -20,7 +20,8 @@ def get_box_model( styles: StylesBase, container: Size, viewport: Size, - fraction_unit: Fraction, + width_fraction: Fraction, + height_fraction: Fraction, get_content_width: Callable[[Size, Size], int], get_content_height: Callable[[Size, Size, int], int], ) -> BoxModel: @@ -63,7 +64,7 @@ def get_box_model( # An explicit width styles_width = styles.width content_width = styles_width.resolve_dimension( - sizing_container - styles.margin.totals, viewport, fraction_unit + sizing_container - styles.margin.totals, viewport, width_fraction ) if is_border_box and styles_width.excludes_border: content_width -= gutter.width @@ -71,14 +72,14 @@ def get_box_model( if styles.min_width is not None: # Restrict to minimum width, if set min_width = styles.min_width.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, width_fraction ) content_width = max(content_width, min_width) if styles.max_width is not None: # Restrict to maximum width, if set max_width = styles.max_width.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, width_fraction ) if is_border_box: max_width -= gutter.width @@ -98,7 +99,7 @@ def get_box_model( styles_height = styles.height # Explicit height set content_height = styles_height.resolve_dimension( - sizing_container - styles.margin.totals, viewport, fraction_unit + sizing_container - styles.margin.totals, viewport, height_fraction ) if is_border_box and styles_height.excludes_border: content_height -= gutter.height @@ -106,14 +107,14 @@ def get_box_model( if styles.min_height is not None: # Restrict to minimum height, if set min_height = styles.min_height.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, height_fraction ) content_height = max(content_height, min_height) if styles.max_height is not None: # Restrict maximum height, if set max_height = styles.max_height.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, height_fraction ) content_height = min(content_height, max_height) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index ea9466461..cff1de36d 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -145,9 +145,7 @@ class GridLayout(Layout): y2, cell_height = rows[min(max_row, row + row_span)] cell_size = Size(cell_width + x2 - x, cell_height + y2 - y) width, height, margin = widget._get_box_model( - cell_size, - viewport, - fraction_unit, + cell_size, viewport, fraction_unit, fraction_unit ) region = ( Region(x, y, int(width + margin.width), int(height + margin.height)) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 1216beb4e..210ee95d3 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -1,12 +1,11 @@ from __future__ import annotations from fractions import Fraction -from typing import cast -from textual.geometry import Size, Region -from textual._layout import ArrangeResult, Layout, WidgetPlacement - -from textual.widget import Widget +from .._resolve import resolve_box_models +from ..geometry import Size, Region +from .._layout import ArrangeResult, Layout, WidgetPlacement +from ..widget import Widget class HorizontalLayout(Layout): @@ -22,20 +21,16 @@ class HorizontalLayout(Layout): placements: list[WidgetPlacement] = [] add_placement = placements.append - x = max_height = Fraction(0) parent_size = parent.outer_size - styles = [child.styles for child in children if child.styles.width is not None] - total_fraction = sum( - [int(style.width.value) for style in styles if style.width.is_fraction] + box_models = resolve_box_models( + [child.styles.width for child in children], + children, + size, + parent_size, + dimension="width", ) - fraction_unit = Fraction(size.width, total_fraction or 1) - - box_models = [ - widget._get_box_model(size, parent_size, fraction_unit) - for widget in cast("list[Widget]", children) - ] margins = [ max((box1.margin.right, box2.margin.left)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 9460bf0db..ccdcd1be3 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -3,6 +3,7 @@ from __future__ import annotations from fractions import Fraction from typing import TYPE_CHECKING +from .._resolve import resolve_box_models from ..geometry import Region, Size from .._layout import ArrangeResult, Layout, WidgetPlacement @@ -21,19 +22,15 @@ class VerticalLayout(Layout): placements: list[WidgetPlacement] = [] add_placement = placements.append - parent_size = parent.outer_size - styles = [child.styles for child in children if child.styles.height is not None] - total_fraction = sum( - [int(style.height.value) for style in styles if style.height.is_fraction] + box_models = resolve_box_models( + [child.styles.height for child in children], + children, + size, + parent_size, + dimension="height", ) - fraction_unit = Fraction(size.height, total_fraction or 1) - - box_models = [ - widget._get_box_model(size, parent_size, fraction_unit) - for widget in children - ] margins = [ max((box1.margin.bottom, box2.margin.top)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 363c1f518..f07599e59 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -409,7 +409,11 @@ class Widget(DOMNode): ) def _get_box_model( - self, container: Size, viewport: Size, fraction_unit: Fraction + self, + container: Size, + viewport: Size, + width_fraction: Fraction, + height_fraction: Fraction, ) -> BoxModel: """Process the box model for this widget. @@ -425,7 +429,8 @@ class Widget(DOMNode): self.styles, container, viewport, - fraction_unit, + width_fraction, + height_fraction, self.get_content_width, self.get_content_height, ) @@ -1930,8 +1935,13 @@ class Widget(DOMNode): async def handle_key(self, event: events.Key) -> bool: return await self.dispatch_key(event) - async def _on_compose(self, event: events.Compose) -> None: - widgets = list(self.compose()) + async def _on_compose(self) -> None: + try: + widgets = list(self.compose()) + except TypeError as error: + raise TypeError( + f"{self!r} compose() returned an invalid response; {error}" + ) from None await self.mount(*widgets) def _on_mount(self, event: events.Mount) -> None: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 92eb75fc1..f70b1645d 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -4913,6 +4913,162 @@ ''' # --- +# name: test_fr_units + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FRApp + + + + + + + + + + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + HEADER + + + + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ┏━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━┓ + foobarbaz + + + + + + + + + + + + ┗━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━┛ + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + FOOTER + + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + + + + ''' +# --- # name: test_grid_layout_basic ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2f04c9314..33c4ad0bd 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -8,6 +8,7 @@ from textual.widgets import Input, Button # --- Layout related stuff --- + def test_grid_layout_basic(snap_compare): assert snap_compare("docs/examples/guide/layout/grid_layout1.py") @@ -41,6 +42,7 @@ def test_dock_layout_sidebar(snap_compare): # When adding a new widget, ideally we should also create a snapshot test # from these examples which test rendering and simple interactions with it. + def test_checkboxes(snap_compare): """Tests checkboxes but also acts a regression test for using width: auto in a Horizontal layout context.""" @@ -94,12 +96,18 @@ def test_header_render(snap_compare): assert snap_compare("docs/examples/widgets/header.py") +def test_fr_units(snap_compare): + assert snap_compare("tests/snapshots/test_fr.py") + + # --- CSS properties --- # We have a canonical example for each CSS property that is shown in their docs. # If any of these change, something has likely broken, so snapshot each of them. PATHS = [ - str(PurePosixPath(path)) for path in Path("docs/examples/styles").iterdir() if path.suffix == ".py" + str(PurePosixPath(path)) + for path in Path("docs/examples/styles").iterdir() + if path.suffix == ".py" ] diff --git a/tests/snapshots/test_fr.py b/tests/snapshots/test_fr.py new file mode 100644 index 000000000..d9bcddeb9 --- /dev/null +++ b/tests/snapshots/test_fr.py @@ -0,0 +1,55 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Static + + +class StaticText(Static): + pass + + +class FRApp(App): + + CSS = """ + StaticText { + height: 1fr; + background: $boost; + border: heavy white; + } + #foo { + width: 10; + } + #bar { + width: 1fr; + } + #baz { + width: 8; + } + #header { + height: 1fr + } + + Horizontal { + height: 2fr; + } + + #footer { + height: 4; + } + + """ + + def compose(self) -> ComposeResult: + yield Vertical( + StaticText("HEADER", id="header"), + Horizontal( + StaticText("foo", id="foo"), + StaticText("bar", id="bar"), + StaticText("baz", id="baz"), + ), + StaticText("FOOTER", id="footer"), + ) + + +if __name__ == "__main__": + app = FRApp() + app.run() diff --git a/tests/test_box_model.py b/tests/test_box_model.py index 1c82620ff..889140041 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -26,7 +26,7 @@ def test_content_box(): assert False, "must not be called" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) # Size should be inclusive of padding / border assert box_model == BoxModel(Fraction(10), Fraction(8), Spacing(0, 0, 0, 0)) @@ -35,7 +35,7 @@ def test_content_box(): styles.box_sizing = "content-box" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) # width and height have added padding / border to accommodate content assert box_model == BoxModel(Fraction(14), Fraction(12), Spacing(0, 0, 0, 0)) @@ -53,7 +53,7 @@ def test_width(): return 10 box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) @@ -61,7 +61,7 @@ def test_width(): styles.margin = (1, 2, 3, 4) box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -69,7 +69,7 @@ def test_width(): styles.width = "auto" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) # Setting width to auto should call get_auto_width assert box_model == BoxModel(Fraction(10), Fraction(16), Spacing(1, 2, 3, 4)) @@ -78,7 +78,7 @@ def test_width(): styles.width = "100vw" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(80), Fraction(16), Spacing(1, 2, 3, 4)) @@ -86,7 +86,7 @@ def test_width(): styles.width = "100%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -94,7 +94,7 @@ def test_width(): styles.max_width = "50%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(30), Fraction(16), Spacing(1, 2, 3, 4)) @@ -111,7 +111,7 @@ def test_height(): return 10 box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) @@ -119,7 +119,7 @@ def test_height(): styles.margin = (1, 2, 3, 4) box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -127,7 +127,7 @@ def test_height(): styles.height = "100vh" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(24), Spacing(1, 2, 3, 4)) @@ -135,7 +135,7 @@ def test_height(): styles.height = "100%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -143,7 +143,7 @@ def test_height(): styles.margin = 2 box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2)) @@ -152,7 +152,7 @@ def test_height(): styles.max_height = "50%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(10), Spacing(1, 2, 3, 4)) @@ -173,7 +173,7 @@ def test_max(): assert False, "must not be called" box_model = get_box_model( - styles, Size(40, 30), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(40, 30), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0)) @@ -194,6 +194,6 @@ def test_min(): assert False, "must not be called" box_model = get_box_model( - styles, Size(40, 30), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(40, 30), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0)) From c4ad6b93fd7f92ebf72fb78db49558a240b8c1a7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 10:11:24 +0000 Subject: [PATCH 31/41] docstrings --- src/textual/_resolve.py | 2 +- src/textual/box_model.py | 2 ++ src/textual/widget.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 945489e8d..854098177 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -143,8 +143,8 @@ def resolve_box_models( width_fraction = fraction_unit height_fraction = Fraction(size.height) else: - height_fraction = fraction_unit width_fraction = Fraction(size.width) + height_fraction = fraction_unit box_models = [ box_model diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 7911aeb8a..2bd93aa00 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -31,6 +31,8 @@ def get_box_model( styles (StylesBase): Styles object. container (Size): The size of the widget container. viewport (Size): The viewport size. + width_fraction (Fraction): A fraction used for 1 `fr` unit on the width dimension. + height_fraction (Fraction):A fraction used for 1 `fr` unit on the height dimension. get_auto_width (Callable): A callable which accepts container size and parent size and returns a width. get_auto_height (Callable): A callable which accepts container size and parent size and returns a height. diff --git a/src/textual/widget.py b/src/textual/widget.py index f07599e59..911a221ed 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -420,7 +420,8 @@ class Widget(DOMNode): Args: container (Size): The size of the container widget (with a layout) viewport (Size): The viewport size. - fraction_unit (Fraction): The unit used for `fr` units. + width_fraction (Fraction): A fraction used for 1 `fr` unit on the width dimension. + height_fraction (Fraction):A fraction used for 1 `fr` unit on the height dimension. Returns: BoxModel: The size and margin for this widget. From 0a02324309cc1b192114afce3bdace5a77234a1b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 10:18:09 +0000 Subject: [PATCH 32/41] renamed test --- tests/snapshot_tests/test_snapshots.py | 2 +- tests/snapshots/{test_fr.py => fr_units.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename tests/snapshots/{test_fr.py => fr_units.py} (100%) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 33c4ad0bd..70b4f1eef 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -97,7 +97,7 @@ def test_header_render(snap_compare): def test_fr_units(snap_compare): - assert snap_compare("tests/snapshots/test_fr.py") + assert snap_compare("tests/snapshots/fr_units.py") # --- CSS properties --- diff --git a/tests/snapshots/test_fr.py b/tests/snapshots/fr_units.py similarity index 100% rename from tests/snapshots/test_fr.py rename to tests/snapshots/fr_units.py From c705752352f2bb0627eb64dad73cb9dee8b2bb16 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 10:22:31 +0000 Subject: [PATCH 33/41] 38 fix --- src/textual/_resolve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 854098177..3ace9759d 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -157,4 +157,4 @@ def resolve_box_models( for widget, box_model in zip(widgets, box_models) ] - return cast(list[BoxModel], box_models) + return cast("list[BoxModel]", box_models) From fab60894b632a52046b2a709e147f4e50d3d3348 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 11:30:55 +0000 Subject: [PATCH 34/41] relax dependencies --- poetry.lock | 631 ++++++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 8 +- 2 files changed, 553 insertions(+), 86 deletions(-) diff --git a/poetry.lock b/poetry.lock index a8e8c639f..5aaf7bfbb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -18,7 +18,7 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} yarl = ">=1.0,<2.0" [package.extras] -speedups = ["aiodns", "brotli", "cchardet"] +speedups = ["Brotli", "aiodns", "cchardet"] [[package]] name = "aiosignal" @@ -59,10 +59,10 @@ optional = false python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] [[package]] name = "black" @@ -120,11 +120,11 @@ optional = false python-versions = ">=3.6.0" [package.extras] -unicode_backport = ["unicodedata2"] +unicode-backport = ["unicodedata2"] [[package]] name = "click" -version = "8.1.2" +version = "8.1.3" description = "Composable command line interface toolkit" category = "main" optional = false @@ -136,11 +136,11 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" -version = "0.4.5" +version = "0.4.6" description = "Cross-platform colored terminal text." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" [[package]] name = "colored" @@ -180,6 +180,17 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "exceptiongroup" +version = "1.0.0" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "filelock" version = "3.8.0" @@ -212,11 +223,11 @@ python-versions = "*" python-dateutil = ">=2.8.1" [package.extras] -dev = ["twine", "markdown", "flake8", "wheel"] +dev = ["flake8", "markdown", "twine", "wheel"] [[package]] name = "griffe" -version = "0.22.2" +version = "0.23.0" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." category = "dev" optional = false @@ -230,7 +241,7 @@ async = ["aiofiles (>=0.7,<1.0)"] [[package]] name = "identify" -version = "2.5.6" +version = "2.5.8" description = "File identification library for Python" category = "dev" optional = false @@ -260,9 +271,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" @@ -339,8 +350,8 @@ typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} watchdog = ">=2.0" [package.extras] -min-versions = ["watchdog (==2.0)", "typing-extensions (==3.10)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "packaging (==20.5)", "mergedeep (==1.3.4)", "markupsafe (==2.0.1)", "markdown (==3.2.1)", "jinja2 (==2.11.1)", "importlib-metadata (==4.3)", "ghp-import (==1.0)", "colorama (==0.4)", "click (==7.0)", "babel (==2.9.0)"] i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"] [[package]] name = "mkdocs-autorefs" @@ -472,6 +483,9 @@ category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +[package.dependencies] +setuptools = "*" + [[package]] name = "packaging" version = "21.3" @@ -500,8 +514,8 @@ optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"] -test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] [[package]] name = "pluggy" @@ -535,14 +549,6 @@ pyyaml = ">=5.1" toml = "*" virtualenv = ">=20.0.8" -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - [[package]] name = "pygments" version = "2.13.0" @@ -574,11 +580,11 @@ optional = false python-versions = ">=3.6.8" [package.extras] -diagrams = ["railroad-diagrams", "jinja2"] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "7.1.3" +version = "7.2.0" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -587,12 +593,12 @@ python-versions = ">=3.7" [package.dependencies] attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] @@ -626,7 +632,7 @@ pytest = ">=6.1.0" typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" @@ -642,7 +648,7 @@ pytest = ">=4.6" toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "python-dateutil" @@ -690,7 +696,7 @@ urllib3 = ">=1.21.1,<1.27" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" @@ -708,6 +714,19 @@ typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9 [package.extras] jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] +[[package]] +name = "setuptools" +version = "65.5.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "six" version = "1.16.0" @@ -780,26 +799,26 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" [package.extras] -brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.16.5" +version = "20.16.6" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -distlib = ">=0.3.5,<1" +distlib = ">=0.3.6,<1" filelock = ">=3.4.1,<4" importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.8\""} platformdirs = ">=2.4,<3" [package.extras] -docs = ["proselint (>=0.13)", "sphinx (>=5.1.1)", "sphinx-argparse (>=0.3.1)", "sphinx-rtd-theme (>=1)", "towncrier (>=21.9)"] +docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] [[package]] @@ -828,15 +847,15 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [[package]] name = "zipp" -version = "3.9.0" +version = "3.10.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [extras] dev = ["aiohttp", "click", "msgpack"] @@ -844,10 +863,98 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "84203bb5193474eb9204f4f808739cb25e61f02a38d0062ea2ea71d3703573c1" +content-hash = "cfa35529900ee7fc7bca1e2a189f0240081bdbc75b501b25b394dfce66261c8b" [metadata.files] -aiohttp = [] +aiohttp = [ + {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ba71c9b4dcbb16212f334126cc3d8beb6af377f6703d9dc2d9fb3874fd667ee9"}, + {file = "aiohttp-3.8.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d24b8bb40d5c61ef2d9b6a8f4528c2f17f1c5d2d31fed62ec860f6006142e83e"}, + {file = "aiohttp-3.8.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f88df3a83cf9df566f171adba39d5bd52814ac0b94778d2448652fc77f9eb491"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97decbb3372d4b69e4d4c8117f44632551c692bb1361b356a02b97b69e18a62"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:309aa21c1d54b8ef0723181d430347d7452daaff93e8e2363db8e75c72c2fb2d"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad5383a67514e8e76906a06741febd9126fc7c7ff0f599d6fcce3e82b80d026f"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20acae4f268317bb975671e375493dbdbc67cddb5f6c71eebdb85b34444ac46b"}, + {file = "aiohttp-3.8.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05a3c31c6d7cd08c149e50dc7aa2568317f5844acd745621983380597f027a18"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d6f76310355e9fae637c3162936e9504b4767d5c52ca268331e2756e54fd4ca5"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:256deb4b29fe5e47893fa32e1de2d73c3afe7407738bd3c63829874661d4822d"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5c59fcd80b9049b49acd29bd3598cada4afc8d8d69bd4160cd613246912535d7"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:059a91e88f2c00fe40aed9031b3606c3f311414f86a90d696dd982e7aec48142"}, + {file = "aiohttp-3.8.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2feebbb6074cdbd1ac276dbd737b40e890a1361b3cc30b74ac2f5e24aab41f7b"}, + {file = "aiohttp-3.8.3-cp310-cp310-win32.whl", hash = "sha256:5bf651afd22d5f0c4be16cf39d0482ea494f5c88f03e75e5fef3a85177fecdeb"}, + {file = "aiohttp-3.8.3-cp310-cp310-win_amd64.whl", hash = "sha256:653acc3880459f82a65e27bd6526e47ddf19e643457d36a2250b85b41a564715"}, + {file = "aiohttp-3.8.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:86fc24e58ecb32aee09f864cb11bb91bc4c1086615001647dbfc4dc8c32f4008"}, + {file = "aiohttp-3.8.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75e14eac916f024305db517e00a9252714fce0abcb10ad327fb6dcdc0d060f1d"}, + {file = "aiohttp-3.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1fde0f44029e02d02d3993ad55ce93ead9bb9b15c6b7ccd580f90bd7e3de476"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ab94426ddb1ecc6a0b601d832d5d9d421820989b8caa929114811369673235c"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89d2e02167fa95172c017732ed7725bc8523c598757f08d13c5acca308e1a061"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:02f9a2c72fc95d59b881cf38a4b2be9381b9527f9d328771e90f72ac76f31ad8"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c7149272fb5834fc186328e2c1fa01dda3e1fa940ce18fded6d412e8f2cf76d"}, + {file = "aiohttp-3.8.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:512bd5ab136b8dc0ffe3fdf2dfb0c4b4f49c8577f6cae55dca862cd37a4564e2"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7018ecc5fe97027214556afbc7c502fbd718d0740e87eb1217b17efd05b3d276"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:88c70ed9da9963d5496d38320160e8eb7e5f1886f9290475a881db12f351ab5d"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:da22885266bbfb3f78218dc40205fed2671909fbd0720aedba39b4515c038091"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:e65bc19919c910127c06759a63747ebe14f386cda573d95bcc62b427ca1afc73"}, + {file = "aiohttp-3.8.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:08c78317e950e0762c2983f4dd58dc5e6c9ff75c8a0efeae299d363d439c8e34"}, + {file = "aiohttp-3.8.3-cp311-cp311-win32.whl", hash = "sha256:45d88b016c849d74ebc6f2b6e8bc17cabf26e7e40c0661ddd8fae4c00f015697"}, + {file = "aiohttp-3.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:96372fc29471646b9b106ee918c8eeb4cca423fcbf9a34daa1b93767a88a2290"}, + {file = "aiohttp-3.8.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c971bf3786b5fad82ce5ad570dc6ee420f5b12527157929e830f51c55dc8af77"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff25f48fc8e623d95eca0670b8cc1469a83783c924a602e0fbd47363bb54aaca"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e381581b37db1db7597b62a2e6b8b57c3deec95d93b6d6407c5b61ddc98aca6d"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db19d60d846283ee275d0416e2a23493f4e6b6028825b51290ac05afc87a6f97"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25892c92bee6d9449ffac82c2fe257f3a6f297792cdb18ad784737d61e7a9a85"}, + {file = "aiohttp-3.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398701865e7a9565d49189f6c90868efaca21be65c725fc87fc305906be915da"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:4a4fbc769ea9b6bd97f4ad0b430a6807f92f0e5eb020f1e42ece59f3ecfc4585"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:b29bfd650ed8e148f9c515474a6ef0ba1090b7a8faeee26b74a8ff3b33617502"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:1e56b9cafcd6531bab5d9b2e890bb4937f4165109fe98e2b98ef0dcfcb06ee9d"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ec40170327d4a404b0d91855d41bfe1fe4b699222b2b93e3d833a27330a87a6d"}, + {file = "aiohttp-3.8.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2df5f139233060578d8c2c975128fb231a89ca0a462b35d4b5fcf7c501ebdbe1"}, + {file = "aiohttp-3.8.3-cp36-cp36m-win32.whl", hash = "sha256:f973157ffeab5459eefe7b97a804987876dd0a55570b8fa56b4e1954bf11329b"}, + {file = "aiohttp-3.8.3-cp36-cp36m-win_amd64.whl", hash = "sha256:437399385f2abcd634865705bdc180c8314124b98299d54fe1d4c8990f2f9494"}, + {file = "aiohttp-3.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:09e28f572b21642128ef31f4e8372adb6888846f32fecb288c8b0457597ba61a"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f3553510abdbec67c043ca85727396ceed1272eef029b050677046d3387be8d"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e168a7560b7c61342ae0412997b069753f27ac4862ec7867eff74f0fe4ea2ad9"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db4c979b0b3e0fa7e9e69ecd11b2b3174c6963cebadeecfb7ad24532ffcdd11a"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e164e0a98e92d06da343d17d4e9c4da4654f4a4588a20d6c73548a29f176abe2"}, + {file = "aiohttp-3.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8a78079d9a39ca9ca99a8b0ac2fdc0c4d25fc80c8a8a82e5c8211509c523363"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:21b30885a63c3f4ff5b77a5d6caf008b037cb521a5f33eab445dc566f6d092cc"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4b0f30372cef3fdc262f33d06e7b411cd59058ce9174ef159ad938c4a34a89da"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:8135fa153a20d82ffb64f70a1b5c2738684afa197839b34cc3e3c72fa88d302c"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ad61a9639792fd790523ba072c0555cd6be5a0baf03a49a5dd8cfcf20d56df48"}, + {file = "aiohttp-3.8.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:978b046ca728073070e9abc074b6299ebf3501e8dee5e26efacb13cec2b2dea0"}, + {file = "aiohttp-3.8.3-cp37-cp37m-win32.whl", hash = "sha256:0d2c6d8c6872df4a6ec37d2ede71eff62395b9e337b4e18efd2177de883a5033"}, + {file = "aiohttp-3.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:21d69797eb951f155026651f7e9362877334508d39c2fc37bd04ff55b2007091"}, + {file = "aiohttp-3.8.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ca9af5f8f5812d475c5259393f52d712f6d5f0d7fdad9acdb1107dd9e3cb7eb"}, + {file = "aiohttp-3.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d90043c1882067f1bd26196d5d2db9aa6d268def3293ed5fb317e13c9413ea4"}, + {file = "aiohttp-3.8.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d737fc67b9a970f3234754974531dc9afeea11c70791dcb7db53b0cf81b79784"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebf909ea0a3fc9596e40d55d8000702a85e27fd578ff41a5500f68f20fd32e6c"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5835f258ca9f7c455493a57ee707b76d2d9634d84d5d7f62e77be984ea80b849"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da37dcfbf4b7f45d80ee386a5f81122501ec75672f475da34784196690762f4b"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f44875f2804bc0511a69ce44a9595d5944837a62caecc8490bbdb0e18b1342"}, + {file = "aiohttp-3.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:527b3b87b24844ea7865284aabfab08eb0faf599b385b03c2aa91fc6edd6e4b6"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5ba88df9aa5e2f806650fcbeedbe4f6e8736e92fc0e73b0400538fd25a4dd96"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e7b8813be97cab8cb52b1375f41f8e6804f6507fe4660152e8ca5c48f0436017"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:2dea10edfa1a54098703cb7acaa665c07b4e7568472a47f4e64e6319d3821ccf"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:713d22cd9643ba9025d33c4af43943c7a1eb8547729228de18d3e02e278472b6"}, + {file = "aiohttp-3.8.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2d252771fc85e0cf8da0b823157962d70639e63cb9b578b1dec9868dd1f4f937"}, + {file = "aiohttp-3.8.3-cp38-cp38-win32.whl", hash = "sha256:66bd5f950344fb2b3dbdd421aaa4e84f4411a1a13fca3aeb2bcbe667f80c9f76"}, + {file = "aiohttp-3.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:84b14f36e85295fe69c6b9789b51a0903b774046d5f7df538176516c3e422446"}, + {file = "aiohttp-3.8.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16c121ba0b1ec2b44b73e3a8a171c4f999b33929cd2397124a8c7fcfc8cd9e06"}, + {file = "aiohttp-3.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8d6aaa4e7155afaf994d7924eb290abbe81a6905b303d8cb61310a2aba1c68ba"}, + {file = "aiohttp-3.8.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43046a319664a04b146f81b40e1545d4c8ac7b7dd04c47e40bf09f65f2437346"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599418aaaf88a6d02a8c515e656f6faf3d10618d3dd95866eb4436520096c84b"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a2964319d359f494f16011e23434f6f8ef0434acd3cf154a6b7bec511e2fb7"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73a4131962e6d91109bca6536416aa067cf6c4efb871975df734f8d2fd821b37"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:598adde339d2cf7d67beaccda3f2ce7c57b3b412702f29c946708f69cf8222aa"}, + {file = "aiohttp-3.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75880ed07be39beff1881d81e4a907cafb802f306efd6d2d15f2b3c69935f6fb"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a0239da9fbafd9ff82fd67c16704a7d1bccf0d107a300e790587ad05547681c8"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4e3a23ec214e95c9fe85a58470b660efe6534b83e6cbe38b3ed52b053d7cb6ad"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:47841407cc89a4b80b0c52276f3cc8138bbbfba4b179ee3acbd7d77ae33f7ac4"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:54d107c89a3ebcd13228278d68f1436d3f33f2dd2af5415e3feaeb1156e1a62c"}, + {file = "aiohttp-3.8.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c37c5cce780349d4d51739ae682dec63573847a2a8dcb44381b174c3d9c8d403"}, + {file = "aiohttp-3.8.3-cp39-cp39-win32.whl", hash = "sha256:f178d2aadf0166be4df834c4953da2d7eef24719e8aec9a65289483eeea9d618"}, + {file = "aiohttp-3.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:88e5be56c231981428f4f506c68b6a46fa25c4123a2e86d156c58a8369d31ab7"}, + {file = "aiohttp-3.8.3.tar.gz", hash = "sha256:3828fb41b7203176b82fe5d699e0d845435f2374750a44b480ea6b930f6be269"}, +] aiosignal = [ {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, @@ -860,43 +967,209 @@ asynctest = [ {file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"}, {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, ] -attrs = [] -black = [] +attrs = [ + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, +] +black = [ + {file = "black-22.10.0-1fixedarch-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:5cc42ca67989e9c3cf859e84c2bf014f6633db63d1cbdf8fdb666dcd9e77e3fa"}, + {file = "black-22.10.0-1fixedarch-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5d8f74030e67087b219b032aa33a919fae8806d49c867846bfacde57f43972ef"}, + {file = "black-22.10.0-1fixedarch-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:197df8509263b0b8614e1df1756b1dd41be6738eed2ba9e9769f3880c2b9d7b6"}, + {file = "black-22.10.0-1fixedarch-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:2644b5d63633702bc2c5f3754b1b475378fbbfb481f62319388235d0cd104c2d"}, + {file = "black-22.10.0-1fixedarch-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:e41a86c6c650bcecc6633ee3180d80a025db041a8e2398dcc059b3afa8382cd4"}, + {file = "black-22.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2039230db3c6c639bd84efe3292ec7b06e9214a2992cd9beb293d639c6402edb"}, + {file = "black-22.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14ff67aec0a47c424bc99b71005202045dc09270da44a27848d534600ac64fc7"}, + {file = "black-22.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:819dc789f4498ecc91438a7de64427c73b45035e2e3680c92e18795a839ebb66"}, + {file = "black-22.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b9b29da4f564ba8787c119f37d174f2b69cdfdf9015b7d8c5c16121ddc054ae"}, + {file = "black-22.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b49776299fece66bffaafe357d929ca9451450f5466e997a7285ab0fe28e3b"}, + {file = "black-22.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:21199526696b8f09c3997e2b4db8d0b108d801a348414264d2eb8eb2532e540d"}, + {file = "black-22.10.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e464456d24e23d11fced2bc8c47ef66d471f845c7b7a42f3bd77bf3d1789650"}, + {file = "black-22.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9311e99228ae10023300ecac05be5a296f60d2fd10fff31cf5c1fa4ca4b1988d"}, + {file = "black-22.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fba8a281e570adafb79f7755ac8721b6cf1bbf691186a287e990c7929c7692ff"}, + {file = "black-22.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:915ace4ff03fdfff953962fa672d44be269deb2eaf88499a0f8805221bc68c87"}, + {file = "black-22.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:444ebfb4e441254e87bad00c661fe32df9969b2bf224373a448d8aca2132b395"}, + {file = "black-22.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:974308c58d057a651d182208a484ce80a26dac0caef2895836a92dd6ebd725e0"}, + {file = "black-22.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72ef3925f30e12a184889aac03d77d031056860ccae8a1e519f6cbb742736383"}, + {file = "black-22.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:432247333090c8c5366e69627ccb363bc58514ae3e63f7fc75c54b1ea80fa7de"}, + {file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"}, + {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"}, +] cached-property = [ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, ] -certifi = [] +certifi = [ + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, +] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -charset-normalizer = [] +charset-normalizer = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] click = [ - {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, - {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, ] colorama = [ - {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, - {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +colored = [ + {file = "colored-1.4.3.tar.gz", hash = "sha256:b7b48b9f40e8a65bbb54813d5d79dd008dc8b8c5638d5bbfd30fc5a82e6def7a"}, ] -colored = [] commonmark = [ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, ] -coverage = [] -distlib = [] -filelock = [] -frozenlist = [] +coverage = [ + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, +] +distlib = [ + {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, + {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, +] +exceptiongroup = [ + {file = "exceptiongroup-1.0.0-py3-none-any.whl", hash = "sha256:2ac84b496be68464a2da60da518af3785fff8b7ec0d090a581604bc870bdee41"}, + {file = "exceptiongroup-1.0.0.tar.gz", hash = "sha256:affbabf13fb6e98988c38d9c5650e701569fe3c1de3233cfb61c5f33774690ad"}, +] +filelock = [ + {file = "filelock-3.8.0-py3-none-any.whl", hash = "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4"}, + {file = "filelock-3.8.0.tar.gz", hash = "sha256:55447caa666f2198c5b6b13a26d2084d26fa5b115c00d065664b2124680c4edc"}, +] +frozenlist = [ + {file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5f271c93f001748fc26ddea409241312a75e13466b06c94798d1a341cf0e6989"}, + {file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c6ef8014b842f01f5d2b55315f1af5cbfde284eb184075c189fd657c2fd8204"}, + {file = "frozenlist-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:219a9676e2eae91cb5cc695a78b4cb43d8123e4160441d2b6ce8d2c70c60e2f3"}, + {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b47d64cdd973aede3dd71a9364742c542587db214e63b7529fbb487ed67cddd9"}, + {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2af6f7a4e93f5d08ee3f9152bce41a6015b5cf87546cb63872cc19b45476e98a"}, + {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a718b427ff781c4f4e975525edb092ee2cdef6a9e7bc49e15063b088961806f8"}, + {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c56c299602c70bc1bb5d1e75f7d8c007ca40c9d7aebaf6e4ba52925d88ef826d"}, + {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:717470bfafbb9d9be624da7780c4296aa7935294bd43a075139c3d55659038ca"}, + {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:31b44f1feb3630146cffe56344704b730c33e042ffc78d21f2125a6a91168131"}, + {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c3b31180b82c519b8926e629bf9f19952c743e089c41380ddca5db556817b221"}, + {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:d82bed73544e91fb081ab93e3725e45dd8515c675c0e9926b4e1f420a93a6ab9"}, + {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49459f193324fbd6413e8e03bd65789e5198a9fa3095e03f3620dee2f2dabff2"}, + {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:94e680aeedc7fd3b892b6fa8395b7b7cc4b344046c065ed4e7a1e390084e8cb5"}, + {file = "frozenlist-1.3.1-cp310-cp310-win32.whl", hash = "sha256:fabb953ab913dadc1ff9dcc3a7a7d3dc6a92efab3a0373989b8063347f8705be"}, + {file = "frozenlist-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:eee0c5ecb58296580fc495ac99b003f64f82a74f9576a244d04978a7e97166db"}, + {file = "frozenlist-1.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0bc75692fb3770cf2b5856a6c2c9de967ca744863c5e89595df64e252e4b3944"}, + {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086ca1ac0a40e722d6833d4ce74f5bf1aba2c77cbfdc0cd83722ffea6da52a04"}, + {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b51eb355e7f813bcda00276b0114c4172872dc5fb30e3fea059b9367c18fbcb"}, + {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74140933d45271c1a1283f708c35187f94e1256079b3c43f0c2267f9db5845ff"}, + {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee4c5120ddf7d4dd1eaf079af3af7102b56d919fa13ad55600a4e0ebe532779b"}, + {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97d9e00f3ac7c18e685320601f91468ec06c58acc185d18bb8e511f196c8d4b2"}, + {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6e19add867cebfb249b4e7beac382d33215d6d54476bb6be46b01f8cafb4878b"}, + {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a027f8f723d07c3f21963caa7d585dcc9b089335565dabe9c814b5f70c52705a"}, + {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:61d7857950a3139bce035ad0b0945f839532987dfb4c06cfe160254f4d19df03"}, + {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:53b2b45052e7149ee8b96067793db8ecc1ae1111f2f96fe1f88ea5ad5fd92d10"}, + {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bbb1a71b1784e68870800b1bc9f3313918edc63dbb8f29fbd2e767ce5821696c"}, + {file = "frozenlist-1.3.1-cp37-cp37m-win32.whl", hash = "sha256:ab6fa8c7871877810e1b4e9392c187a60611fbf0226a9e0b11b7b92f5ac72792"}, + {file = "frozenlist-1.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f89139662cc4e65a4813f4babb9ca9544e42bddb823d2ec434e18dad582543bc"}, + {file = "frozenlist-1.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4c0c99e31491a1d92cde8648f2e7ccad0e9abb181f6ac3ddb9fc48b63301808e"}, + {file = "frozenlist-1.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:61e8cb51fba9f1f33887e22488bad1e28dd8325b72425f04517a4d285a04c519"}, + {file = "frozenlist-1.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc2f3e368ee5242a2cbe28323a866656006382872c40869b49b265add546703f"}, + {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58fb94a01414cddcdc6839807db77ae8057d02ddafc94a42faee6004e46c9ba8"}, + {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:022178b277cb9277d7d3b3f2762d294f15e85cd2534047e68a118c2bb0058f3e"}, + {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:572ce381e9fe027ad5e055f143763637dcbac2542cfe27f1d688846baeef5170"}, + {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19127f8dcbc157ccb14c30e6f00392f372ddb64a6ffa7106b26ff2196477ee9f"}, + {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42719a8bd3792744c9b523674b752091a7962d0d2d117f0b417a3eba97d1164b"}, + {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2743bb63095ef306041c8f8ea22bd6e4d91adabf41887b1ad7886c4c1eb43d5f"}, + {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fa47319a10e0a076709644a0efbcaab9e91902c8bd8ef74c6adb19d320f69b83"}, + {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52137f0aea43e1993264a5180c467a08a3e372ca9d378244c2d86133f948b26b"}, + {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:f5abc8b4d0c5b556ed8cd41490b606fe99293175a82b98e652c3f2711b452988"}, + {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1e1cf7bc8cbbe6ce3881863671bac258b7d6bfc3706c600008925fb799a256e2"}, + {file = "frozenlist-1.3.1-cp38-cp38-win32.whl", hash = "sha256:0dde791b9b97f189874d654c55c24bf7b6782343e14909c84beebd28b7217845"}, + {file = "frozenlist-1.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:9494122bf39da6422b0972c4579e248867b6b1b50c9b05df7e04a3f30b9a413d"}, + {file = "frozenlist-1.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31bf9539284f39ff9398deabf5561c2b0da5bb475590b4e13dd8b268d7a3c5c1"}, + {file = "frozenlist-1.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e0c8c803f2f8db7217898d11657cb6042b9b0553a997c4a0601f48a691480fab"}, + {file = "frozenlist-1.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da5ba7b59d954f1f214d352308d1d86994d713b13edd4b24a556bcc43d2ddbc3"}, + {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74e6b2b456f21fc93ce1aff2b9728049f1464428ee2c9752a4b4f61e98c4db96"}, + {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526d5f20e954d103b1d47232e3839f3453c02077b74203e43407b962ab131e7b"}, + {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b499c6abe62a7a8d023e2c4b2834fce78a6115856ae95522f2f974139814538c"}, + {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab386503f53bbbc64d1ad4b6865bf001414930841a870fc97f1546d4d133f141"}, + {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f63c308f82a7954bf8263a6e6de0adc67c48a8b484fab18ff87f349af356efd"}, + {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:12607804084d2244a7bd4685c9d0dca5df17a6a926d4f1967aa7978b1028f89f"}, + {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:da1cdfa96425cbe51f8afa43e392366ed0b36ce398f08b60de6b97e3ed4affef"}, + {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f810e764617b0748b49a731ffaa525d9bb36ff38332411704c2400125af859a6"}, + {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:35c3d79b81908579beb1fb4e7fcd802b7b4921f1b66055af2578ff7734711cfa"}, + {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c92deb5d9acce226a501b77307b3b60b264ca21862bd7d3e0c1f3594022f01bc"}, + {file = "frozenlist-1.3.1-cp39-cp39-win32.whl", hash = "sha256:5e77a8bd41e54b05e4fb2708dc6ce28ee70325f8c6f50f3df86a44ecb1d7a19b"}, + {file = "frozenlist-1.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:625d8472c67f2d96f9a4302a947f92a7adbc1e20bedb6aff8dbc8ff039ca6189"}, + {file = "frozenlist-1.3.1.tar.gz", hash = "sha256:3a735e4211a04ccfa3f4833547acdf5d2f863bfeb01cfd3edaffbc251f15cec8"}, +] ghp-import = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, ] -griffe = [] -identify = [] -idna = [] -importlib-metadata = [] +griffe = [ + {file = "griffe-0.23.0-py3-none-any.whl", hash = "sha256:cfca5f523808109da3f8cfaa46e325fa2e5bef51120d1146e908c121b56475f0"}, + {file = "griffe-0.23.0.tar.gz", hash = "sha256:a639e2968c8e27f56ebcc57f869a03cea7ac7e7f5684bd2429c665f761c4e7bd"}, +] +identify = [ + {file = "identify-2.5.8-py2.py3-none-any.whl", hash = "sha256:48b7925fe122720088aeb7a6c34f17b27e706b72c61070f27fe3789094233440"}, + {file = "identify-2.5.8.tar.gz", hash = "sha256:7a214a10313b9489a0d61467db2856ae8d0b8306fc923e03a9effa53d8aedc58"}, +] +idna = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, + {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, +] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, @@ -955,13 +1228,22 @@ mergedeep = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] -mkdocs = [] +mkdocs = [ + {file = "mkdocs-1.4.1-py3-none-any.whl", hash = "sha256:2b7845c2775396214cd408753e4cfb01af3cfed36acc141a84bce2ceec9d705d"}, + {file = "mkdocs-1.4.1.tar.gz", hash = "sha256:07ed90be4062e4ef732bbac2623097b9dca35c67b562c38cfd0bfbc7151758c1"}, +] mkdocs-autorefs = [ {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] -mkdocs-material = [] -mkdocs-material-extensions = [] +mkdocs-material = [ + {file = "mkdocs_material-8.5.7-py3-none-any.whl", hash = "sha256:07fc70dfa325a8019b99a124751c43e4c1c2a739ed1b0b82c00f823f31c9a1e2"}, + {file = "mkdocs_material-8.5.7.tar.gz", hash = "sha256:ff4c7851b2e5f9a6cfa0a8b247e973ebae753b9836a53bd68742827541ab73e5"}, +] +mkdocs-material-extensions = [ + {file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"}, + {file = "mkdocs_material_extensions-1.1.tar.gz", hash = "sha256:96ca979dae66d65c2099eefe189b49d5ac62f76afb59c38e069ffc7cf3c131ec"}, +] mkdocstrings = [ {file = "mkdocstrings-0.19.0-py3-none-any.whl", hash = "sha256:3217d510d385c961f69385a670b2677e68e07b5fea4a504d86bf54c006c87c7d"}, {file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"}, @@ -1085,12 +1367,40 @@ multidict = [ {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"}, {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, ] -mypy = [] +mypy = [ + {file = "mypy-0.982-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5085e6f442003fa915aeb0a46d4da58128da69325d8213b4b35cc7054090aed5"}, + {file = "mypy-0.982-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:41fd1cf9bc0e1c19b9af13a6580ccb66c381a5ee2cf63ee5ebab747a4badeba3"}, + {file = "mypy-0.982-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f793e3dd95e166b66d50e7b63e69e58e88643d80a3dcc3bcd81368e0478b089c"}, + {file = "mypy-0.982-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86ebe67adf4d021b28c3f547da6aa2cce660b57f0432617af2cca932d4d378a6"}, + {file = "mypy-0.982-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:175f292f649a3af7082fe36620369ffc4661a71005aa9f8297ea473df5772046"}, + {file = "mypy-0.982-cp310-cp310-win_amd64.whl", hash = "sha256:8ee8c2472e96beb1045e9081de8e92f295b89ac10c4109afdf3a23ad6e644f3e"}, + {file = "mypy-0.982-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58f27ebafe726a8e5ccb58d896451dd9a662a511a3188ff6a8a6a919142ecc20"}, + {file = "mypy-0.982-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6af646bd46f10d53834a8e8983e130e47d8ab2d4b7a97363e35b24e1d588947"}, + {file = "mypy-0.982-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e7aeaa763c7ab86d5b66ff27f68493d672e44c8099af636d433a7f3fa5596d40"}, + {file = "mypy-0.982-cp37-cp37m-win_amd64.whl", hash = "sha256:724d36be56444f569c20a629d1d4ee0cb0ad666078d59bb84f8f887952511ca1"}, + {file = "mypy-0.982-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:14d53cdd4cf93765aa747a7399f0961a365bcddf7855d9cef6306fa41de01c24"}, + {file = "mypy-0.982-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:26ae64555d480ad4b32a267d10cab7aec92ff44de35a7cd95b2b7cb8e64ebe3e"}, + {file = "mypy-0.982-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6389af3e204975d6658de4fb8ac16f58c14e1bacc6142fee86d1b5b26aa52bda"}, + {file = "mypy-0.982-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b35ce03a289480d6544aac85fa3674f493f323d80ea7226410ed065cd46f206"}, + {file = "mypy-0.982-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c6e564f035d25c99fd2b863e13049744d96bd1947e3d3d2f16f5828864506763"}, + {file = "mypy-0.982-cp38-cp38-win_amd64.whl", hash = "sha256:cebca7fd333f90b61b3ef7f217ff75ce2e287482206ef4a8b18f32b49927b1a2"}, + {file = "mypy-0.982-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a705a93670c8b74769496280d2fe6cd59961506c64f329bb179970ff1d24f9f8"}, + {file = "mypy-0.982-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75838c649290d83a2b83a88288c1eb60fe7a05b36d46cbea9d22efc790002146"}, + {file = "mypy-0.982-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:91781eff1f3f2607519c8b0e8518aad8498af1419e8442d5d0afb108059881fc"}, + {file = "mypy-0.982-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaa97b9ddd1dd9901a22a879491dbb951b5dec75c3b90032e2baa7336777363b"}, + {file = "mypy-0.982-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a692a8e7d07abe5f4b2dd32d731812a0175626a90a223d4b58f10f458747dd8a"}, + {file = "mypy-0.982-cp39-cp39-win_amd64.whl", hash = "sha256:eb7a068e503be3543c4bd329c994103874fa543c1727ba5288393c21d912d795"}, + {file = "mypy-0.982-py3-none-any.whl", hash = "sha256:1021c241e8b6e1ca5a47e4d52601274ac078a89845cfde66c6d5f769819ffa1d"}, + {file = "mypy-0.982.tar.gz", hash = "sha256:85f7a343542dc8b1ed0a888cdd34dca56462654ef23aa673907305b260b3d746"}, +] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] -nanoid = [] +nanoid = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] nodeenv = [ {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, @@ -1099,7 +1409,10 @@ packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] -pathspec = [] +pathspec = [ + {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, + {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, +] platformdirs = [ {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, @@ -1108,23 +1421,34 @@ pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] -pre-commit = [] -py = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +pre-commit = [ + {file = "pre_commit-2.20.0-py2.py3-none-any.whl", hash = "sha256:51a5ba7c480ae8072ecdb6933df22d2f812dc897d5fe848778116129a681aac7"}, + {file = "pre_commit-2.20.0.tar.gz", hash = "sha256:a978dac7bc9ec0bcee55c18a277d553b0f419d259dadb4b9418ff2d00eb43959"}, +] +pygments = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, +] +pymdown-extensions = [ + {file = "pymdown_extensions-9.7-py3-none-any.whl", hash = "sha256:767d07d9dead0f52f5135545c01f4ed627f9a7918ee86c646d893e24c59db87d"}, + {file = "pymdown_extensions-9.7.tar.gz", hash = "sha256:651b0107bc9ee790aedea3673cb88832c0af27d2569cf45c2de06f1d65292e96"}, ] -pygments = [] -pymdown-extensions = [] pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] -pytest = [] +pytest = [ + {file = "pytest-7.2.0-py3-none-any.whl", hash = "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71"}, + {file = "pytest-7.2.0.tar.gz", hash = "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59"}, +] pytest-aiohttp = [ {file = "pytest-aiohttp-1.0.4.tar.gz", hash = "sha256:39ff3a0d15484c01d1436cbedad575c6eafbf0f57cdf76fb94994c97b5b8c5a4"}, {file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"}, ] -pytest-asyncio = [] +pytest-asyncio = [ + {file = "pytest-asyncio-0.20.1.tar.gz", hash = "sha256:626699de2a747611f3eeb64168b3575f70439b06c3d0206e6ceaeeb956e65519"}, + {file = "pytest_asyncio-0.20.1-py3-none-any.whl", hash = "sha256:2c85a835df33fda40fe3973b451e0c194ca11bc2c007eabff90bb3d156fc172b"}, +] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, @@ -1141,6 +1465,13 @@ pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, @@ -1172,14 +1503,78 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] -requests = [] -rich = [] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +rich = [ + {file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"}, + {file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"}, +] +setuptools = [ + {file = "setuptools-65.5.0-py3-none-any.whl", hash = "sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"}, + {file = "setuptools-65.5.0.tar.gz", hash = "sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] -syrupy = [] -time-machine = [] +syrupy = [ + {file = "syrupy-3.0.2-py3-none-any.whl", hash = "sha256:b7afb1424ddbdbfba6c7340fd7b939dd129879f891172dd2e59ec971fb40c60d"}, + {file = "syrupy-3.0.2.tar.gz", hash = "sha256:5425a23f816743227542c91c83d2bf0847f029284236f8c30e10147bdde4f80e"}, +] +time-machine = [ + {file = "time-machine-2.8.2.tar.gz", hash = "sha256:2ff3cd145c381ac87b1c35400475a8f019b15dc2267861aad0466f55b49e7813"}, + {file = "time_machine-2.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:931f762053031ec76e81d5b97b276d6cbc3c9958fd281a3661a4e4dcd434ae4d"}, + {file = "time_machine-2.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2bec6756c46d9e7ccfaeb177fde46da01af74ac9e5862dd9528e501d367f451e"}, + {file = "time_machine-2.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:959e63ad6980df1c36aefd19ae746e9b01c2be2f009199ec996fde0443b84de0"}, + {file = "time_machine-2.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62db94b5ebe246949e6cedc57e7b96028f18ab9fb63b391d0e94d2e963702e30"}, + {file = "time_machine-2.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4b40d872fd025c9ee6924372d345b2788aac9df89eba5562e6464dde04cf99"}, + {file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b68259837b59c3bef30c5cff24d73228c5a5821342af624c78707fe297153221"}, + {file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:46b4d2763c514d0036f7f46b23836d8fba0240ac1c50df588ca43193a59ee184"}, + {file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f416489bc8d0adb4bd63edcce5ba743b408f3c161ab0e1a65f9f904a6f9a06c0"}, + {file = "time_machine-2.8.2-cp310-cp310-win32.whl", hash = "sha256:94ab54c2062a362059a02e6df624151bfdcda79dab704ffee220bb31f8153e24"}, + {file = "time_machine-2.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:f227819cfa27793e759811dabe6187e8f36dba6ac3a404516e17a81bb0216763"}, + {file = "time_machine-2.8.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:875eedfdf9cc59a9d119420b35c43a6d7ec08951a86581b4a4dbde47e6327256"}, + {file = "time_machine-2.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01ee31fca1414d1198feff9eb7d062ca42aea9d1c01f63cdd6b2e0bb4f7479a9"}, + {file = "time_machine-2.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4387678c392cfb40c038016b04f5becb022bdc371ecabded751c2a116d2c0b5a"}, + {file = "time_machine-2.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a42739702fd8ccbf4295aa6a0e5089f0ce125974e06ab157c6e4f4eadbc167c"}, + {file = "time_machine-2.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1963e1b9ea4891cbdd8a8f12cfb273dc7d3b0771ffe61238d688a7c2499445ef"}, + {file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7c0234c2fae05b4945b711d655af3487df34c466e184fbce7253dfc28c9980d1"}, + {file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19d01c6b6791c3ff45f8c82d149ac28292cf67242b1ace3dc1fdc0494edc111e"}, + {file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b05a2ca1045edd343fa07d2c55d57695c40b7af1e4c7df480d8e1976eb48a22f"}, + {file = "time_machine-2.8.2-cp311-cp311-win32.whl", hash = "sha256:71607d92fd23cd5fc5bcddb3ec6b91a6a1b07f7277e7e58dce0a5c1f67d229cd"}, + {file = "time_machine-2.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9e4c58915b2136041027fb4d795e8844112683e550a9aed24ecde1de8a5a8f2"}, + {file = "time_machine-2.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4b20f55d76cacb8b6f99c4161d8bfd6fc3be8d8ae003df2a79dbda9015d6ab85"}, + {file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb64b249df5c2958484706bdc095b326baf0f9c4a96c990d63a6e290680a8933"}, + {file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460f3d7344b64c906030013f6ca314017d7cbeb211e6c8c0efbdb3a2f5b168e3"}, + {file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ccd0e73e75f9cc624be08a2ae0305617ce7890d5b55f938ba336f086001ac66"}, + {file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8856b03574bc88f506534489562dfeb9c057485052817895413d8f33e7d03d28"}, + {file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3be539125dc815ff1f1ff05cd00f8839132a4b3a729809fa4a7de405f47cbd0b"}, + {file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c3b356e9038abb78618169b86a2bc3488aa2faee27fa97c9cd8638972d60dfe"}, + {file = "time_machine-2.8.2-cp37-cp37m-win32.whl", hash = "sha256:bfbe53b80402ab3c93f112374d8624eb5e7f26395f01aea341bf91b4a512e36e"}, + {file = "time_machine-2.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:71917d38d2c34039a31ac0d63970f6009072a14c3a89169d165ca81130daf308"}, + {file = "time_machine-2.8.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a3384f03776ffed86afdc2a807aa80fc656fbce6605e9b89261fc17302759290"}, + {file = "time_machine-2.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6d084ccfbf30c658c23b1340583aa64afe4c6421b4d2ab3a84769915630e0d68"}, + {file = "time_machine-2.8.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ed6c02afa3fc48af1fa256d5a3a18b63c3e36e7759fec8184e340e1b2f38f77"}, + {file = "time_machine-2.8.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c783769cc7b722e4b9df6015919a65952e58eb6fe884c198c1f56d58d883d0bc"}, + {file = "time_machine-2.8.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5da17b12c20d96b69bbe71d1e260e76c81072cded63539050d0f8aa26e9701dc"}, + {file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0213c32498190d7701cf90dc8a4f87d6d8571b856a16b474072e37f9e4daf896"}, + {file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c47caacc5a00656ee9e4ad4600ed46e036f233bbd93ed99c0da5f3dcec6a1a64"}, + {file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0e7950776b9087ba8e44f3602e5d695eaba853518c9963f41f3cba094000d87f"}, + {file = "time_machine-2.8.2-cp38-cp38-win32.whl", hash = "sha256:8bb1e68434a6c45bf2ef5d738420399803e7aa8211d77353e416d5043f82053e"}, + {file = "time_machine-2.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:f67957dac20cca1171a7b63a8343c86f4f589e42f3c61bce687e77dd475e4d88"}, + {file = "time_machine-2.8.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:18d60cb6eb2bb896ef442628be783d2ddf374873caefb083cbc2b2ed19361157"}, + {file = "time_machine-2.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82055dc781c4c9f6c97f3a349473ab44f1096da61a8cf1e72c105d12a39344ea"}, + {file = "time_machine-2.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfaa1018ea5695a47f9536e1c7f7a112d55741162d8cdaa49801b3977f710666"}, + {file = "time_machine-2.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f9c6bdead992708d3f88e9e337f08f9067e259eb6a7df23f94652cee7f08459"}, + {file = "time_machine-2.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e6ba08062248fd9ba750ca997ed8699176d71b0d3aa525333efbd10e644f574"}, + {file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7d7233bb7a01d27e93fd8f687227fb93d314fb5048127844c248d76067b36e84"}, + {file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0cb22588e0c88239bad7ac5d593dc1119aacb7ac074e7aa2badc53583b92febf"}, + {file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ba71634179448df5dc6fb85d61e3956c8e33755ad3f76549dacb9c4854e88046"}, + {file = "time_machine-2.8.2-cp39-cp39-win32.whl", hash = "sha256:70ccbd8c5c4396fe4d60b0ceacef47f95e44f84a4d1d8cd5acdf9f81880e863a"}, + {file = "time_machine-2.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:32f77a14ffbaeef8ae5e5bb86eb0e76057b56cb94f1f4990756c66047f8cac91"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -1214,9 +1609,18 @@ typed-ast = [ {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, ] -typing-extensions = [] -urllib3 = [] -virtualenv = [] +typing-extensions = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] +urllib3 = [ + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, +] +virtualenv = [ + {file = "virtualenv-20.16.6-py3-none-any.whl", hash = "sha256:186ca84254abcbde98180fd17092f9628c5fe742273c02724972a1d8a2035108"}, + {file = "virtualenv-20.16.6.tar.gz", hash = "sha256:530b850b523c6449406dfba859d6345e48ef19b8439606c5d74d7d3c9e14d76e"}, +] watchdog = [ {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d"}, @@ -1244,5 +1648,68 @@ watchdog = [ {file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"}, {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"}, ] -yarl = [] -zipp = [] +yarl = [ + {file = "yarl-1.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:abc06b97407868ef38f3d172762f4069323de52f2b70d133d096a48d72215d28"}, + {file = "yarl-1.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:07b21e274de4c637f3e3b7104694e53260b5fc10d51fb3ec5fed1da8e0f754e3"}, + {file = "yarl-1.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9de955d98e02fab288c7718662afb33aab64212ecb368c5dc866d9a57bf48880"}, + {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ec362167e2c9fd178f82f252b6d97669d7245695dc057ee182118042026da40"}, + {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20df6ff4089bc86e4a66e3b1380460f864df3dd9dccaf88d6b3385d24405893b"}, + {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5999c4662631cb798496535afbd837a102859568adc67d75d2045e31ec3ac497"}, + {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed19b74e81b10b592084a5ad1e70f845f0aacb57577018d31de064e71ffa267a"}, + {file = "yarl-1.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e4808f996ca39a6463f45182e2af2fae55e2560be586d447ce8016f389f626f"}, + {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2d800b9c2eaf0684c08be5f50e52bfa2aa920e7163c2ea43f4f431e829b4f0fd"}, + {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6628d750041550c5d9da50bb40b5cf28a2e63b9388bac10fedd4f19236ef4957"}, + {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f5af52738e225fcc526ae64071b7e5342abe03f42e0e8918227b38c9aa711e28"}, + {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:76577f13333b4fe345c3704811ac7509b31499132ff0181f25ee26619de2c843"}, + {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0c03f456522d1ec815893d85fccb5def01ffaa74c1b16ff30f8aaa03eb21e453"}, + {file = "yarl-1.8.1-cp310-cp310-win32.whl", hash = "sha256:ea30a42dc94d42f2ba4d0f7c0ffb4f4f9baa1b23045910c0c32df9c9902cb272"}, + {file = "yarl-1.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:9130ddf1ae9978abe63808b6b60a897e41fccb834408cde79522feb37fb72fb0"}, + {file = "yarl-1.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0ab5a138211c1c366404d912824bdcf5545ccba5b3ff52c42c4af4cbdc2c5035"}, + {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0fb2cb4204ddb456a8e32381f9a90000429489a25f64e817e6ff94879d432fc"}, + {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85cba594433915d5c9a0d14b24cfba0339f57a2fff203a5d4fd070e593307d0b"}, + {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca7e596c55bd675432b11320b4eacc62310c2145d6801a1f8e9ad160685a231"}, + {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0f77539733e0ec2475ddcd4e26777d08996f8cd55d2aef82ec4d3896687abda"}, + {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29e256649f42771829974e742061c3501cc50cf16e63f91ed8d1bf98242e5507"}, + {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7fce6cbc6c170ede0221cc8c91b285f7f3c8b9fe28283b51885ff621bbe0f8ee"}, + {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:59ddd85a1214862ce7c7c66457f05543b6a275b70a65de366030d56159a979f0"}, + {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:12768232751689c1a89b0376a96a32bc7633c08da45ad985d0c49ede691f5c0d"}, + {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:b19255dde4b4f4c32e012038f2c169bb72e7f081552bea4641cab4d88bc409dd"}, + {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6c8148e0b52bf9535c40c48faebb00cb294ee577ca069d21bd5c48d302a83780"}, + {file = "yarl-1.8.1-cp37-cp37m-win32.whl", hash = "sha256:de839c3a1826a909fdbfe05f6fe2167c4ab033f1133757b5936efe2f84904c07"}, + {file = "yarl-1.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:dd032e8422a52e5a4860e062eb84ac94ea08861d334a4bcaf142a63ce8ad4802"}, + {file = "yarl-1.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:19cd801d6f983918a3f3a39f3a45b553c015c5aac92ccd1fac619bd74beece4a"}, + {file = "yarl-1.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6347f1a58e658b97b0a0d1ff7658a03cb79bdbda0331603bed24dd7054a6dea1"}, + {file = "yarl-1.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c0da7e44d0c9108d8b98469338705e07f4bb7dab96dbd8fa4e91b337db42548"}, + {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5587bba41399854703212b87071c6d8638fa6e61656385875f8c6dff92b2e461"}, + {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31a9a04ecccd6b03e2b0e12e82131f1488dea5555a13a4d32f064e22a6003cfe"}, + {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:205904cffd69ae972a1707a1bd3ea7cded594b1d773a0ce66714edf17833cdae"}, + {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea513a25976d21733bff523e0ca836ef1679630ef4ad22d46987d04b372d57fc"}, + {file = "yarl-1.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0b51530877d3ad7a8d47b2fff0c8df3b8f3b8deddf057379ba50b13df2a5eae"}, + {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d2b8f245dad9e331540c350285910b20dd913dc86d4ee410c11d48523c4fd546"}, + {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ab2a60d57ca88e1d4ca34a10e9fb4ab2ac5ad315543351de3a612bbb0560bead"}, + {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:449c957ffc6bc2309e1fbe67ab7d2c1efca89d3f4912baeb8ead207bb3cc1cd4"}, + {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a165442348c211b5dea67c0206fc61366212d7082ba8118c8c5c1c853ea4d82e"}, + {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b3ded839a5c5608eec8b6f9ae9a62cb22cd037ea97c627f38ae0841a48f09eae"}, + {file = "yarl-1.8.1-cp38-cp38-win32.whl", hash = "sha256:c1445a0c562ed561d06d8cbc5c8916c6008a31c60bc3655cdd2de1d3bf5174a0"}, + {file = "yarl-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:56c11efb0a89700987d05597b08a1efcd78d74c52febe530126785e1b1a285f4"}, + {file = "yarl-1.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e80ed5a9939ceb6fda42811542f31c8602be336b1fb977bccb012e83da7e4936"}, + {file = "yarl-1.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6afb336e23a793cd3b6476c30f030a0d4c7539cd81649683b5e0c1b0ab0bf350"}, + {file = "yarl-1.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4c322cbaa4ed78a8aac89b2174a6df398faf50e5fc12c4c191c40c59d5e28357"}, + {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fae37373155f5ef9b403ab48af5136ae9851151f7aacd9926251ab26b953118b"}, + {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5395da939ffa959974577eff2cbfc24b004a2fb6c346918f39966a5786874e54"}, + {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:076eede537ab978b605f41db79a56cad2e7efeea2aa6e0fa8f05a26c24a034fb"}, + {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d1a50e461615747dd93c099f297c1994d472b0f4d2db8a64e55b1edf704ec1c"}, + {file = "yarl-1.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7de89c8456525650ffa2bb56a3eee6af891e98f498babd43ae307bd42dca98f6"}, + {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4a88510731cd8d4befaba5fbd734a7dd914de5ab8132a5b3dde0bbd6c9476c64"}, + {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2d93a049d29df172f48bcb09acf9226318e712ce67374f893b460b42cc1380ae"}, + {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:21ac44b763e0eec15746a3d440f5e09ad2ecc8b5f6dcd3ea8cb4773d6d4703e3"}, + {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d0272228fabe78ce00a3365ffffd6f643f57a91043e119c289aaba202f4095b0"}, + {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:99449cd5366fe4608e7226c6cae80873296dfa0cde45d9b498fefa1de315a09e"}, + {file = "yarl-1.8.1-cp39-cp39-win32.whl", hash = "sha256:8b0af1cf36b93cee99a31a545fe91d08223e64390c5ecc5e94c39511832a4bb6"}, + {file = "yarl-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:de49d77e968de6626ba7ef4472323f9d2e5a56c1d85b7c0e2a190b2173d3b9be"}, + {file = "yarl-1.8.1.tar.gz", hash = "sha256:af887845b8c2e060eb5605ff72b6f2dd2aab7a761379373fd89d314f4752abbf"}, +] +zipp = [ + {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, + {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, +] diff --git a/pyproject.toml b/pyproject.toml index dc6affad7..c4cbaa621 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,10 +33,10 @@ importlib-metadata = "^4.11.3" typing-extensions = { version = "^4.0.0", python = "<3.10" } # Dependencies below are required for devtools only -aiohttp = { version = "^3.8.1", optional = true } -click = {version = "8.1.2", optional = true} -msgpack = { version = "^1.0.3", optional = true } -nanoid = "^2.0.0" +aiohttp = { version = ">=3.8.1", optional = true } +click = {version = ">=8.1.2", optional = true} +msgpack = { version = ">=1.0.3", optional = true } +nanoid = ">=2.0.0" [tool.poetry.extras] dev = ["aiohttp", "click", "msgpack"] From 02c947985e85b34e9662e5f652c94c8a13cf0b3f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 11:43:24 +0000 Subject: [PATCH 35/41] bump poetry --- .github/workflows/pythonpackage.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index ee439baf0..cf12affdd 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -20,9 +20,9 @@ jobs: python-version: ${{ matrix.python-version }} architecture: x64 - name: Install and configure Poetry - uses: snok/install-poetry@v1.1.6 + uses: snok/install-poetry@v1.2.2 with: - version: 1.1.6 + version: 1.2.2 virtualenvs-in-project: true - name: Install dependencies run: poetry install --extras "dev" From 26f31cfbe63e1a92843aec3b83ca513c60ab721d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 31 Oct 2022 11:45:01 +0000 Subject: [PATCH 36/41] Change the way that `textual run` does the argv monkey-patch A `textual run` already made an attempt to monkey-patch `argv` so that the application being run would not see an `argv` any differently to how it would if it had been run "vanilla". However, it could be caught out by code like: from sys import argv ... do_something_with( argv ) beforehand it was okay with: import sys ... do_something_with( sys.argv ) With this change it should work both ways. Somewhat related to #1064. --- src/textual/_import_app.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/textual/_import_app.py b/src/textual/_import_app.py index ee75227fb..d5acd598f 100644 --- a/src/textual/_import_app.py +++ b/src/textual/_import_app.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import sys import runpy import shlex from pathlib import Path @@ -30,7 +31,6 @@ def import_app(import_name: str) -> App: import inspect import importlib - import sys from textual.app import App, WINDOWS @@ -45,8 +45,7 @@ def import_app(import_name: str) -> App: except Exception as error: raise AppFail(str(error)) - if "sys" in global_vars: - global_vars["sys"].argv = [path, *argv] + sys.argv = [path, *argv] if name: # User has given a name, use that From acda0fa8aff92269eb1770cd43b8ac150430f7b5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 11:45:01 +0000 Subject: [PATCH 37/41] update snok install poetry --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index cf12affdd..1f7d27996 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -20,7 +20,7 @@ jobs: python-version: ${{ matrix.python-version }} architecture: x64 - name: Install and configure Poetry - uses: snok/install-poetry@v1.2.2 + uses: snok/install-poetry@v1.1.6 with: version: 1.2.2 virtualenvs-in-project: true From c391681b3d31785e01f92b6f79ecbf878270cbd5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 31 Oct 2022 12:03:32 +0000 Subject: [PATCH 38/41] Ensure we patch the same instance of argv --- src/textual/_import_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_import_app.py b/src/textual/_import_app.py index d5acd598f..e65e7fc85 100644 --- a/src/textual/_import_app.py +++ b/src/textual/_import_app.py @@ -45,7 +45,7 @@ def import_app(import_name: str) -> App: except Exception as error: raise AppFail(str(error)) - sys.argv = [path, *argv] + sys.argv[:] = [path, *argv] if name: # User has given a name, use that From 47c5a22b31ea93d57fd380613ba8f3d5eafc9755 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 31 Oct 2022 12:43:30 +0000 Subject: [PATCH 39/41] Docs change, as per code review request --- docs/guide/reactivity.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 18a0b5a02..44735c5aa 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -196,11 +196,11 @@ The following app will display any color you type in to the input. Try it with a The color is parsed in `on_input_submitted` and assigned to `self.color`. Because `color` is reactive, Textual also calls `watch_color` with the old and new values. -!! warning +### When are watch methods called? - Textual only calls watch methods if the value of a reactive attribute changes. - If the newly assigned value is the same as the previous value, the watch method is not called. - You can override this behaviour by passing `always_update=True`. +Textual only calls watch methods if the value of a reactive attribute _changes_. +If the newly assigned value is the same as the previous value, the watch method is not called. +You can override this behaviour by passing `always_update=True` to `reactive`. ## Compute methods From 4bce8317c76d9178ff7dc39423a0b2593dbb9ae0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 13:28:57 +0000 Subject: [PATCH 40/41] Update src/textual/app.py Co-authored-by: darrenburns --- 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 bbc30baa7..83fc64110 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1644,7 +1644,7 @@ class App(Generic[ReturnType], DOMNode): """Walk children depth first, generating widgets and a list of their siblings. Returns: - Iterable[list[Widget]]: + Iterable[list[Widget]]: The child widgets of root. """ stack: list[Widget] = [root] From 07dced3435126125e5c579bb16c537169f8a3e18 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 31 Oct 2022 13:31:10 +0000 Subject: [PATCH 41/41] Update tests/test_unmount.py Co-authored-by: darrenburns --- tests/test_unmount.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_unmount.py b/tests/test_unmount.py index 632998d5b..e5c557aa0 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -5,7 +5,7 @@ from textual.screen import Screen async def test_unmount(): - """Text unmount events are received in reverse DOM order.""" + """Test unmount events are received in reverse DOM order.""" unmount_ids: list[str] = [] class UnmountWidget(Container):