Merge pull request #463 from Textualize/start-accepting-paths-in-textual-api

[API] Start accepting PathLike objects here and there
This commit is contained in:
Will McGugan
2022-05-05 10:48:11 +01:00
committed by GitHub
31 changed files with 74 additions and 63 deletions

View File

@@ -87,7 +87,7 @@ class ColorChanger(App):
self.background = f"on color({event.key})" self.background = f"on color({event.key})"
ColorChanger.run(log="textual.log") ColorChanger.run(log_path="textual.log")
``` ```
You'll notice that the `on_key` method above contains an additional `event` parameter which wasn't present on the beeper example. If the `event` argument is present, Textual will call the handler with an event object. Every event has an associated handler object, in this case it is a KeyEvent which contains additional information regarding which key was pressed. You'll notice that the `on_key` method above contains an additional `event` parameter which wasn't present on the beeper example. If the `event` argument is present, Textual will call the handler with an event object. Every event has an associated handler object, in this case it is a KeyEvent which contains additional information regarding which key was pressed.
@@ -114,7 +114,7 @@ class SimpleApp(App):
await self.view.dock(Placeholder(), Placeholder(), edge="top") await self.view.dock(Placeholder(), Placeholder(), edge="top")
SimpleApp.run(log="textual.log") SimpleApp.run(log_path="textual.log")
``` ```
This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. You may have noticed that `on_mount` is an `async` function. Since Textual is an asynchronous framework we will need this if we need to call most other methods. This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. You may have noticed that `on_mount` is an `async` function. Since Textual is an asynchronous framework we will need this if we need to call most other methods.
@@ -137,7 +137,7 @@ await self.view.dock(Placeholder(), Placeholder(), edge="top")
You will notice that this time we are docking _two_ Placeholder objects onto the `"top"` edge. We haven't set an explicit size this time so Textual will divide the remaining size amongst the two new widgets. You will notice that this time we are docking _two_ Placeholder objects onto the `"top"` edge. We haven't set an explicit size this time so Textual will divide the remaining size amongst the two new widgets.
The last line calls the `run` class method in the usual way, but with an argument we haven't seen before: `log="textual.log"` tells Textual to write log information to the given file. You can tail textual.log to see events being processed and other debug information. The last line calls the `run` class method in the usual way, but with an argument we haven't seen before: `log_path="textual.log"` tells Textual to write log information to the given file. You can tail textual.log to see events being processed and other debug information.
If you run the above example, you will see something like the following: If you run the above example, you will see something like the following:
@@ -183,7 +183,7 @@ class HoverApp(App):
await self.view.dock(*hovers, edge="top") await self.view.dock(*hovers, edge="top")
HoverApp.run(log="textual.log") HoverApp.run(log_path="textual.log")
``` ```
The `Hover` class is a custom widget which displays a panel containing the classic text "Hello World". The first line in the Hover class may seem a little mysterious at this point: The `Hover` class is a custom widget which displays a panel containing the classic text "Hello World". The first line in the Hover class may seem a little mysterious at this point:

View File

@@ -7,4 +7,4 @@ class ColorChanger(App):
self.background = f"on color({event.key})" self.background = f"on color({event.key})"
ColorChanger.run(log="textual.log") ColorChanger.run(log_path="textual.log")

View File

@@ -29,4 +29,4 @@ class HoverApp(App):
await self.screen.dock(*hovers, edge="top") await self.screen.dock(*hovers, edge="top")
HoverApp.run(log="textual.log") HoverApp.run(log_path="textual.log")

View File

@@ -12,4 +12,4 @@ class SimpleApp(App):
await self.screen.dock(Placeholder(), Placeholder(), edge="top") await self.screen.dock(Placeholder(), Placeholder(), edge="top")
SimpleApp.run(log="textual.log") SimpleApp.run(log_path="textual.log")

View File

