Skip to content

Commit

Permalink
Merge pull request Textualize#4953 from Textualize/screenshot-deliver
Browse files Browse the repository at this point in the history
added name to deliver
  • Loading branch information
willmcgugan authored Aug 29, 2024
2 parents cd8bbb3 + 914ec2a commit 112e8c6
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 113 deletions.
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ repos:
hooks:
- id: isort
name: isort (python)
language_version: '3.8'
language_version: '3.11'
args: ['--profile', 'black', '--filter-files']
- repo: https://github.com/psf/black
rev: '24.1.1'
Expand All @@ -31,6 +31,6 @@ repos:
rev: v2.3.0
hooks:
- id: pycln
language_version: '3.8'
language_version: '3.11'
args: [--all]
exclude: ^tests/snapshot_tests
102 changes: 77 additions & 25 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1027,27 +1027,11 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
"Maximize", "Maximize the focused widget", screen.action_maximize
)

# Don't save screenshot for web drivers until we have the deliver_file in place
if self._driver.__class__.__name__ in {"LinuxDriver", "WindowsDriver"}:

def export_screenshot() -> None:
"""Export a screenshot and write a notification."""
filename = self.save_screenshot()
try:
self.notify(f"Saved {filename}", title="Screenshot")
except Exception as error:
self.log.error(error)
self.notify(
"Failed to save screenshot.",
title="Screenshot",
severity="warning",
)

yield SystemCommand(
"Save screenshot",
"Save an SVG 'screenshot' of the current screen (in the current working directory)",
export_screenshot,
)
yield SystemCommand(
"Save screenshot",
"Save an SVG 'screenshot' of the current screen",
self.deliver_screenshot,
)

