mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into outline-top-bottom-colour-glitch
This commit is contained in:
2
.github/workflows/pythonpackage.yml
vendored
2
.github/workflows/pythonpackage.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Install and configure Poetry
|
||||
uses: snok/install-poetry@v1.3.3
|
||||
with:
|
||||
version: 1.2.2
|
||||
version: 1.4.2
|
||||
virtualenvs-in-project: true
|
||||
- name: Install dependencies
|
||||
run: poetry install --extras "dev"
|
||||
|
||||
@@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
- Fixed `outline` top and bottom not handling alpha - https://github.com/Textualize/textual/issues/2371
|
||||
|
||||
### Changed
|
||||
|
||||
- Setting attributes with a `compute_` method will now raise an `AttributeError` https://github.com/Textualize/textual/issues/2383
|
||||
- Unknown psuedo-selectors will now raise a tokenizer error (previously they were silently ignored) https://github.com/Textualize/textual/pull/2445
|
||||
|
||||
### Added
|
||||
|
||||
- Watch methods can now optionally be private https://github.com/Textualize/textual/issues/2382
|
||||
|
||||
## [0.22.3] - 2023-04-29
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -130,30 +130,29 @@ textual run --dev --port 7342 my_app.py
|
||||
|
||||
## Textual log
|
||||
|
||||
In addition to simple strings, Textual console supports [Rich](https://rich.readthedocs.io/en/latest/) formatting. To write rich logs, import `log` as follows:
|
||||
Use the `log` function to pretty-print data structures and anything that [Rich](https://rich.readthedocs.io/en/latest/) can display.
|
||||
|
||||
You can import the log function as follows:
|
||||
|
||||
```python
|
||||
from textual import log
|
||||
```
|
||||
|
||||
This method will pretty print data structures (like lists and dicts) as well as [Rich renderables](https://rich.readthedocs.io/en/stable/protocol.html). Here are some examples:
|
||||
Here's a few examples of writing to the console, with `log`:
|
||||
|
||||
|
||||
|
||||
```python
|
||||
log("Hello, World") # simple string
|
||||
log(locals()) # Log local variables
|
||||
log(children=self.children, pi=3.141592) # key/values
|
||||
log(self.tree) # Rich renderables
|
||||
```
|
||||
|
||||
Textual log messages may contain [console Markup](https://rich.readthedocs.io/en/stable/markup.html):
|
||||
|
||||
```python
|
||||
log("[bold red]DANGER![/] We're having too much fun")
|
||||
def on_mount(self) -> None:
|
||||
log("Hello, World") # simple string
|
||||
log(locals()) # Log local variables
|
||||
log(children=self.children, pi=3.141592) # key/values
|
||||
log(self.tree) # Rich renderables
|
||||
```
|
||||
|
||||
### Log method
|
||||
|
||||
There's a convenient shortcut to `log` available on the `App` and `Widget` objects. This is useful in event handlers. Here's an example:
|
||||
There's a convenient shortcut to `log` on the `App` and `Widget` objects. This is useful in event handlers. Here's an example:
|
||||
|
||||
```python
|
||||
from textual.app import App
|
||||
@@ -170,7 +169,7 @@ if __name__ == "__main__":
|
||||
LogApp().run()
|
||||
```
|
||||
|
||||
### Logging handler
|
||||
## Logging handler
|
||||
|
||||
Textual has a [logging handler][textual.logging.TextualHandler] which will write anything logged via the builtin logging library to the devtools.
|
||||
This may be useful if you have a third-party library that uses the logging module, and you want to see those logs with Textual logs.
|
||||
|
||||
@@ -61,6 +61,14 @@ VALID_STYLE_FLAGS: Final = {
|
||||
"underline",
|
||||
"uu",
|
||||
}
|
||||
VALID_PSEUDO_CLASSES: Final = {
|
||||
"blur",
|
||||
"disabled",
|
||||
"enabled",
|
||||
"focus-within",
|
||||
"focus",
|
||||
"hover",
|
||||
}
|
||||
|
||||
|
||||
NULL_SPACING: Final = Spacing.all(0)
|
||||
|
||||
@@ -12,7 +12,9 @@ from rich.panel import Panel
|
||||
from rich.syntax import Syntax
|
||||
from rich.text import Text
|
||||
|
||||
from ..suggestions import get_suggestion
|
||||
from ._error_tools import friendly_list
|
||||
from .constants import VALID_PSEUDO_CLASSES
|
||||
|
||||
|
||||
class TokenError(Exception):
|
||||
@@ -56,7 +58,7 @@ class TokenError(Exception):
|
||||
line_numbers=True,
|
||||
indent_guides=True,
|
||||
line_range=(max(0, line_no - 2), line_no + 2),
|
||||
highlight_lines={line_no},
|
||||
highlight_lines={line_no + 1},
|
||||
)
|
||||
syntax.stylize_range("reverse bold", self.start, self.end)
|
||||
return Panel(syntax, border_style="red")
|
||||
@@ -227,6 +229,29 @@ class Tokenizer:
|
||||
(line_no, col_no),
|
||||
referenced_by=None,
|
||||
)
|
||||
|
||||
if (
|
||||
token.name == "pseudo_class"
|
||||
and token.value.strip(":") not in VALID_PSEUDO_CLASSES
|
||||
):
|
||||
pseudo_class = token.value.strip(":")
|
||||
suggestion = get_suggestion(pseudo_class, list(VALID_PSEUDO_CLASSES))
|
||||
all_valid = f"must be one of {friendly_list(VALID_PSEUDO_CLASSES)}"
|
||||
if suggestion:
|
||||
raise TokenError(
|
||||
self.path,
|
||||
self.code,
|
||||
(line_no, col_no),
|
||||
f"unknown pseudo-class {pseudo_class!r}; did you mean {suggestion!r}?; {all_valid}",
|
||||
)
|
||||
else:
|
||||
raise TokenError(
|
||||
self.path,
|
||||
self.code,
|
||||
(line_no, col_no),
|
||||
f"unknown pseudo-class {pseudo_class!r}; {all_valid}",
|
||||
)
|
||||
|
||||
col_no += len(value)
|
||||
if col_no >= len(line):
|
||||
line_no += 1
|
||||
|
||||
@@ -174,6 +174,12 @@ class Reactive(Generic[ReactiveType]):
|
||||
_rich_traceback_omit = True
|
||||
|
||||
self._initialize_reactive(obj, self.name)
|
||||
|
||||
if hasattr(obj, self.compute_name):
|
||||
raise AttributeError(
|
||||
f"Can't set {obj}.{self.name!r}; reactive attributes with a compute method are read-only"
|
||||
)
|
||||
|
||||
name = self.name
|
||||
current_value = getattr(obj, name)
|
||||
# Check for validate function
|
||||
@@ -241,9 +247,13 @@ class Reactive(Generic[ReactiveType]):
|
||||
events.Callback(callback=partial(await_watcher, watch_result))
|
||||
)
|
||||
|
||||
watch_function = getattr(obj, f"watch_{name}", None)
|
||||
if callable(watch_function):
|
||||
invoke_watcher(watch_function, old_value, value)
|
||||
private_watch_function = getattr(obj, f"_watch_{name}", None)
|
||||
if callable(private_watch_function):
|
||||
invoke_watcher(private_watch_function, old_value, value)
|
||||
|
||||
public_watch_function = getattr(obj, f"watch_{name}", None)
|
||||
if callable(public_watch_function):
|
||||
invoke_watcher(public_watch_function, old_value, value)
|
||||
|
||||
# Process "global" watchers
|
||||
watchers: list[tuple[Reactable, Callable]]
|
||||
@@ -272,7 +282,9 @@ class Reactive(Generic[ReactiveType]):
|
||||
compute_method = getattr(obj, f"compute_{compute}")
|
||||
except AttributeError:
|
||||
continue
|
||||
current_value = getattr(obj, f"_reactive_{compute}")
|
||||
current_value = getattr(
|
||||
obj, f"_reactive_{compute}", getattr(obj, f"_default_{compute}", None)
|
||||
)
|
||||
value = compute_method()
|
||||
setattr(obj, f"_reactive_{compute}", value)
|
||||
if value != current_value:
|
||||
|
||||
@@ -336,7 +336,6 @@ class ProgressBar(Widget, can_focus=False):
|
||||
self.show_percentage = show_percentage
|
||||
self.show_eta = show_eta
|
||||
|
||||
self.percentage = None
|
||||
self.total = total
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
|
||||
@@ -140,52 +140,52 @@ A1
|
||||
|
||||
/**********************************************************************/
|
||||
|
||||
A:foo {}
|
||||
A:foo:bar {}
|
||||
A:focus {}
|
||||
A:focus:hover {}
|
||||
A
|
||||
:foo {}
|
||||
:focus {}
|
||||
A
|
||||
:foo:bar {}
|
||||
:focus:hover {}
|
||||
A
|
||||
:foo
|
||||
:bar {}
|
||||
A:foo-bar {}
|
||||
:focus
|
||||
:hover {}
|
||||
A:enabled {}
|
||||
A
|
||||
:foo-bar {}
|
||||
:enabled {}
|
||||
|
||||
A :foo {}
|
||||
A :foo :bar {}
|
||||
A :foo-bar {}
|
||||
A :focus {}
|
||||
A :focus :hover {}
|
||||
A :enabled {}
|
||||
|
||||
.A:foo {}
|
||||
.A:foo:bar {}
|
||||
.A:focus {}
|
||||
.A:focus:hover {}
|
||||
.A
|
||||
:foo {}
|
||||
:focus {}
|
||||
.A
|
||||
:foo:bar {}
|
||||
:focus:hover {}
|
||||
.A
|
||||
:foo
|
||||
:bar {}
|
||||
.A:foo-bar {}
|
||||
:focus
|
||||
:hover {}
|
||||
.A:enabled {}
|
||||
.A
|
||||
:foo-bar {}
|
||||
:enabled {}
|
||||
|
||||
#A:foo {}
|
||||
#A:foo:bar {}
|
||||
#A:focus {}
|
||||
#A:focus:hover {}
|
||||
#A
|
||||
:foo {}
|
||||
:focus {}
|
||||
#A
|
||||
:foo:bar {}
|
||||
:focus:hover {}
|
||||
#A
|
||||
:foo
|
||||
:bar {}
|
||||
#A:foo-bar {}
|
||||
:focus
|
||||
:hover {}
|
||||
#A:enabled {}
|
||||
#A
|
||||
:foo-bar {}
|
||||
:enabled {}
|
||||
|
||||
A1.A1.A1:foo {}
|
||||
A1.A1#A1:foo {}
|
||||
A1:foo.A1:foo#A1:foo {}
|
||||
A1.A1.A1:focus {}
|
||||
A1.A1#A1:focus {}
|
||||
A1:focus.A1:focus#A1:focus {}
|
||||
|
||||
/**********************************************************************/
|
||||
|
||||
|
||||
@@ -1226,3 +1226,21 @@ class TestTypeNames:
|
||||
stylesheet.add_source(f"StartType {separator} 1TestType {{}}")
|
||||
with pytest.raises(TokenError):
|
||||
stylesheet.parse()
|
||||
|
||||
|
||||
def test_parse_bad_psuedo_selector():
|
||||
"""Check unknown selector raises a token error."""
|
||||
|
||||
bad_selector = """\
|
||||
Widget:foo{
|
||||
border: red;
|
||||
}
|
||||
"""
|
||||
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(bad_selector, "foo")
|
||||
|
||||
with pytest.raises(TokenError) as error:
|
||||
stylesheet.parse()
|
||||
|
||||
assert error.value.start == (0, 6)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
@@ -354,6 +356,9 @@ async def test_compute():
|
||||
app.start = 10
|
||||
assert app.count_double == 14
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
app.count_double = 100
|
||||
|
||||
|
||||
async def test_watch_compute():
|
||||
"""Check that watching a computed attribute works."""
|
||||
@@ -386,3 +391,25 @@ async def test_watch_compute():
|
||||
assert app.show_ac is False
|
||||
|
||||
assert watch_called == [True, True, False, False, True, True, False, False]
|
||||
|
||||
|
||||
async def test_public_and_private_watch() -> None:
|
||||
"""If a reactive/var has public and private watches both should get called."""
|
||||
|
||||
calls: dict[str, bool] = {"private": False, "public": False}
|
||||
|
||||
class PrivateWatchTest(App):
|
||||
counter = var(0, init=False)
|
||||
|
||||
def watch_counter(self) -> None:
|
||||
calls["public"] = True
|
||||
|
||||
def _watch_counter(self) -> None:
|
||||
calls["private"] = True
|
||||
|
||||
async with PrivateWatchTest().run_test() as pilot:
|
||||
assert calls["private"] is False
|
||||
assert calls["public"] is False
|
||||
pilot.app.counter += 1
|
||||
assert calls["private"] is True
|
||||
assert calls["public"] is True
|
||||
|
||||
Reference in New Issue
Block a user