@@ -141,9 +141,12 @@ class BasicApp(App):
self.panic(self.tree) self.panic(self.tree)
css_file = Path(__file__).parent / "basic.css" sandbox_folder = Path(__file__).parent
app = BasicApp( app = BasicApp(
css_file=str(css_file), watch_css=True, log="textual.log", log_verbosity=0 css_path=sandbox_folder / "basic.css",
watch_css=True,
log_path=sandbox_folder / "basic.log",
log_verbosity=0,
) )
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -35,4 +35,4 @@ class SmoothApp(App):
# self.set_timer(10, lambda: self.action("quit")) # self.set_timer(10, lambda: self.action("quit"))
SmoothApp.run(log="textual.log", log_verbosity=2) SmoothApp.run(log_path="textual.log", log_verbosity=2)

View File

@@ -30,4 +30,4 @@ class MyApp(App):
await self.call_later(add_content) await self.call_later(add_content)
MyApp.run(title="Simple App", log="textual.log") MyApp.run(title="Simple App", log_path="textual.log")

View File

@@ -55,4 +55,4 @@ class BordersApp(App):
self.mount(borders=borders_view) self.mount(borders=borders_view)
BordersApp.run(css_file="borders.css", log="textual.log") BordersApp.run(css_path="borders.css", log_path="textual.log")

View File

@@ -212,4 +212,4 @@ class CalculatorApp(App):
await self.screen.dock(Calculator()) await self.screen.dock(Calculator())
CalculatorApp.run(title="Calculator Test", log="textual.log") CalculatorApp.run(title="Calculator Test", log_path="textual.log")

View File

@@ -67,4 +67,4 @@ class MyApp(App):
# Run our app class # Run our app class
MyApp.run(title="Code Viewer", log="textual.log") MyApp.run(title="Code Viewer", log_path="textual.log")

View File

@@ -42,4 +42,4 @@ class EasingApp(App):
self.side = not self.side self.side = not self.side
EasingApp().run(log="textual.log") EasingApp().run(log_path="textual.log")

View File

@@ -31,4 +31,4 @@ class GridTest(App):
) )
GridTest.run(title="Grid Test", log="textual.log") GridTest.run(title="Grid Test", log_path="textual.log")

View File

@@ -19,4 +19,4 @@ class GridTest(App):
grid.place(*placeholders, center=Placeholder()) grid.place(*placeholders, center=Placeholder())
GridTest.run(title="Grid Test", log="textual.log") GridTest.run(title="Grid Test", log_path="textual.log")

View File

@@ -48,4 +48,4 @@ class MyApp(App):
await self.call_later(get_markdown, "richreadme.md") await self.call_later(get_markdown, "richreadme.md")
MyApp.run(title="Simple App", log="textual.log") MyApp.run(title="Simple App", log_path="textual.log")

View File

@@ -22,4 +22,4 @@ class AlignApp(App):
self.log(self.screen.tree) self.log(self.screen.tree)
AlignApp.run(css_file="align.css", log="textual.log", watch_css=True) AlignApp.run(css_path="align.css", log_path="textual.log", watch_css=True)

View File

@@ -1,6 +1,3 @@
from pathlib import Path
from rich.align import Align
from rich.console import RenderableType from rich.console import RenderableType
from rich.syntax import Syntax from rich.syntax import Syntax
from rich.text import Text from rich.text import Text
@@ -141,8 +138,11 @@ class BasicApp(App):
self.panic(self.tree) self.panic(self.tree)
css_file = Path(__file__).parent / "basic.css" app = BasicApp(
app = BasicApp(css_file=str(css_file), watch_css=True, log="textual.log") css_path="basic.css",
watch_css=True,
log_path="textual.log",
)
if __name__ == "__main__": if __name__ == "__main__":
app.run() app.run()

View File