def get_default_screen(self) -> Screen:
"""Get the default screen.
Expand Down Expand Up @@ -1385,14 +1369,16 @@ def action_toggle_dark(self) -> None:
"""An [action](/guide/actions) to toggle dark mode."""
self.dark = not self.dark

def action_screenshot(self, filename: str | None = None, path: str = "./") -> None:
def action_screenshot(
self, filename: str | None = None, path: str | None = None
) -> None:
"""This [action](/guide/actions) will save an SVG file containing the current contents of the screen.
Args:
filename: Filename of screenshot, or None to auto-generate.
path: Path to directory. Defaults to current working directory.
path: Path to directory. Defaults to the user's Downloads directory.
"""
self.save_screenshot(filename, path)
self.deliver_screenshot(filename, path)

def export_screenshot(self, *, title: str | None = None) -> str:
"""Export an SVG screenshot of the current screen.
Expand Down Expand Up @@ -1451,6 +1437,42 @@ def save_screenshot(
svg_file.write(screenshot_svg)
return svg_path

def deliver_screenshot(
self,
filename: str | None = None,
path: str | None = None,
time_format: str | None = None,
) -> str | None:
"""Deliver a screenshot of the app.
This with save the screenshot when running locally, or serve it when the app
is running in a web browser.
Args:
filename: Filename of SVG screenshot, or None to auto-generate
a filename with the date and time.
path: Path to directory for output when saving locally (not used when app is running in the browser).
Defaults to current working directory.
time_format: Date and time format to use if filename is None.
Defaults to a format like ISO 8601 with some reserved characters replaced with underscores.
Returns:
The delivery key that uniquely identifies the file delivery.
"""
if not filename:
svg_filename = generate_datetime_filename(self.title, ".svg", time_format)
else:
svg_filename = filename
screenshot_svg = self.export_screenshot()
return self.deliver_text(
io.StringIO(screenshot_svg),
save_directory=path,
save_filename=svg_filename,
open_method="browser",
mime_type="image/svg+xml",
name="screenshot",
)

def bind(
self,
keys: str,
Expand Down Expand Up @@ -3926,6 +3948,7 @@ def deliver_text(
open_method: Literal["browser", "download"] = "download",
encoding: str | None = None,
mime_type: str | None = None,
name: str | None = None,
) -> str | None:
"""Deliver a text file to the end-user of the application.
Expand Down Expand Up @@ -3956,6 +3979,8 @@ def deliver_text(
mime_type: The MIME type of the file or None to guess based on file extension.
If no MIME type is supplied and we cannot guess the MIME type, from the
file extension, the MIME type will be set to "text/plain".
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
and [`DeliveryComplete`][textual.events.DeliveryComplete].
Returns:
The delivery key that uniquely identifies the file delivery.
Expand Down Expand Up @@ -3985,6 +4010,7 @@ def deliver_text(
open_method=open_method,
encoding=encoding,
mime_type=mime_type,
name=name,
)

def deliver_binary(
Expand All @@ -3995,6 +4021,7 @@ def deliver_binary(
save_filename: str | None = None,
open_method: Literal["browser", "download"] = "download",
mime_type: str | None = None,
name: str | None = None,
) -> str | None:
"""Deliver a binary file to the end-user of the application.
Expand Down Expand Up @@ -4033,6 +4060,8 @@ def deliver_binary(
mime_type: The MIME type of the file or None to guess based on file extension.
If no MIME type is supplied and we cannot guess the MIME type, from the
file extension, the MIME type will be set to "application/octet-stream".
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
and [`DeliveryComplete`][textual.events.DeliveryComplete].
Returns:
The delivery key that uniquely identifies the file delivery.
Expand Down Expand Up @@ -4061,6 +4090,7 @@ def deliver_binary(
open_method=open_method,
mime_type=mime_type,
encoding=None,
name=name,
)

def _deliver_binary(
Expand All @@ -4072,10 +4102,11 @@ def _deliver_binary(
open_method: Literal["browser", "download"],
encoding: str | None = None,
mime_type: str | None = None,
name: str | None = None,
) -> str | None:
"""Deliver a binary file to the end-user of the application."""
if self._driver is None:
return
return None

# Generate a filename if the file-like object doesn't have one.
if save_filename is None:
Expand All @@ -4099,6 +4130,27 @@ def _deliver_binary(
encoding=encoding,
open_method=open_method,
mime_type=mime_type,
name=name,
)

return delivery_key

@on(events.DeliveryComplete)
def _on_delivery_complete(self, event: events.DeliveryComplete) -> None:
"""Handle a successfully delivered screenshot."""
if event.name == "screenshot":
if event.path is None:
self.notify("Saved screenshot", title="Screenshot")
else:
self.notify(
f"Saved screenshot to [green]{str(event.path)!r}",
title="Screenshot",
)

@on(events.DeliveryFailed)
def _on_delivery_failed(self, event: events.DeliveryComplete) -> None:
"""Handle a failure to deliver the screenshot."""
if event.name == "screenshot":
self.notify(
"Failed to save screenshot", title="Screenshot", severity="error"
)
22 changes: 16 additions & 6 deletions src/textual/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ def deliver_binary(
open_method: Literal["browser", "download"] = "download",
encoding: str | None = None,
mime_type: str | None = None,
name: str | None = None,
) -> None:
"""Save the file `path_or_file` to `save_path`.
Expand All @@ -227,6 +228,9 @@ def deliver_binary(
in the `Content-Type` header.
mime_type: *web only* The MIME type of the file. This will be used to
set the `Content-Type` header in the HTTP response.
name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete]
and [`DeliveryComplete`][textual.events.DeliveryComplete].
"""

def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
Expand All @@ -239,7 +243,9 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
data = read(chunk_size)
if not data:
# No data left to read - delivery is complete.
self._delivery_complete(delivery_key, save_path)
self._delivery_complete(
delivery_key, save_path=save_path, name=name
)
break
write(data)
except Exception as error:
Expand All @@ -249,7 +255,7 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
import traceback

log.error(str(traceback.format_exc()))
self._delivery_failed(delivery_key, exception=error)
self._delivery_failed(delivery_key, exception=error, name=name)
finally:
if not binary.closed:
binary.close()
Expand All @@ -262,22 +268,26 @@ def save_file_thread(binary: BinaryIO | TextIO, mode: str) -> None:
thread = threading.Thread(target=save_file_thread, args=(binary, mode))
thread.start()

def _delivery_complete(self, delivery_key: str, save_path: Path | None) -> None:
def _delivery_complete(
self, delivery_key: str, save_path: Path | None, name: str | None
) -> None:
"""Called when a file has been delivered successfully.
Delivers a DeliveryComplete event to the app.
"""
self._app.call_from_thread(
self._app.post_message,
events.DeliveryComplete(key=delivery_key, path=save_path),
events.DeliveryComplete(key=delivery_key, path=save_path, name=name),
)

def _delivery_failed(self, delivery_key: str, exception: BaseException) -> None:
def _delivery_failed(
self, delivery_key: str, exception: BaseException, name: str | None
) -> None:
"""Called when a file delivery fails.
Delivers a DeliveryFailed event to the app.
"""
self._app.call_from_thread(
self._app.post_message,
events.DeliveryFailed(key=delivery_key, exception=exception),
events.DeliveryFailed(key=delivery_key, exception=exception, name=name),
)
7 changes: 5 additions & 2 deletions src/textual/drivers/web_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:
)
else:
# Read the requested amount of data from the file
name = payload.get("name", None)
try:
log.debug(f"Reading {requested_size} bytes from {delivery_key}")
chunk = file_like.read(requested_size)
Expand All @@ -269,7 +270,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:
log.info(f"Delivery complete for {delivery_key}")
file_like.close()
del deliveries[delivery_key]
self._delivery_complete(delivery_key, save_path=None)
self._delivery_complete(delivery_key, save_path=None, name=name)
except Exception as error:
file_like.close()
del deliveries[delivery_key]
Expand All @@ -282,7 +283,7 @@ def on_meta(self, packet_type: str, payload: dict[str, object]) -> None:

log.error(str(traceback.format_exc()))

self._delivery_failed(delivery_key, exception=error)
self._delivery_failed(delivery_key, exception=error, name=name)

def open_url(self, url: str, new_tab: bool = True) -> None:
"""Open a URL in the default web browser.
Expand Down Expand Up @@ -321,6 +322,7 @@ def _deliver_file(
open_method: Literal["browser", "download"],
encoding: str | None = None,
mime_type: str | None = None,
name: str | None = None,
) -> None:
"""Deliver a file to the end-user of the application."""
binary.seek(0)
Expand All @@ -335,6 +337,7 @@ def _deliver_file(
"open_method": open_method,
"encoding": encoding or "",
"mime_type": mime_type or "",
"name": name,
}
self.write_meta(meta)
log.info(f"Delivering file {meta['path']!r}: {meta!r}")
6 changes: 6 additions & 0 deletions src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,9 @@ class DeliveryComplete(Event, bubble=False):
example if the file was delivered via web browser.
"""

name: str | None = None
"""Optional name returned to the app to identify the download."""


@dataclass
class DeliveryFailed(Event, bubble=False):
Expand All @@ -760,3 +763,6 @@ class DeliveryFailed(Event, bubble=False):

exception: BaseException
"""The exception that was raised during the delivery."""

name: str | None = None
"""Optional name returned to the app to identify the download."""
Loading

0 comments on commit 112e8c6

Please sign in to comment.