diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md index 9c4be4e926..f1c36916ed 100644 --- a/.github/ISSUE_TEMPLATE/task.md +++ b/.github/ISSUE_TEMPLATE/task.md @@ -6,5 +6,3 @@ labels: '' assignees: '' --- - - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 009c06616e..ce352e11b0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v5.0.0 hooks: - id: check-ast # simply checks whether the files parse as valid python - id: check-builtin-literals # requires literal syntax when initializing empty or zero python builtin types @@ -15,20 +15,21 @@ repos: - id: check-shebang-scripts-are-executable # ensures that (non-binary) files with a shebang are executable - id: check-vcs-permalinks # ensures that links to vcs websites are permalinks - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline + exclude: '^.*\.svg$' - id: mixed-line-ending # replaces or checks mixed line ending - repo: https://github.com/pycqa/isort - rev: '5.13.2' + rev: '6.0.1' hooks: - id: isort name: isort (python) language_version: '3.11' args: ['--profile', 'black', '--filter-files'] - repo: https://github.com/psf/black - rev: '24.1.1' + rev: '25.1.0' hooks: - id: black - repo: https://github.com/hadialqattan/pycln # removes unused imports - rev: v2.3.0 + rev: v2.5.0 hooks: - id: pycln language_version: '3.11' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index d1435248d5..e54b608481 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -125,4 +125,4 @@ enforcement ladder](https://github.com/mozilla/diversity). For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. \ No newline at end of file +https://www.contributor-covenant.org/translations. diff --git a/docs/blog/index.md b/docs/blog/index.md index 210ffd3f9b..8b01728346 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -1,2 +1 @@ # Textual Blog - diff --git a/docs/blog/posts/helo-world.md b/docs/blog/posts/helo-world.md index 90dab804b6..fa90e1f5a0 100644 --- a/docs/blog/posts/helo-world.md +++ b/docs/blog/posts/helo-world.md @@ -16,4 +16,3 @@ Welcome to the first post on the Textual blog. I plan on using this as a place to make announcements regarding new releases of Textual, and any other relevant news. The first piece of news is that we've reorganized this site a little. The Events, Styles, and Widgets references are now under "Reference", and what used to be under "Reference" is now "API" which contains API-level documentation. I hope that's a little clearer than it used to be! - diff --git a/docs/blog/posts/release0-24-0.md b/docs/blog/posts/release0-24-0.md index 95feb66fc9..8addc95216 100644 --- a/docs/blog/posts/release0-24-0.md +++ b/docs/blog/posts/release0-24-0.md @@ -50,7 +50,7 @@ This was also solved with a new rule called "constrain". Applying `constrain` to a widget will keep the widget within the bounds of the screen. In the case of `Select`, if you expand the options while at the bottom of the screen, then the overlay will be moved up so that you can see all the options. -These new rules are currently undocumented as they are still subject to change, but you can see them in the [Select](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_select.py#L179) source if you are interested. +These new rules are currently undocumented as they are still subject to change, but you can see them in the [Select](https://github.com/Textualize/textual/blob/v0.24.0/src/textual/widgets/_select.py#L179-L220) source if you are interested. In a future release these will be finalized and you can confidently use them in your own projects. diff --git a/docs/blog/posts/release0-6-0.md b/docs/blog/posts/release0-6-0.md index 715b6a3c39..958b89f74b 100644 --- a/docs/blog/posts/release0-6-0.md +++ b/docs/blog/posts/release0-6-0.md @@ -103,4 +103,3 @@ As always, there are a number of fixes in this release. Mostly related to layout ## What's next? The next release will focus on *pain points* we discovered while in a dog-fooding phase (see the [DevLog](https://textual.textualize.io/blog/category/devlog/) for details on what Textual devs have been building). - diff --git a/docs/examples/app/event01.py b/docs/examples/app/event01.py index 0f3bc3cdca..cbe6290e9b 100644 --- a/docs/examples/app/event01.py +++ b/docs/examples/app/event01.py @@ -1,5 +1,5 @@ -from textual.app import App from textual import events +from textual.app import App class EventApp(App): diff --git a/docs/examples/app/question01.py b/docs/examples/app/question01.py index 04c4e86898..c7be464892 100644 --- a/docs/examples/app/question01.py +++ b/docs/examples/app/question01.py @@ -1,5 +1,5 @@ from textual.app import App, ComposeResult -from textual.widgets import Label, Button +from textual.widgets import Button, Label class QuestionApp(App[str]): diff --git a/docs/examples/app/question03.py b/docs/examples/app/question03.py index 6fc372dedb..411c4ae1ae 100644 --- a/docs/examples/app/question03.py +++ b/docs/examples/app/question03.py @@ -1,5 +1,5 @@ from textual.app import App, ComposeResult -from textual.widgets import Label, Button +from textual.widgets import Button, Label class QuestionApp(App[str]): diff --git a/docs/examples/guide/actions/actions01.py b/docs/examples/guide/actions/actions01.py index 54b366f95e..7b599854dd 100644 --- a/docs/examples/guide/actions/actions01.py +++ b/docs/examples/guide/actions/actions01.py @@ -1,5 +1,5 @@ -from textual.app import App from textual import events +from textual.app import App class ActionsApp(App): diff --git a/docs/examples/guide/dom2.py b/docs/examples/guide/dom2.py index 35b6703278..84d9ebe963 100644 --- a/docs/examples/guide/dom2.py +++ b/docs/examples/guide/dom2.py @@ -1,5 +1,5 @@ from textual.app import App, ComposeResult -from textual.widgets import Header, Footer +from textual.widgets import Footer, Header class ExampleApp(App): diff --git a/docs/examples/guide/styles/box_sizing01.py b/docs/examples/guide/styles/box_sizing01.py index f2799b6e3e..7c276a4994 100644 --- a/docs/examples/guide/styles/box_sizing01.py +++ b/docs/examples/guide/styles/box_sizing01.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/dimensions01.py b/docs/examples/guide/styles/dimensions01.py index ed479f0f34..021e0d343a 100644 --- a/docs/examples/guide/styles/dimensions01.py +++ b/docs/examples/guide/styles/dimensions01.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/dimensions02.py b/docs/examples/guide/styles/dimensions02.py index 339aade995..d263c404e6 100644 --- a/docs/examples/guide/styles/dimensions02.py +++ b/docs/examples/guide/styles/dimensions02.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/dimensions03.py b/docs/examples/guide/styles/dimensions03.py index 4d361227e4..1dc49e891f 100644 --- a/docs/examples/guide/styles/dimensions03.py +++ b/docs/examples/guide/styles/dimensions03.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/dimensions04.py b/docs/examples/guide/styles/dimensions04.py index 405b5545e9..c1f52ee02b 100644 --- a/docs/examples/guide/styles/dimensions04.py +++ b/docs/examples/guide/styles/dimensions04.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/margin01.py b/docs/examples/guide/styles/margin01.py index 7036cb7257..7c8f7eac80 100644 --- a/docs/examples/guide/styles/margin01.py +++ b/docs/examples/guide/styles/margin01.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/outline01.py b/docs/examples/guide/styles/outline01.py index cd77d0b8c6..ef67f3cba2 100644 --- a/docs/examples/guide/styles/outline01.py +++ b/docs/examples/guide/styles/outline01.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/padding01.py b/docs/examples/guide/styles/padding01.py index 92c68948aa..916d6825b9 100644 --- a/docs/examples/guide/styles/padding01.py +++ b/docs/examples/guide/styles/padding01.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/styles/padding02.py b/docs/examples/guide/styles/padding02.py index 50bf0b940c..828f5cb54d 100644 --- a/docs/examples/guide/styles/padding02.py +++ b/docs/examples/guide/styles/padding02.py @@ -1,7 +1,6 @@ from textual.app import App, ComposeResult from textual.widgets import Static - TEXT = """I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. diff --git a/docs/examples/guide/widgets/checker03.py b/docs/examples/guide/widgets/checker03.py index 03ca19381c..7b4a3d5b97 100644 --- a/docs/examples/guide/widgets/checker03.py +++ b/docs/examples/guide/widgets/checker03.py @@ -1,11 +1,11 @@ from __future__ import annotations +from rich.segment import Segment + from textual.app import App, ComposeResult from textual.geometry import Size -from textual.strip import Strip from textual.scroll_view import ScrollView - -from rich.segment import Segment +from textual.strip import Strip class CheckerBoard(ScrollView): diff --git a/docs/examples/guide/widgets/checker04.py b/docs/examples/guide/widgets/checker04.py index 0445ffea5a..fc91798d74 100644 --- a/docs/examples/guide/widgets/checker04.py +++ b/docs/examples/guide/widgets/checker04.py @@ -1,14 +1,14 @@ from __future__ import annotations +from rich.segment import Segment +from rich.style import Style + from textual import events from textual.app import App, ComposeResult from textual.geometry import Offset, Region, Size from textual.reactive import var -from textual.strip import Strip from textual.scroll_view import ScrollView - -from rich.segment import Segment -from rich.style import Style +from textual.strip import Strip class CheckerBoard(ScrollView): diff --git a/docs/guide/styles.md b/docs/guide/styles.md index c251f9faf4..abb8d81f98 100644 --- a/docs/guide/styles.md +++ b/docs/guide/styles.md @@ -125,7 +125,7 @@ Together these styles compose the widget's *box model*. The following diagram sh Setting the width restricts the number of columns used by a widget, and setting the height restricts the number of rows. Let's look at an example which sets both dimensions. -```python title="dimensions01.py" hl_lines="21-22" +```python title="dimensions01.py" hl_lines="20-21" --8<-- "docs/examples/guide/styles/dimensions01.py" ``` @@ -142,7 +142,7 @@ In practice, we generally want the size of a widget to adapt to its content, whi Let's set the height to auto and see what happens. -```python title="dimensions02.py" hl_lines="22" +```python title="dimensions02.py" hl_lines="21" --8<-- "docs/examples/guide/styles/dimensions02.py" ``` @@ -163,7 +163,7 @@ Textual offers a few different *units* which allow you to specify dimensions rel The following example demonstrates applying percentage units: -```python title="dimensions03.py" hl_lines="21-22" +```python title="dimensions03.py" hl_lines="20-21" --8<-- "docs/examples/guide/styles/dimensions03.py" ``` @@ -193,7 +193,7 @@ When specifying `fr` units for a given dimension, Textual will divide the availa Let's look at an example. We will create two widgets, one with a height of `"2fr"` and one with a height of `"1fr"`. -```python title="dimensions04.py" hl_lines="24-25" +```python title="dimensions04.py" hl_lines="23-24" --8<-- "docs/examples/guide/styles/dimensions04.py" ``` @@ -219,7 +219,7 @@ The following styles set minimum and maximum sizes and can accept any of the val Padding adds space around your content which can aid readability. Setting [padding](../styles/padding.md) to an integer will add that number additional rows and columns around the content area. The following example sets padding to 2: -```python title="padding01.py" hl_lines="22" +```python title="padding01.py" hl_lines="21" --8<-- "docs/examples/guide/styles/padding01.py" ``` @@ -230,7 +230,7 @@ Notice the additional space around the text: You can also set padding to a tuple of *two* integers which will apply padding to the top/bottom and left/right edges. The following example sets padding to `(2, 4)` which adds two rows to the top and bottom of the widget, and 4 columns to the left and right of the widget. -```python title="padding02.py" hl_lines="22" +```python title="padding02.py" hl_lines="21" --8<-- "docs/examples/guide/styles/padding02.py" ``` @@ -289,7 +289,7 @@ Note the addition of the titles and their alignments: [Outline](../styles/outline.md) is similar to border and is set in the same way. The difference is that outline will not change the size of the widget, and may overlap the content area. The following example sets an outline on a widget: -```python title="outline01.py" hl_lines="22" +```python title="outline01.py" hl_lines="21" --8<-- "docs/examples/guide/styles/outline01.py" ``` @@ -325,7 +325,7 @@ The following example creates two widgets with a width of 30, a height of 6, and The first widget has the default `box_sizing` (`"border-box"`). The second widget sets `box_sizing` to `"content-box"`. -```python title="box_sizing01.py" hl_lines="32" +```python title="box_sizing01.py" hl_lines="31" --8<-- "docs/examples/guide/styles/box_sizing01.py" ``` @@ -340,7 +340,7 @@ Margin is similar to padding in that it adds space, but unlike padding, [margin] The following example creates two widgets, each with a margin of 2. -```python title="margin01.py" hl_lines="26-27" +```python title="margin01.py" hl_lines="25-26" --8<-- "docs/examples/guide/styles/margin01.py" ``` diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md index 0010c38b56..ef62d946d9 100644 --- a/docs/guide/widgets.md +++ b/docs/guide/widgets.md @@ -511,7 +511,7 @@ Let's add scrolling to our checkerboard example. A standard 8 x 8 board isn't su === "checker03.py" - ```python title="checker03.py" hl_lines="4 26-30 35-36 52-53" + ```python title="checker03.py" hl_lines="6 26-30 35-36 52-53" --8<-- "docs/examples/guide/widgets/checker03.py" ``` diff --git a/examples/json_tree.py b/examples/json_tree.py index a8bfd1bdbe..62033b971a 100644 --- a/examples/json_tree.py +++ b/examples/json_tree.py @@ -4,7 +4,7 @@ from rich.text import Text from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, Tree +from textual.widgets import Footer, Header, Tree from textual.widgets.tree import TreeNode diff --git a/poetry.lock b/poetry.lock index f2200d7a03..d9a5525107 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2236,7 +2236,7 @@ files = [ name = "tree-sitter" version = "0.23.2" description = "Python bindings to the Tree-sitter parsing library" -optional = true +optional = false python-versions = ">=3.9" files = [ {file = "tree-sitter-0.23.2.tar.gz", hash = "sha256:66bae8dd47f1fed7bdef816115146d3a41c39b5c482d7bad36d9ba1def088450"}, @@ -2855,4 +2855,4 @@ syntax = ["tree-sitter", "tree-sitter-bash", "tree-sitter-css", "tree-sitter-go" [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "db29b377e8fcedd9730b54f573ba175c8743e06fa57da2dee8a4e62bc2a6faa7" +content-hash = "3dc11349b4c4a0c407ed1b407d2f822159b3dc3a3e5fe26e4c560cadec458181" diff --git a/pyproject.toml b/pyproject.toml index 91f314cd28..0b40335ab8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ python = "^3.8.1" markdown-it-py = { extras = ["plugins", "linkify"], version = ">=2.1.0" } rich = ">=13.3.3" #rich = {path="../rich", develop=true} -typing-extensions = "^4.4.0" +typing-extensions = "^4.12.0" platformdirs = ">=3.6.0,<5" # start of [syntax] extras @@ -113,6 +113,7 @@ textual-dev = "^1.7.0" types-setuptools = "^67.2.0.1" isort = "^5.13.2" pytest-textual-snapshot = "^1.0.0" +tree-sitter = { version = ">=0.23.0", python = ">=3.9" } [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/textual/_debug.py b/src/textual/_debug.py index bb46ea3837..387671d346 100644 --- a/src/textual/_debug.py +++ b/src/textual/_debug.py @@ -20,7 +20,7 @@ def get_caller_file_and_line() -> str | None: try: current_frame = inspect.currentframe() - caller_frame = inspect.getframeinfo(current_frame.f_back.f_back) + caller_frame = inspect.getframeinfo(current_frame.f_back.f_back) # type: ignore[union-attr,arg-type] return f"{caller_frame.filename}:{caller_frame.lineno}" except Exception: return None diff --git a/src/textual/_tree_sitter.py b/src/textual/_tree_sitter.py index 9d099c109b..c3dbee4f9c 100644 --- a/src/textual/_tree_sitter.py +++ b/src/textual/_tree_sitter.py @@ -1,9 +1,9 @@ from __future__ import annotations + from importlib import import_module from textual import log - try: from tree_sitter import Language diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 889651a4b0..6285eeffe6 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -33,42 +33,42 @@ class WorkerDeclarationError(Exception): """An error in the declaration of a worker method.""" -if TYPE_CHECKING: +@overload +def work( + method: Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]], + *, + name: str = "", + group: str = "default", + exit_on_error: bool = True, + exclusive: bool = False, + description: str | None = None, + thread: bool = False, +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... + + +@overload +def work( + method: Callable[FactoryParamSpec, ReturnType], + *, + name: str = "", + group: str = "default", + exit_on_error: bool = True, + exclusive: bool = False, + description: str | None = None, + thread: bool = False, +) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... + - @overload - def work( - method: Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]], - *, - name: str = "", - group: str = "default", - exit_on_error: bool = True, - exclusive: bool = False, - description: str | None = None, - thread: bool = False, - ) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... - - @overload - def work( - method: Callable[FactoryParamSpec, ReturnType], - *, - name: str = "", - group: str = "default", - exit_on_error: bool = True, - exclusive: bool = False, - description: str | None = None, - thread: bool = False, - ) -> Callable[FactoryParamSpec, "Worker[ReturnType]"]: ... - - @overload - def work( - *, - name: str = "", - group: str = "default", - exit_on_error: bool = True, - exclusive: bool = False, - description: str | None = None, - thread: bool = False, - ) -> Decorator[..., ReturnType]: ... +@overload +def work( + *, + name: str = "", + group: str = "default", + exit_on_error: bool = True, + exclusive: bool = False, + description: str | None = None, + thread: bool = False, +) -> Decorator: ... def work( diff --git a/src/textual/app.py b/src/textual/app.py index ba87005aba..5f02823a78 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3364,7 +3364,7 @@ def _register_child( # Now that the widget is in the NodeList of its parent, sort out # the rest of the admin. self._registry.add(child) - child._attach(parent) + child._attach(parent) # type: ignore[arg-type] child._post_register(self) child._start_messages() diff --git a/src/textual/document/_edit.py b/src/textual/document/_edit.py index dc9c04aeba..6e871bbf11 100644 --- a/src/textual/document/_edit.py +++ b/src/textual/document/_edit.py @@ -114,6 +114,7 @@ def undo(self, text_area: TextArea) -> EditResult: Returns: An `EditResult` containing information about the replace operation. """ + assert self._edit_result is not None replaced_text = self._edit_result.replaced_text edit_end = self._edit_result.end_location diff --git a/src/textual/document/_syntax_aware_document.py b/src/textual/document/_syntax_aware_document.py index 00305dcec2..42cc23363f 100644 --- a/src/textual/document/_syntax_aware_document.py +++ b/src/textual/document/_syntax_aware_document.py @@ -82,14 +82,16 @@ def query_syntax_tree( Returns: A tuple containing the nodes and text captured by the query. """ - captures_kwargs = {} - if start_point is not None: - captures_kwargs["start_point"] = start_point - if end_point is not None: - captures_kwargs["end_point"] = end_point - - captures = query.captures(self._syntax_tree.root_node, **captures_kwargs) - return captures + + if start_point is None: + start_point = self._syntax_tree.included_ranges[0].start_point + + if end_point is None: + end_point = self._syntax_tree.included_ranges[-1].end_point + + return query.set_point_range((start_point, end_point)).captures( + self._syntax_tree.root_node + ) def replace_range(self, start: Location, end: Location, text: str) -> EditResult: """Replace text at the given range. diff --git a/src/textual/dom.py b/src/textual/dom.py index 56fe673e0b..bf48e2fa01 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1206,12 +1206,11 @@ def ancestors_with_self(self) -> list[DOMNode]: Returns: A list of nodes. """ - nodes: list[MessagePump | None] = [self] - add_node = nodes.append - node: MessagePump | None = self - while (node := node._parent) is not None: - add_node(node) - return cast("list[DOMNode]", nodes) + nodes = [self] + node: DOMNode | None = self + while node is not None and (node := node._parent) is not None: + nodes.append(node) + return nodes @property def ancestors(self) -> list[DOMNode]: @@ -1220,12 +1219,11 @@ def ancestors(self) -> list[DOMNode]: Returns: A list of nodes. """ - nodes: list[MessagePump | None] = [] - add_node = nodes.append - node: MessagePump | None = self - while (node := node._parent) is not None: - add_node(node) - return cast("list[DOMNode]", nodes) + nodes = [] + node: DOMNode | None = self + while node is not None and (node := node._parent) is not None: + nodes.append(node) + return nodes @property def displayed_children(self) -> list[Widget]: @@ -1295,7 +1293,7 @@ def _add_child(self, node: Widget) -> None: node: A DOM node. """ self._nodes._append(node) - node._attach(self) + node._attach(self) # type: ignore[arg-type] def _add_children(self, *nodes: Widget) -> None: """Add multiple children to this node. @@ -1308,7 +1306,7 @@ def _add_children(self, *nodes: Widget) -> None: """ _append = self._nodes._append for node in nodes: - node._attach(self) + node._attach(self) # type: ignore[arg-type] _append(node) node._add_children(*node._pending_children) diff --git a/src/textual/drivers/_input_reader.py b/src/textual/drivers/_input_reader.py index b183b13907..02440aa29a 100644 --- a/src/textual/drivers/_input_reader.py +++ b/src/textual/drivers/_input_reader.py @@ -1,10 +1,11 @@ import sys +from typing import Final __all__ = ["InputReader"] -WINDOWS = sys.platform == "win32" +WINDOWS: Final = sys.platform == "win32" -if WINDOWS: +if sys.platform == "win32": from textual.drivers._input_reader_windows import InputReader else: from textual.drivers._input_reader_linux import InputReader diff --git a/src/textual/drivers/_input_reader_linux.py b/src/textual/drivers/_input_reader_linux.py index a4bae77935..fe50abb7ba 100644 --- a/src/textual/drivers/_input_reader_linux.py +++ b/src/textual/drivers/_input_reader_linux.py @@ -14,6 +14,7 @@ def __init__(self, timeout: float = 0.1) -> None: Args: timeout: Seconds to block for input. """ + assert sys.__stdin__ is not None self._fileno = sys.__stdin__.fileno() self.timeout = timeout self._selector = selectors.DefaultSelector() diff --git a/src/textual/drivers/_input_reader_windows.py b/src/textual/drivers/_input_reader_windows.py index c001c728e2..ce9a014f12 100644 --- a/src/textual/drivers/_input_reader_windows.py +++ b/src/textual/drivers/_input_reader_windows.py @@ -13,6 +13,7 @@ def __init__(self, timeout: float = 0.1) -> None: Args: timeout: Seconds to block for input. """ + assert sys.__stdin__ is not None self._fileno = sys.__stdin__.fileno() self.timeout = timeout self._exit_event = Event() diff --git a/src/textual/drivers/win32.py b/src/textual/drivers/win32.py index a8eee1724b..2f64738f48 100644 --- a/src/textual/drivers/win32.py +++ b/src/textual/drivers/win32.py @@ -12,6 +12,7 @@ from textual import constants from textual._xterm_parser import XTermParser from textual.events import Event, Resize +from textual.message import Message from textual.geometry import Size if TYPE_CHECKING: @@ -162,6 +163,8 @@ def enable_application_mode() -> Callable[[], None]: A callable that will restore terminal to previous state. """ + assert sys.__stdin__ is not None + assert sys.__stdout__ is not None terminal_in = sys.__stdin__ terminal_out = sys.__stdout__ @@ -217,7 +220,7 @@ def __init__( loop: AbstractEventLoop, app: App, exit_event: threading.Event, - process_event: Callable[[Event], None], + process_event: Callable[[Message], None], ) -> None: self.loop = loop self.app = app diff --git a/src/textual/message.py b/src/textual/message.py index cc69ca1b65..04b79f69c6 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -81,8 +81,8 @@ def __init_subclass__( qualname = cls.__qualname__.rsplit(".", 1)[-1] # only keep the last two parts of the qualified name of deeply nested classes # for backwards compatibility, e.g. A.B.C.D becomes C.D - namespace = qualname.rsplit(".", 2)[-2:] - name = "_".join(camel_to_snake(part) for part in namespace) + namespace_ = qualname.rsplit(".", 2)[-2:] + name = "_".join(camel_to_snake(part) for part in namespace_) cls.handler_name = f"on_{name}" @property diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7a1e3bde7d..a82877fe69 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,5 +1,4 @@ """ - A `MessagePump` is a base class for any object which processes messages, which includes Widget, Screen, and App. !!! tip @@ -28,6 +27,7 @@ Type, TypeVar, cast, + Self, overload, ) from weakref import WeakSet @@ -117,7 +117,7 @@ def __new__( class MessagePump(metaclass=_MessagePumpMeta): """Base class which supplies a message pump.""" - def __init__(self, parent: MessagePump | None = None) -> None: + def __init__(self, parent: Self | None = None) -> None: self._parent = parent self._running: bool = False self._closing: bool = False @@ -253,7 +253,7 @@ def is_attached(self) -> bool: except NoActiveAppError: return False node: MessagePump | None = self - while (node := node._parent) is not None: + while node is not None and (node := node._parent) is not None: if node.is_dom_root: return True return False @@ -278,7 +278,7 @@ def log(self) -> Logger: """ return self.app._logger - def _attach(self, parent: MessagePump) -> None: + def _attach(self, parent: Self) -> None: """Set the parent, and therefore attach this node to the tree. Args: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 1480cf70a0..ac89a0cc6e 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from textual.dom import DOMNode + from textual.message_pump import MessagePump Reactable = DOMNode @@ -153,7 +154,7 @@ def __rich_repr__(self) -> rich.repr.Result: yield "name", getattr(self, "name", None), None @classmethod - def _clear_watchers(cls, obj: Reactable) -> None: + def _clear_watchers(cls, obj: MessagePump) -> None: """Clear any watchers on a given object. Args: diff --git a/src/textual/signal.py b/src/textual/signal.py index e6c8119777..5d0ab6aec1 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, TypeVar, Union +from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, Generic, Union from weakref import WeakKeyDictionary, ref import rich.repr @@ -41,9 +41,9 @@ def __init__(self, owner: DOMNode, name: str) -> None: """ self._owner = ref(owner) self._name = name - self._subscriptions: WeakKeyDictionary[ - MessagePump, list[SignalCallbackType] - ] = WeakKeyDictionary() + self._subscriptions: WeakKeyDictionary[DOMNode, list[SignalCallbackType]] = ( + WeakKeyDictionary() + ) def __rich_repr__(self) -> rich.repr.Result: yield "owner", self.owner @@ -57,7 +57,7 @@ def owner(self) -> DOMNode | None: def subscribe( self, - node: MessagePump, + node: DOMNode, callback: SignalCallbackType, immediate: bool = False, ) -> None: @@ -95,7 +95,7 @@ def signal_callback(data: object) -> None: callbacks = self._subscriptions.setdefault(node, []) callbacks.append(signal_callback) - def unsubscribe(self, node: MessagePump) -> None: + def unsubscribe(self, node: DOMNode) -> None: """Unsubscribe a node from this signal. Args: diff --git a/src/textual/walk.py b/src/textual/walk.py index dcda856e49..46b079a1da 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -17,22 +17,21 @@ WalkType = TypeVar("WalkType", bound=DOMNode) -if TYPE_CHECKING: +@overload +def walk_depth_first( + root: DOMNode, + *, + with_root: bool = True, +) -> Iterable[DOMNode]: ... - @overload - def walk_depth_first( - root: DOMNode, - *, - with_root: bool = True, - ) -> Iterable[DOMNode]: ... - @overload - def walk_depth_first( - root: WalkType, - filter_type: type[WalkType], - *, - with_root: bool = True, - ) -> Iterable[WalkType]: ... +@overload +def walk_depth_first( + root: WalkType, + filter_type: type[WalkType], + *, + with_root: bool = True, +) -> Iterable[WalkType]: ... def walk_depth_first( @@ -83,22 +82,21 @@ def walk_depth_first( push(iter(children)) -if TYPE_CHECKING: +@overload +def walk_breadth_first( + root: DOMNode, + *, + with_root: bool = True, +) -> Iterable[DOMNode]: ... - @overload - def walk_breadth_first( - root: DOMNode, - *, - with_root: bool = True, - ) -> Iterable[DOMNode]: ... - - @overload - def walk_breadth_first( - root: WalkType, - filter_type: type[WalkType], - *, - with_root: bool = True, - ) -> Iterable[WalkType]: ... + +@overload +def walk_breadth_first( + root: WalkType, + filter_type: type[WalkType], + *, + with_root: bool = True, +) -> Iterable[WalkType]: ... def walk_breadth_first( diff --git a/src/textual/widget.py b/src/textual/widget.py index 4e73d3799a..5353f27bf3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -448,13 +448,20 @@ def __init__( self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache: FIFOCache[ - tuple[Size, int, Widget], DockArrangeResult - ] = FIFOCache(4) + self._arrangement_cache: FIFOCache[tuple[Size, int], DockArrangeResult] = ( + FIFOCache(4) + ) self._styles_cache = StylesCache() self._rich_style_cache: dict[tuple[str, ...], tuple[Style, Style]] = {} - self._visual_style_cache: dict[tuple[str, ...], VisualStyle] = {} + self._visual_style_cache: dict[ + tuple[ + tuple[int, ...], + tuple[str, ...], + bool, + ], + VisualStyle, + ] = {} self._tooltip: VisualType | None = None """The tooltip content.""" @@ -1013,15 +1020,13 @@ def get_child_by_id( ) return child - if TYPE_CHECKING: - - @overload - def get_widget_by_id(self, id: str) -> Widget: ... + @overload + def get_widget_by_id(self, id: str) -> Widget: ... - @overload - def get_widget_by_id( - self, id: str, expect_type: type[ExpectType] - ) -> ExpectType: ... + @overload + def get_widget_by_id( + self, id: str, expect_type: type[ExpectType] + ) -> ExpectType: ... def get_widget_by_id( self, id: str, expect_type: type[ExpectType] | None = None @@ -1124,7 +1129,7 @@ def get_visual_style( def iter_styles() -> Iterable[StylesBase]: """Iterate over the styles from the DOM and additional components styles.""" if partial: - node = self + node: DOMNode = self else: for node in reversed(self.ancestors_with_self): yield node.styles diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py index a879e2594c..857618564c 100644 --- a/src/textual/widgets/text_area.py +++ b/src/textual/widgets/text_area.py @@ -12,13 +12,13 @@ from textual.document._syntax_aware_document import SyntaxAwareDocument from textual.document._wrapped_document import WrappedDocument from textual.widgets._text_area import ( + BUILTIN_LANGUAGES, EndColumn, Highlight, HighlightName, LanguageDoesNotExist, StartColumn, ThemeDoesNotExist, - BUILTIN_LANGUAGES, ) __all__ = [ diff --git a/src/textual/worker.py b/src/textual/worker.py index 582b242ced..020a9dd1da 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -306,22 +306,19 @@ def run_callable(work: Callable[[], ResultType]) -> ResultType: active_worker.set(self) return work() + loop = asyncio.get_running_loop() if ( inspect.iscoroutinefunction(self._work) or hasattr(self._work, "func") and inspect.iscoroutinefunction(self._work.func) ): - runner = run_coroutine - elif inspect.isawaitable(self._work): - runner = run_awaitable - elif callable(self._work): - runner = run_callable - else: - raise WorkerError("Unsupported attempt to run a thread worker") + return await loop.run_in_executor(None, run_coroutine, self._work) # type: ignore[arg-type] + if inspect.isawaitable(self._work): + return await loop.run_in_executor(None, run_awaitable, self._work) + if callable(self._work): + return await loop.run_in_executor(None, run_callable, self._work) # type: ignore[arg-type] - loop = asyncio.get_running_loop() - assert loop is not None - return await loop.run_in_executor(None, runner, self._work) + raise WorkerError("Unsupported attempt to run a thread worker") async def _run_async(self) -> ResultType: """Run an async worker. @@ -334,7 +331,7 @@ async def _run_async(self) -> ResultType: or hasattr(self._work, "func") and inspect.iscoroutinefunction(self._work.func) ): - return await self._work() + return await self._work() # type: ignore[operator] elif inspect.isawaitable(self._work): return await self._work elif callable(self._work): diff --git a/tests/input/test_input_restrict.py b/tests/input/test_input_restrict.py index 48d9d50ee7..d0465ec2e2 100644 --- a/tests/input/test_input_restrict.py +++ b/tests/input/test_input_restrict.py @@ -38,7 +38,6 @@ def test_input_number_type(): assert not re.fullmatch(number, "-inf") - def test_input_integer_type(): """Test input type regex""" integer = _RESTRICT_TYPES["integer"] diff --git a/tests/option_list/test_option_prompt_replacement.py b/tests/option_list/test_option_prompt_replacement.py index ef11b75383..4bcb2f7ca5 100644 --- a/tests/option_list/test_option_prompt_replacement.py +++ b/tests/option_list/test_option_prompt_replacement.py @@ -1,4 +1,5 @@ """Test replacing options prompt from an option list.""" + import pytest from textual.app import App, ComposeResult @@ -20,14 +21,18 @@ async def test_replace_option_prompt_with_invalid_id() -> None: """Attempting to replace the prompt of an option ID that doesn't exist should raise an exception.""" async with OptionListApp().run_test() as pilot: with pytest.raises(OptionDoesNotExist): - pilot.app.query_one(OptionList).replace_option_prompt("does-not-exist", "new-prompt") + pilot.app.query_one(OptionList).replace_option_prompt( + "does-not-exist", "new-prompt" + ) async def test_replace_option_prompt_with_invalid_index() -> None: """Attempting to replace the prompt of an option index that doesn't exist should raise an exception.""" async with OptionListApp().run_test() as pilot: with pytest.raises(OptionDoesNotExist): - pilot.app.query_one(OptionList).replace_option_prompt_at_index(23, "new-prompt") + pilot.app.query_one(OptionList).replace_option_prompt_at_index( + 23, "new-prompt" + ) async def test_replace_option_prompt_with_valid_id() -> None: @@ -41,12 +46,14 @@ async def test_replace_option_prompt_with_valid_id() -> None: async def test_replace_option_prompt_with_valid_index() -> None: """It should be possible to replace the prompt of an option index that does exist.""" async with OptionListApp().run_test() as pilot: - option_list = pilot.app.query_one(OptionList).replace_option_prompt_at_index(1, "new-prompt") + option_list = pilot.app.query_one(OptionList).replace_option_prompt_at_index( + 1, "new-prompt" + ) assert option_list.get_option_at_index(1).prompt == "new-prompt" async def test_replace_single_line_option_prompt_with_multiple() -> None: - """It should be possible to replace single line prompt with multiple lines """ + """It should be possible to replace single line prompt with multiple lines""" new_prompt = "new-prompt\nsecond line" async with OptionListApp().run_test() as pilot: option_list = pilot.app.query_one(OptionList) diff --git a/tests/select/test_blank_and_clear.py b/tests/select/test_blank_and_clear.py index 4d1921451f..3d4f7dca66 100644 --- a/tests/select/test_blank_and_clear.py +++ b/tests/select/test_blank_and_clear.py @@ -64,6 +64,7 @@ def compose(self): with pytest.raises(InvalidSelectValueError): select.clear() + async def test_selection_is_none_with_blank(): class SelectApp(App[None]): def compose(self): diff --git a/tests/selection_list/test_selection_click_checkbox.py b/tests/selection_list/test_selection_click_checkbox.py index e9c349e46b..8ca8b0fe0f 100644 --- a/tests/selection_list/test_selection_click_checkbox.py +++ b/tests/selection_list/test_selection_click_checkbox.py @@ -5,6 +5,7 @@ from textual.geometry import Offset from textual.widgets import SelectionList + class SelectionListApp(App[None]): """Test selection list application.""" @@ -25,7 +26,7 @@ async def test_click_on_prompt() -> None: """It should be possible to toggle a selection by clicking on the prompt.""" async with SelectionListApp().run_test() as pilot: assert isinstance(pilot.app, SelectionListApp) - await pilot.click(SelectionList, Offset(5,1)) + await pilot.click(SelectionList, Offset(5, 1)) await pilot.pause() assert pilot.app.clicks == [0] @@ -34,9 +35,10 @@ async def test_click_on_checkbox() -> None: """It should be possible to toggle a selection by clicking on the checkbox.""" async with SelectionListApp().run_test() as pilot: assert isinstance(pilot.app, SelectionListApp) - await pilot.click(SelectionList, Offset(3,1)) + await pilot.click(SelectionList, Offset(3, 1)) await pilot.pause() assert pilot.app.clicks == [0] + if __name__ == "__main__": SelectionListApp().run() diff --git a/tests/test_expand_tabs.py b/tests/test_expand_tabs.py index b120978da5..811803b7a5 100644 --- a/tests/test_expand_tabs.py +++ b/tests/test_expand_tabs.py @@ -29,4 +29,9 @@ def test_get_tab_widths(): assert get_tab_widths("\tbar") == [("", 4), ("bar", 0)] assert get_tab_widths("\tbar\t") == [("", 4), ("bar", 1)] assert get_tab_widths("\tfoo\t\t") == [("", 4), ("foo", 1), ("", 4)] - assert get_tab_widths("\t木foo\t木\t\t") == [("", 4), ("木foo", 3), ("木", 2), ("", 4)] + assert get_tab_widths("\t木foo\t木\t\t") == [ + ("", 4), + ("木foo", 3), + ("木", 2), + ("", 4), + ] diff --git a/tests/test_unmount.py b/tests/test_unmount.py index 3da75d1247..324a3812a3 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -50,5 +50,4 @@ async def on_mount(self) -> None: "MyScreen#main", ] - assert unmount_ids == expected diff --git a/tests/test_validation.py b/tests/test_validation.py index 38be8b38e9..6a6bee2f6a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -160,7 +160,12 @@ def test_Regex_validate(regex, value, expected_result): ("123", 100, 200, True), # valid integer within range ("99", 100, 200, False), # valid integer but not in range ("201", 100, 200, False), # valid integer but not in range - ("1.23e4", None, None, False), # valid scientific notation, even resolving to an integer, is not valid + ( + "1.23e4", + None, + None, + False, + ), # valid scientific notation, even resolving to an integer, is not valid ("123.", None, None, False), # periods not valid in integers ("123_456", None, None, True), # underscores are valid python ("_123_456", None, None, False), # leading underscores are not valid python diff --git a/tests/test_win_sleep.py b/tests/test_win_sleep.py index 5ed9abbd83..fdbc9ae178 100644 --- a/tests/test_win_sleep.py +++ b/tests/test_win_sleep.py @@ -1,6 +1,6 @@ import asyncio -import time import sys +import time import pytest diff --git a/tests/text_area/test_edit_via_api.py b/tests/text_area/test_edit_via_api.py index e732680f0f..d4f8241ed3 100644 --- a/tests/text_area/test_edit_via_api.py +++ b/tests/text_area/test_edit_via_api.py @@ -115,9 +115,7 @@ async def test_insert_character_near_cursor_maintain_selection_offset( ], ) async def test_insert_newline_around_cursor_maintain_selection_offset( - cursor_location, - insert_location, - cursor_destination + cursor_location, insert_location, cursor_destination ): app = TextAreaApp() async with app.run_test(): diff --git a/tests/text_area/test_selection_bindings.py b/tests/text_area/test_selection_bindings.py index 4fd6947386..5985635cc6 100644 --- a/tests/text_area/test_selection_bindings.py +++ b/tests/text_area/test_selection_bindings.py @@ -254,9 +254,7 @@ async def test_cursor_page_down(app: TextAreaApp): text_area.selection = Selection.cursor((0, 1)) await pilot.press("pagedown") margin = 2 - assert text_area.selection == Selection.cursor( - (app.size.height - margin, 1) - ) + assert text_area.selection == Selection.cursor((app.size.height - margin, 1)) async def test_cursor_page_up(app: TextAreaApp): diff --git a/tests/tree/test_node_refresh.py b/tests/tree/test_node_refresh.py index 53e98b7387..8efaaec1ed 100644 --- a/tests/tree/test_node_refresh.py +++ b/tests/tree/test_node_refresh.py @@ -5,6 +5,7 @@ from textual.widgets import Tree from textual.widgets.tree import TreeNode + class HistoryTree(Tree): def __init__(self) -> None: @@ -33,7 +34,7 @@ async def test_initial_state() -> None: """Initially all the visible nodes should have had a render call.""" app = RefreshApp() async with app.run_test(): - assert app.query_one(HistoryTree).render_hits == {(0,0), (1,0), (2,0)} + assert app.query_one(HistoryTree).render_hits == {(0, 0), (1, 0), (2, 0)} async def test_root_refresh() -> None: @@ -45,6 +46,7 @@ async def test_root_refresh() -> None: await pilot.pause() assert (0, 1) in pilot.app.query_one(HistoryTree).render_hits + async def test_child_refresh() -> None: """A refresh of the child node should cause a subsequent render call.""" async with RefreshApp().run_test() as pilot: @@ -54,6 +56,7 @@ async def test_child_refresh() -> None: await pilot.pause() assert (1, 1) in pilot.app.query_one(HistoryTree).render_hits + async def test_grandchild_refresh() -> None: """A refresh of the grandchild node should cause a subsequent render call.""" async with RefreshApp().run_test() as pilot: diff --git a/tools/widget_documentation.py b/tools/widget_documentation.py index 04f3de86bf..7149e6d465 100644 --- a/tools/widget_documentation.py +++ b/tools/widget_documentation.py @@ -4,6 +4,7 @@ This goes through the widgets listed in textual.widgets and prints the scaffolding for the tables that are used to document the classvars BINDINGS and COMPONENT_CLASSES. """ + from __future__ import annotations from typing import TYPE_CHECKING