@@ -5,7 +5,6 @@ from textual import layout
class ButtonsApp(App[str]): class ButtonsApp(App[str]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield layout.Vertical( yield layout.Vertical(
Button("foo", id="foo"), Button("foo", id="foo"),
@@ -19,7 +18,7 @@ class ButtonsApp(App[str]):
self.exit(event.button.id) self.exit(event.button.id)
app = ButtonsApp(log="textual.log", log_verbosity=2) app = ButtonsApp(log_path="textual.log", log_verbosity=2)
if __name__ == "__main__": if __name__ == "__main__":
result = app.run() result = app.run()

View File

@@ -34,4 +34,4 @@ class BasicApp(App):
self.panic(self.tree) self.panic(self.tree)
BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log") BasicApp.run(css_path="dev_sandbox.scss", watch_css=True, log_path="textual.log")

View File

@@ -33,4 +33,4 @@ class BasicApp(App):
self.log(header.styles) self.log(header.styles)
BasicApp.run(css_file="local_styles.css", log="textual.log") BasicApp.run(css_path="local_styles.css", log_path="textual.log")

View File

@@ -144,4 +144,4 @@ class BasicApp(App):
self.mount(example.widget) self.mount(example.widget)
BasicApp.run(css_file="tabs.scss", watch_css=True, log="textual.log") BasicApp.run(css_path="tabs.scss", watch_css=True, log_path="textual.log")

View File

@@ -76,7 +76,7 @@ class BasicApp(App):
self.focused.styles.border_top = ("solid", "invalid-color") self.focused.styles.border_top = ("solid", "invalid-color")
app = BasicApp(css_file="uber.css", log="textual.log", log_verbosity=1) app = BasicApp(css_path="uber.css", log_path="textual.log", log_verbosity=1)
if __name__ == "__main__": if __name__ == "__main__":
app.run() app.run()

View File

@@ -7,6 +7,7 @@ import platform
import sys import sys
import warnings import warnings
from contextlib import redirect_stdout from contextlib import redirect_stdout
from pathlib import PurePath
from time import perf_counter from time import perf_counter
from typing import ( from typing import (
Any, Any,
@@ -105,20 +106,20 @@ class App(Generic[ReturnType], DOMNode):
def __init__( def __init__(
self, self,
driver_class: Type[Driver] | None = None, driver_class: Type[Driver] | None = None,
log: str = "", log_path: str | PurePath = "",
log_verbosity: int = 1, log_verbosity: int = 1,
title: str = "Textual Application", title: str = "Textual Application",
css_file: str | None = None, css_path: str | PurePath | None = None,
watch_css: bool = True, watch_css: bool = True,
): ):
"""Textual application base class """Textual application base class
Args: Args:
driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None.
log (str, optional): Path to log file, or "" to disable. Defaults to "". log_path (str | PurePath, optional): Path to log file, or "" to disable. Defaults to "".
log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1. log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1.
title (str, optional): Default title of the application. Defaults to "Textual Application". title (str, optional): Default title of the application. Defaults to "Textual Application".
css_file (str | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None. css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None.
watch_css (bool, optional): Watch CSS for changes. Defaults to True. watch_css (bool, optional): Watch CSS for changes. Defaults to True.
""" """
# N.B. This must be done *before* we call the parent constructor, because MessagePump's # N.B. This must be done *before* we call the parent constructor, because MessagePump's
@@ -150,8 +151,8 @@ class App(Generic[ReturnType], DOMNode):
self._log_console: Console | None = None self._log_console: Console | None = None
self._log_file: TextIO | None = None self._log_file: TextIO | None = None
if log: if log_path:
self._log_file = open(log, "wt") self._log_file = open(log_path, "wt")
self._log_console = Console( self._log_console = Console(
file=self._log_file, file=self._log_file,
markup=False, markup=False,
@@ -170,10 +171,10 @@ class App(Generic[ReturnType], DOMNode):
self.stylesheet = Stylesheet(variables=self.get_css_variables()) self.stylesheet = Stylesheet(variables=self.get_css_variables())
self._require_styles_update = False self._require_styles_update = False
self.css_file = css_file self.css_path = css_path
self.css_monitor = ( self.css_monitor = (
FileMonitor(css_file, self._on_css_change) FileMonitor(css_path, self._on_css_change)
if (watch_css and css_file) if (watch_css and css_path)
else None else None
) )
@@ -449,13 +450,13 @@ class App(Generic[ReturnType], DOMNode):
async def _on_css_change(self) -> None: async def _on_css_change(self) -> None:
"""Called when the CSS changes (if watch_css is True).""" """Called when the CSS changes (if watch_css is True)."""
if self.css_file is not None: if self.css_path is not None:
try: try:
time = perf_counter() time = perf_counter()
self.stylesheet.read(self.css_file) self.stylesheet.read(self.css_path)
elapsed = (perf_counter() - time) * 1000 elapsed = (perf_counter() - time) * 1000
self.log(f"loaded {self.css_file} in {elapsed:.0f}ms") self.log(f"loaded {self.css_path} in {elapsed:.0f}ms")
except Exception as error: except Exception as error:
# TODO: Catch specific exceptions # TODO: Catch specific exceptions
self.console.bell() self.console.bell()
@@ -646,8 +647,8 @@ class App(Generic[ReturnType], DOMNode):
except DevtoolsConnectionError: except DevtoolsConnectionError:
self.log(f"Couldn't connect to devtools ({self.devtools.url})") self.log(f"Couldn't connect to devtools ({self.devtools.url})")
try: try:
if self.css_file is not None: if self.css_path is not None:
self.stylesheet.read(self.css_file) self.stylesheet.read(self.css_path)
if self.CSS is not None: if self.CSS is not None:
self.stylesheet.add_source( self.stylesheet.add_source(
self.CSS, path=f"<{self.__class__.__name__}>" self.CSS, path=f"<{self.__class__.__name__}>"

View File

@@ -36,8 +36,8 @@ def _check_selectors(selectors: list[Selector], node: DOMNode) -> bool:
DESCENDENT = CombinatorType.DESCENDENT DESCENDENT = CombinatorType.DESCENDENT
css_path = node.css_path css_path_nodes = node.css_path_nodes
path_count = len(css_path) path_count = len(css_path_nodes)
selector_count = len(selectors) selector_count = len(selectors)
stack: list[tuple[int, int]] = [(0, 0)] stack: list[tuple[int, int]] = [(0, 0)]
@@ -51,7 +51,7 @@ def _check_selectors(selectors: list[Selector], node: DOMNode) -> bool:
if selector_index == selector_count or node_index == path_count: if selector_index == selector_count or node_index == path_count:
pop() pop()
else: else:
path_node = css_path[node_index] path_node = css_path_nodes[node_index]
selector = selectors[selector_index] selector = selectors[selector_index]
if selector.combinator == DESCENDENT: if selector.combinator == DESCENDENT:
# Find a matching descendent # Find a matching descendent

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import PurePath
from typing import Iterator, Iterable from typing import Iterator, Iterable
from rich import print from rich import print
@@ -305,7 +306,7 @@ def substitute_references(
def parse( def parse(
css: str, path: str, variables: dict[str, str] | None = None css: str, path: str | PurePath, variables: dict[str, str] | None = None
) -> Iterable[RuleSet]: ) -> Iterable[RuleSet]:
"""Parse CSS by tokenizing it, performing variable substitution, """Parse CSS by tokenizing it, performing variable substitution,
and generating rule sets from it. and generating rule sets from it.

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import os import os
from collections import defaultdict from collections import defaultdict
from operator import itemgetter from operator import itemgetter
from pathlib import Path from pathlib import Path, PurePath
from typing import cast, Iterable from typing import cast, Iterable
import rich.repr import rich.repr
@@ -149,13 +149,13 @@ class Stylesheet:
""" """
self.variables = variables self.variables = variables
def _parse_rules(self, css: str, path: str) -> list[RuleSet]: def _parse_rules(self, css: str, path: str | PurePath) -> list[RuleSet]:
"""Parse CSS and return rules. """Parse CSS and return rules.
Args: Args:
css (str): String containing Textual CSS. css (str): String containing Textual CSS.
path (str): Path to CSS or unique identifier path (str | PurePath): Path to CSS or unique identifier
Raises: Raises:
StylesheetError: If the CSS is invalid. StylesheetError: If the CSS is invalid.
@@ -171,11 +171,11 @@ class Stylesheet:
raise StylesheetError(f"failed to parse css; {error}") raise StylesheetError(f"failed to parse css; {error}")
return rules return rules
def read(self, filename: str) -> None: def read(self, filename: str | PurePath) -> None:
"""Read Textual CSS file. """Read Textual CSS file.
Args: Args:
filename (str): filename of CSS. filename (str | PurePath): filename of CSS.
Raises: Raises:
StylesheetError: If the CSS could not be read. StylesheetError: If the CSS could not be read.
@@ -188,15 +188,16 @@ class Stylesheet:
path = os.path.abspath(filename) path = os.path.abspath(filename)
except Exception as error: except Exception as error:
raise StylesheetError(f"unable to read {filename!r}; {error}") raise StylesheetError(f"unable to read {filename!r}; {error}")
self.source[path] = css self.source[str(path)] = css
self._require_parse = True self._require_parse = True
def add_source(self, css: str, path: str | None = None) -> None: def add_source(self, css: str, path: str | PurePath | None = None) -> None:
"""Parse CSS from a string. """Parse CSS from a string.
Args: Args:
css (str): String with CSS source. css (str): String with CSS source.
path (str, optional): The path of the source if a file, or some other identifier. Defaults to "". path (str | PurePath, optional): The path of the source if a file, or some other identifier.
Defaults to None.
Raises: Raises:
StylesheetError: If the CSS could not be read. StylesheetError: If the CSS could not be read.
@@ -205,6 +206,8 @@ class Stylesheet:
if path is None: if path is None:
path = str(hash(css)) path = str(hash(css))
elif isinstance(path, PurePath):
path = str(css)
if path in self.source and self.source[path] == css: if path in self.source and self.source[path] == css:
# Path already in source, and CSS is identical # Path already in source, and CSS is identical
return return

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from pathlib import PurePath
from typing import Iterable from typing import Iterable
from textual.css.tokenizer import Expect, Tokenizer, Token from textual.css.tokenizer import Expect, Tokenizer, Token
@@ -136,7 +137,7 @@ class TokenizerState:
"declaration_set_end": expect_root_scope, "declaration_set_end": expect_root_scope,
} }
def __call__(self, code: str, path: str) -> Iterable[Token]: def __call__(self, code: str, path: str | PurePath) -> Iterable[Token]:
tokenizer = Tokenizer(code, path=path) tokenizer = Tokenizer(code, path=path)
expect = self.EXPECT expect = self.EXPECT
get_token = tokenizer.get_token get_token = tokenizer.get_token

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from pathlib import PurePath
from typing import NamedTuple from typing import NamedTuple
from rich.console import Group, RenderableType from rich.console import Group, RenderableType
@@ -148,8 +149,8 @@ class Token(NamedTuple):
class Tokenizer: class Tokenizer:
def __init__(self, text: str, path: str = "") -> None: def __init__(self, text: str, path: str | PurePath = "") -> None:
self.path = path self.path = str(path)
self.code = text self.code = text
self.lines = text.splitlines(keepends=True) self.lines = text.splitlines(keepends=True)
self.line_no = 0 self.line_no = 0

View File

@@ -167,7 +167,7 @@ class DOMNode(MessagePump):
return self.__class__.__name__.lower() return self.__class__.__name__.lower()
@property @property
def css_path(self) -> list[DOMNode]: def css_path_nodes(self) -> list[DOMNode]:
"""A list of nodes from the root to this node, forming a "path". """A list of nodes from the root to this node, forming a "path".
Returns: Returns:

View File

@@ -1,4 +1,6 @@
from __future__ import annotations
import os import os
from pathlib import PurePath
from typing import Callable from typing import Callable
import rich.repr import rich.repr
@@ -8,7 +10,7 @@ from ._callback import invoke
@rich.repr.auto @rich.repr.auto
class FileMonitor: class FileMonitor:
def __init__(self, path: str, callback: Callable) -> None: def __init__(self, path: str | PurePath, callback: Callable) -> None:
self.path = path self.path = path
self.callback = callback self.callback = callback
self._modified = self._get_modified() self._modified = self._get_modified()

View File

@@ -132,4 +132,4 @@ if __name__ == "__main__":
async def on_mount(self, event: events.Mount) -> None: async def on_mount(self, event: events.Mount) -> None:
await self.screen.dock(DirectoryTree("/Users/willmcgugan/projects")) await self.screen.dock(DirectoryTree("/Users/willmcgugan/projects"))
TreeApp.run(log="textual.log") TreeApp(log_path="textual.log").run()

View File

@@ -328,4 +328,4 @@ if __name__ == "__main__":
else: else:
await message.node.toggle() await message.node.toggle()
TreeApp.run(log="textual.log") TreeApp(log_path="textual.log").run()