Copy & Share — Capacidades Nativas 📋¶
Acesse o clipboard e o share sheet do sistema operacional direto do Python tipado — e aprenda como o tempestweb conecta código Python a APIs nativas do browser.
O que você vai construir¶
Um demo de cópia e compartilhamento com:
- 📄 Um trecho de texto exibido na tela
- 📋 Botão Copy que escreve o texto no clipboard do SO
- 🔗 Botão Share que abre o share sheet nativo do browser
- ⏳ Um Spinner exibido enquanto a operação está em andamento
- 💬 Texto de status que reflete o resultado: copiado, compartilhado, cancelado, não suportado ou erro
Nota — o que são capacidades nativas?
Capacidades nativas são Web APIs do browser (Clipboard API, Web Share API, Geolocation API etc.) acessadas a partir do Python tipado. O tempestweb roteia cada chamada para o browser correto — seja o browser rodando Python diretamente (Modo A / WASM) ou o browser separado do servidor Python (Modo B / WebSocket).
Pré-requisitos¶
Certifique-se de ter o tempestweb instalado:
Leitura recomendada (opcional):
- Tutorial básico — primeiros passos com
App,vieweset_state - Gerenciando estado — como
set_statefunciona - Modos de execução — WASM vs. servidor
Criando o projeto¶
Crie a pasta e o arquivo do app:
Passo 1 — Entendendo a bridge nativa¶
Antes de escrever código, é importante entender por que existe uma bridge.
O Python não tem acesso direto ao clipboard ou ao share sheet — esses são recursos do browser. O tempestweb resolve isso através de uma NativeBridge, que é a única diferença entre os dois modos de execução:
| Modo | Bridge instalada | Como funciona |
|---|---|---|
| A (WASM) | FFIBridge |
Chama client/native/*.js diretamente, in-process, sem rede |
| B (servidor) | ProxyBridge |
Serializa a chamada, envia ao browser pelo WebSocket, aguarda o resultado |
Atenção — bridge obrigatória em runtime
As funções clipboard.write e share.share lançam BrowserUnavailableError se nenhuma bridge estiver instalada no momento da chamada. Em runtime (Modo A ou B) a bridge é instalada automaticamente pelo bootstrap do tempestweb. Você não precisa chamar install_bridge na sua aplicação. Você só chama install_bridge / uninstall_bridge em testes para injetar uma bridge falsa.
No diagrama abaixo, o NativeBridge é a única peça que muda entre os modos — a função view não sabe e não precisa saber qual bridge está instalada:
view(app)
│
└── await clipboard.write(text)
│
└── send_native_call("clipboard.write", ...)
│
└── current_bridge().call(envelope) ← SEAM
│
┌───────┴────────┐
│ │
FFIBridge ProxyBridge
(Modo A: in-proc) (Modo B: WebSocket)
│ │
client/native/ client/native/
clipboard.js clipboard.js
│ │
navigator.clipboard.writeText(...)
Passo 2 — Definindo os tipos e o estado¶
O exemplo usa injeção de dependência para que os testes possam trocar as funções reais por fakes.
Definimos dois tipos de callable — Copier e Sharer — e armazenamos a função concreta como campo do estado:
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import StrEnum
from tempestweb._core import App, Style, Widget
from tempestweb._core.style import Edge
from tempestweb._core.widgets import Button, Column, Row, Spinner, Text
from tempestweb.native import clipboard
from tempestweb.native.share import ShareOutcome, ShareResult
from tempestweb.native.share import share as _native_share
# ---------------------------------------------------------------------------
# Injected capability types
# ---------------------------------------------------------------------------
#: A coroutine that writes text to the clipboard. Injected for testability.
Copier = Callable[[str], Awaitable[None]]
#: A coroutine that opens the share sheet. Injected for testability.
Sharer = Callable[..., Awaitable[ShareResult]]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
#: The snippet shown to the user and offered for copy / share.
SNIPPET: str = "tempestweb — write UIs in typed Python, run them everywhere."
class Phase(StrEnum):
"""Lifecycle phase of the clipboard-share interaction.
Attributes:
IDLE: Nothing has been attempted yet.
BUSY: A capability call is in flight.
COPIED: The clipboard write succeeded.
SHARED: The share sheet completed.
ERROR: The capability raised an error.
"""
IDLE = "idle"
BUSY = "busy"
COPIED = "copied"
SHARED = "shared"
ERROR = "error"
@dataclass
class ClipShareState:
"""Application state for the clipboard-share demo.
Attributes:
phase: Current lifecycle phase.
share_outcome: The ShareOutcome from the last share attempt, or
None if no share has been tried.
error: Human-readable error message shown when phase is ERROR.
copy: Injected clipboard-write coroutine (real default is the native cap).
share_fn: Injected share coroutine (real default is the native cap).
"""
phase: Phase = Phase.IDLE
share_outcome: ShareOutcome | None = None
error: str = ""
copy: Copier = field(default=clipboard.write)
share_fn: Sharer = field(default=_native_share)
def make_state() -> ClipShareState:
"""Build the initial, idle clipboard-share state.
Returns:
A fresh ClipShareState.
"""
return ClipShareState()
Dica — injeção de dependência via campo do dataclass
Ao guardar copy e share_fn como campos com defaults reais, você consegue duas coisas ao mesmo tempo:
- Em produção,
make_state()cria um estado com as funções nativas reais — zero configuração extra. - Em testes, você substitui os callables por fakes sem precisar de monkey-patching: basta passar
copy=fake_copyao construir o estado.
Esse padrão é especialmente valioso quando a função real precisaria de uma bridge instalada para não lançar exceção.
Veja como a máquina de estados evolui conforme o usuário age:
IDLE ──────── clicar Copy ──────► BUSY ──── sucesso ──► COPIED
│ │
│ clicar Share └──── erro ──────► ERROR
└─────────────────────────────► BUSY ──── sucesso ──► SHARED
└──── erro ──────► ERROR
Passo 3 — Os handlers assíncronos¶
Os handlers ficam dentro de view() porque precisam capturar o app do escopo externo. Cada um segue o mesmo padrão de três etapas:
- Transiciona para
BUSYimediatamente (feedback visual). awaita capacidade nativa.- Transiciona para o estado final (
COPIED,SHAREDouERROR).
async def do_copy() -> None:
"""Copy the snippet to the OS clipboard.
Transitions: IDLE/ERROR -> BUSY -> COPIED | ERROR.
"""
app.set_state(lambda s: setattr(s, "phase", Phase.BUSY))
try:
await app.state.copy(SNIPPET)
except Exception as exc: # noqa: BLE001 — surface to UI
msg = str(exc)
def _on_copy_error(s: ClipShareState) -> None:
s.phase = Phase.ERROR
s.error = msg
app.set_state(_on_copy_error)
return
app.set_state(lambda s: setattr(s, "phase", Phase.COPIED))
async def do_share() -> None:
"""Open the OS share sheet.
Transitions: IDLE/ERROR -> BUSY -> SHARED (outcome stored) | ERROR.
"""
app.set_state(lambda s: setattr(s, "phase", Phase.BUSY))
try:
result: ShareResult = await app.state.share_fn(
title="tempestweb",
text=SNIPPET,
url="https://github.com/tempest-framework/tempestweb",
)
except Exception as exc: # noqa: BLE001 — surface to UI
msg = str(exc)
def _on_share_error(s: ClipShareState) -> None:
s.phase = Phase.ERROR
s.error = msg
app.set_state(_on_share_error)
return
def _on_shared(s: ClipShareState) -> None:
s.phase = Phase.SHARED
s.share_outcome = result.outcome
app.set_state(_on_shared)
Por que capturar Exception genérica aqui?
Em produção a função de share pode lançar NativeError (da bridge) ou qualquer exceção de rede. Capturar Exception e exibir a mensagem na UI é o comportamento correto para um demo: o usuário vê o que aconteceu. Em apps de produção você pode ser mais seletivo e separar os tipos de erro.
Dica — ShareOutcome não é uma exceção
ShareOutcome.CANCELLED e ShareOutcome.UNSUPPORTED são retornados como valores normais dentro de ShareResult, nunca como exceções. A Web Share API degrada graciosamente: se o browser não suporta navigator.share, o JS retorna {"outcome": "unsupported"} em vez de lançar um erro.
Passo 4 — O texto de status¶
O texto de status é derivado do phase e do share_outcome atuais. É calculado dentro de view() a cada render — zero estado extra:
phase = app.state.phase
if phase is Phase.IDLE:
status_text = "Choose an action below."
elif phase is Phase.BUSY:
status_text = "Working…"
elif phase is Phase.COPIED:
status_text = "Copied to clipboard!"
elif phase is Phase.SHARED:
outcome = app.state.share_outcome
if outcome is ShareOutcome.SHARED:
status_text = "Shared successfully."
elif outcome is ShareOutcome.CANCELLED:
status_text = "Share cancelled."
else:
# UNSUPPORTED — Web Share API missing in this browser
status_text = "Sharing is not supported in this browser."
else:
# ERROR
status_text = f"Error: {app.state.error}"
phase |
share_outcome |
Texto exibido |
|---|---|---|
IDLE |
— | Choose an action below. |
BUSY |
— | Working… |
COPIED |
— | Copied to clipboard! |
SHARED |
SHARED |
Shared successfully. |
SHARED |
CANCELLED |
Share cancelled. |
SHARED |
UNSUPPORTED |
Sharing is not supported in this browser. |
ERROR |
— | Error: <mensagem> |
Passo 5 — Montando a árvore de widgets¶
A linha de ação exibe um Spinner quando BUSY, ou os dois botões nos outros estados. Isso elimina cliques duplos sem precisar de um campo disabled separado:
is_busy = phase is Phase.BUSY
action_children: list[Widget] = []
if is_busy:
action_children.append(Spinner(key="spinner"))
else:
action_children.extend(
[
Button(
label="Copy",
on_click=do_copy,
key="copy-btn",
),
Button(
label="Share",
on_click=do_share,
key="share-btn",
),
]
)
actions: Widget = Row(
style=Style(gap=8.0),
children=action_children,
key="actions",
)
return Column(
style=Style(gap=16.0, padding=Edge.all(20.0)),
children=[
Text(content="Copy & Share", style=Style(font_size=22.0), key="title"),
Text(
content=SNIPPET,
style=Style(font_size=14.0),
key="snippet",
),
actions,
Text(content=status_text, key="status"),
],
)
Dica — Spinner como proteção contra duplo clique
Substituir os botões por um Spinner durante BUSY é uma proteção natural: não há botão para clicar, logo não há como disparar uma segunda operação simultânea. É mais simples e mais seguro do que manter um campo booleano loading separado do phase.
O app completo¶
Aqui está o arquivo completo, pronto para copiar:
"""Copy & share — exercises the clipboard and share native capabilities.
Like :mod:`examples.counter.app`, this exact ``view`` runs unchanged in both modes::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
The demo presents a short text snippet alongside two action buttons:
* **Copy** — writes the snippet to the OS clipboard via
``native.clipboard.write``. The action status is stored in state so the UI
reflects whether the write succeeded, failed or is still pending.
* **Share** — opens the platform share sheet via ``native.share.share`` and
renders the :class:`~tempestweb.native.share.ShareOutcome` back to the user:
``shared``, ``cancelled``, or ``unsupported`` (the API does not exist in
the current browser).
Both capability callables are **injected into** :class:`ClipShareState` with
real defaults so that:
1. ``build(view(app))`` is green with **no bridge installed** — the initial
mount only reads state; it never calls the capabilities.
2. Tests swap in a ``FakeBridge`` and drive the async handlers end-to-end,
asserting real state transitions.
State machine
-------------
* ``Phase.IDLE`` — nothing has been attempted yet.
* ``Phase.BUSY`` — a capability call is in flight (spinner or disabled feedback).
* ``Phase.COPIED`` — clipboard write succeeded.
* ``Phase.SHARED`` — share sheet completed (outcome stored separately).
* ``Phase.ERROR`` — the capability raised :class:`~tempestweb.native.NativeError`.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import StrEnum
from tempestweb._core import App, Style, Widget
from tempestweb._core.style import Edge
from tempestweb._core.widgets import Button, Column, Row, Spinner, Text
from tempestweb.native import clipboard
from tempestweb.native.share import ShareOutcome, ShareResult
from tempestweb.native.share import share as _native_share
# ---------------------------------------------------------------------------
# Injected capability types
# ---------------------------------------------------------------------------
#: A coroutine that writes text to the clipboard. Injected for testability.
Copier = Callable[[str], Awaitable[None]]
#: A coroutine that opens the share sheet. Injected for testability.
Sharer = Callable[..., Awaitable[ShareResult]]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
#: The snippet shown to the user and offered for copy / share.
SNIPPET: str = "tempestweb — write UIs in typed Python, run them everywhere."
class Phase(StrEnum):
"""Lifecycle phase of the clipboard-share interaction.
Attributes:
IDLE: Nothing has been attempted yet.
BUSY: A capability call is in flight.
COPIED: The clipboard write succeeded.
SHARED: The share sheet completed.
ERROR: The capability raised an error.
"""
IDLE = "idle"
BUSY = "busy"
COPIED = "copied"
SHARED = "shared"
ERROR = "error"
@dataclass
class ClipShareState:
"""Application state for the clipboard-share demo.
Attributes:
phase: Current lifecycle phase.
share_outcome: The :class:`~tempestweb.native.share.ShareOutcome` from
the last share attempt, or ``None`` if no share has been tried.
error: Human-readable error message shown when ``phase`` is ERROR.
copy: Injected clipboard-write coroutine (real default is the native cap).
share_fn: Injected share coroutine (real default is the native cap).
"""
phase: Phase = Phase.IDLE
share_outcome: ShareOutcome | None = None
error: str = ""
copy: Copier = field(default=clipboard.write)
share_fn: Sharer = field(default=_native_share)
def make_state() -> ClipShareState:
"""Build the initial, idle clipboard-share state.
Returns:
A fresh :class:`ClipShareState`.
"""
return ClipShareState()
# ---------------------------------------------------------------------------
# View
# ---------------------------------------------------------------------------
def view(app: App[ClipShareState]) -> Widget:
"""Render the clipboard-share UI from the current state.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The widget tree for the current state.
"""
# ------------------------------------------------------------------
# Async handlers
# ------------------------------------------------------------------
async def do_copy() -> None:
"""Copy the snippet to the OS clipboard.
Transitions: IDLE/ERROR -> BUSY -> COPIED | ERROR.
"""
app.set_state(lambda s: setattr(s, "phase", Phase.BUSY))
try:
await app.state.copy(SNIPPET)
except Exception as exc: # noqa: BLE001 — surface to UI
msg = str(exc)
def _on_copy_error(s: ClipShareState) -> None:
s.phase = Phase.ERROR
s.error = msg
app.set_state(_on_copy_error)
return
app.set_state(lambda s: setattr(s, "phase", Phase.COPIED))
async def do_share() -> None:
"""Open the OS share sheet.
Transitions: IDLE/ERROR -> BUSY -> SHARED (outcome stored) | ERROR.
"""
app.set_state(lambda s: setattr(s, "phase", Phase.BUSY))
try:
result: ShareResult = await app.state.share_fn(
title="tempestweb",
text=SNIPPET,
url="https://github.com/tempest-framework/tempestweb",
)
except Exception as exc: # noqa: BLE001 — surface to UI
msg = str(exc)
def _on_share_error(s: ClipShareState) -> None:
s.phase = Phase.ERROR
s.error = msg
app.set_state(_on_share_error)
return
def _on_shared(s: ClipShareState) -> None:
s.phase = Phase.SHARED
s.share_outcome = result.outcome
app.set_state(_on_shared)
# ------------------------------------------------------------------
# Status text — reflects the last action
# ------------------------------------------------------------------
phase = app.state.phase
if phase is Phase.IDLE:
status_text = "Choose an action below."
elif phase is Phase.BUSY:
status_text = "Working…"
elif phase is Phase.COPIED:
status_text = "Copied to clipboard!"
elif phase is Phase.SHARED:
outcome = app.state.share_outcome
if outcome is ShareOutcome.SHARED:
status_text = "Shared successfully."
elif outcome is ShareOutcome.CANCELLED:
status_text = "Share cancelled."
else:
# UNSUPPORTED — Web Share API missing in this browser
status_text = "Sharing is not supported in this browser."
else:
# ERROR
status_text = f"Error: {app.state.error}"
# ------------------------------------------------------------------
# Action buttons row
# ------------------------------------------------------------------
is_busy = phase is Phase.BUSY
action_children: list[Widget] = []
if is_busy:
action_children.append(Spinner(key="spinner"))
else:
action_children.extend(
[
Button(
label="Copy",
on_click=do_copy,
key="copy-btn",
),
Button(
label="Share",
on_click=do_share,
key="share-btn",
),
]
)
actions: Widget = Row(
style=Style(gap=8.0),
children=action_children,
key="actions",
)
# ------------------------------------------------------------------
# Assemble the full view
# ------------------------------------------------------------------
return Column(
style=Style(gap=16.0, padding=Edge.all(20.0)),
children=[
Text(content="Copy & Share", style=Style(font_size=22.0), key="title"),
Text(
content=SNIPPET,
style=Style(font_size=14.0),
key="snippet",
),
actions,
Text(content=status_text, key="status"),
],
)
Rodando o exemplo ▶¶
Modo A — Python no browser (Pyodide / WASM)¶
Python roda dentro do browser via Pyodide. A FFIBridge é instalada automaticamente pelo bootstrap do WASM e chama client/native/clipboard.js e client/native/share.js in-process.
Modo B — Python no servidor (FastAPI + WebSocket)¶
Python roda no servidor; a ProxyBridge é instalada automaticamente pela sessão WebSocket. Cada chamada a clipboard.write ou share.share vai até o browser pelo WebSocket, o JS executa a Web API, e o resultado volta para o Python pelo mesmo canal.
Verificação
Em qualquer modo, você deve ver:
- Título Copy & Share e o snippet de texto
- Dois botões: Copy e Share
- Texto de status inicial:
Choose an action below. - Clicar Copy → botões somem, spinner aparece, depois:
Copied to clipboard! - Clicar Share → spinner → share sheet nativo do browser →
Shared successfully.(ouShare cancelled.se fechar sem compartilhar) - Em browsers sem Web Share API (ex.: Firefox desktop) →
Sharing is not supported in this browser.
Atenção — contexto seguro
A Clipboard API e a Web Share API exigem HTTPS (ou localhost). Ao rodar em localhost com o dev server do tempestweb, tudo funciona. Em produção, certifique-se de servir sob HTTPS, caso contrário a bridge retorna um NativeError com código insecure_context.
Testando com bridges falsas 🧪¶
Como os handlers chamam capacidades nativas, os testes não podem simplesmente importar e chamar view() e esperar que tudo funcione — precisariam de uma bridge real (e de um browser). A solução é a injeção de dependência: você instala uma bridge falsa antes do teste e a remove depois.
FakeBridge — comportamento programado¶
from typing import Any
from tempestweb.native import install_bridge, uninstall_bridge
class FakeBridge:
"""Fake native bridge for clipboard and share capabilities.
Records the last envelope received and returns scripted responses so the
tests run with no real browser present.
Attributes:
share_outcome: The share outcome string to return (default "shared").
calls: Ordered list of capability names that were dispatched.
"""
def __init__(self, *, share_outcome: str = "shared") -> None:
"""Initialise the bridge.
Args:
share_outcome: The ShareOutcome value to return from share.share.
"""
self.share_outcome: str = share_outcome
self.calls: list[str] = []
async def call(self, envelope: dict[str, Any]) -> dict[str, Any]:
"""Handle a native capability call.
Args:
envelope: The native_call envelope dispatched by the capability wrapper.
Returns:
A scripted ok / value response dict.
"""
cap: str = envelope["capability"]
self.calls.append(cap)
if cap == "clipboard.write":
return {"ok": True, "value": {}}
if cap == "share.share":
return {"ok": True, "value": {"outcome": self.share_outcome}}
return {"ok": False, "error": "unavailable", "message": f"no fake for {cap}"}
ErrorBridge — simula falha de permissão¶
class ErrorBridge:
"""Fake bridge that always returns an error response.
Used to verify that the ERROR phase is surfaced correctly in the UI.
"""
async def call(self, envelope: dict[str, Any]) -> dict[str, Any]:
"""Return a permission_denied error for every call.
Args:
envelope: Ignored; every call returns an error.
Returns:
An ok: False response.
"""
return {
"ok": False,
"error": "permission_denied",
"message": "permission denied by user",
}
Os 8 testes¶
O suite completo de testes do exemplo cobre todos os caminhos da máquina de estados:
from __future__ import annotations
from typing import Any
import pytest
from tempestweb._core import App, Node, build
from tempestweb.native import install_bridge, uninstall_bridge
from tempestweb.native.share import ShareOutcome
def _walk(node: Node) -> list[Node]:
"""Flatten an IR tree into a list of nodes (pre-order).
Args:
node: The root node.
Returns:
Every node in the subtree, root first.
"""
nodes: list[Node] = [node]
for child in node.children:
nodes.extend(_walk(child))
return nodes
def _find_handler(widget: Any, key: str, attr: str) -> Any: # noqa: ANN401
"""Locate a handler callable by widget key and attribute name.
Args:
widget: The root widget returned by view(app).
key: The key of the target widget.
attr: The handler attribute name (e.g. "on_click").
Returns:
The handler callable.
Raises:
AssertionError: If no matching widget/handler is found.
"""
stack: list[Any] = [widget]
while stack:
current = stack.pop()
if getattr(current, "key", None) == key:
handler = getattr(current, attr, None)
if handler is not None:
return handler
children = getattr(current, "children", None)
if children:
stack.extend(children)
raise AssertionError(f"no widget with key={key!r} and handler {attr!r}")
def _status_text(node: Node) -> str:
"""Return the content prop of the status Text node.
Args:
node: The root IR node of the built tree.
Returns:
The status text string.
Raises:
AssertionError: If no status node is found.
"""
for n in _walk(node):
if n.key == "status":
return str(n.props.get("content", ""))
raise AssertionError("no node with key='status' found")
# -- Fixtures ----------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clean_bridge() -> Any: # noqa: ANN401
"""Guarantee no bridge leaks between tests."""
uninstall_bridge()
yield
uninstall_bridge()
# -- Testes ------------------------------------------------------------------
def test_initial_build_no_bridge(module: Any, app: App[Any]) -> None:
"""build(view(app)) yields a valid Node tree with no bridge installed."""
node = build(module.view(app))
assert isinstance(node, Node)
assert node.type
assert node.children
def test_initial_status_is_idle(module: Any, app: App[Any]) -> None:
"""The status Text reflects the IDLE phase on first mount."""
node = build(module.view(app))
assert _status_text(node) == "Choose an action below."
async def test_copy_handler_transitions_to_copied(module: Any, app: App[Any]) -> None:
"""Driving do_copy with a fake bridge transitions IDLE -> COPIED."""
bridge = FakeBridge()
install_bridge(bridge)
idle_node = build(module.view(app))
handler = _find_handler(module.view(app), "copy-btn", "on_click")
await handler()
assert app.state.phase.value == "copied"
copied_node = build(module.view(app))
assert _status_text(copied_node) == "Copied to clipboard!"
assert _status_text(copied_node) != _status_text(idle_node)
assert "clipboard.write" in bridge.calls
async def test_share_handler_shared_outcome(module: Any, app: App[Any]) -> None:
"""Driving do_share with outcome 'shared' transitions IDLE -> SHARED."""
bridge = FakeBridge(share_outcome="shared")
install_bridge(bridge)
handler = _find_handler(module.view(app), "share-btn", "on_click")
await handler()
assert app.state.phase.value == "shared"
assert app.state.share_outcome is ShareOutcome.SHARED
node = build(module.view(app))
assert _status_text(node) == "Shared successfully."
assert "share.share" in bridge.calls
async def test_share_handler_cancelled_outcome(module: Any, app: App[Any]) -> None:
"""A cancelled share sheet transitions to SHARED with CANCELLED outcome."""
install_bridge(FakeBridge(share_outcome="cancelled"))
handler = _find_handler(module.view(app), "share-btn", "on_click")
await handler()
assert app.state.share_outcome is ShareOutcome.CANCELLED
node = build(module.view(app))
assert _status_text(node) == "Share cancelled."
async def test_share_handler_unsupported_outcome(module: Any, app: App[Any]) -> None:
"""An unsupported browser returns UNSUPPORTED outcome without raising."""
install_bridge(FakeBridge(share_outcome="unsupported"))
handler = _find_handler(module.view(app), "share-btn", "on_click")
await handler()
assert app.state.share_outcome is ShareOutcome.UNSUPPORTED
node = build(module.view(app))
assert "not supported" in _status_text(node)
async def test_copy_error_transitions_to_error_phase(
module: Any, app: App[Any]
) -> None:
"""A NativeError during clipboard.write transitions to the ERROR phase."""
install_bridge(ErrorBridge())
handler = _find_handler(module.view(app), "copy-btn", "on_click")
await handler()
assert app.state.phase.value == "error"
node = build(module.view(app))
status = _status_text(node)
assert status.startswith("Error:")
async def test_tree_changes_between_idle_and_copied(module: Any, app: App[Any]) -> None:
"""The rebuilt tree differs after a successful copy (diff-friendly)."""
from tempestweb._core import diff
install_bridge(FakeBridge())
before = build(module.view(app))
handler = _find_handler(module.view(app), "copy-btn", "on_click")
await handler()
after = build(module.view(app))
patches = diff(before, after)
assert patches, "expected at least one patch after a state transition"
Dica — autouse=True no _clean_bridge
O fixture _clean_bridge limpa a bridge antes e depois de cada teste usando autouse=True. Isso garante que um teste que esqueceu de desinstalar a bridge não contamina o próximo. É uma boa prática em qualquer suite que use install_bridge.
Verificação automatizada ✅¶
Rode os quatro checks antes de commitar:
# Lint
ruff check .
# Formatação
ruff format --check .
# Tipos
mypy --strict tempestweb
# Testes
pytest -q
Todos devem passar em verde. O exemplo foi especificamente projetado para ser mypy --strict clean — toda variável, parâmetro e retorno é anotado explicitamente.
Como funciona por dentro¶
O ciclo completo de uma chamada nativa¶
1. Usuário clica "Copy"
│
▼
2. do_copy() chamada
│
▼
3. app.set_state(phase=BUSY) → re-render → Spinner aparece
│
▼
4. await app.state.copy(SNIPPET)
│ (= clipboard.write em produção)
│
▼
5. send_native_call("clipboard.write", {"text": SNIPPET})
│
▼
6. current_bridge().call(envelope)
│
┌────┴──────────────────────────────┐
│ Modo A: FFIBridge │ Modo B: ProxyBridge
│ chama JS in-process │ envia frame pelo WS
│ (sem rede, sem round-trip) │ aguarda native_result
└────────────────────────────────────┘
│
▼
7. navigator.clipboard.writeText(SNIPPET) [no browser]
│
▼
8. Resultado volta para o Python
│
▼
9. app.set_state(phase=COPIED) → re-render → "Copied to clipboard!"
Por que Phase é uma StrEnum?¶
StrEnum permite comparar app.state.phase.value == "copied" nos testes (string legível) e usar phase is Phase.COPIED na view (comparação de identidade, zero alocação). O valor string também é serializável nativamente em JSON — útil para logging e telemetria.
Por que os handlers são async?¶
Capacidades nativas são operações de I/O: o Python precisa suspender enquanto o browser executa a Web API e retorna o resultado. Usar await é o caminho natural — o event loop asyncio do tempestweb gerencia a suspensão e retomada sem bloquear outros renders em andamento.
Recapitulando¶
Neste tutorial você aprendeu:
- ✅ O que é uma
NativeBridgee por que ela é a única diferença entre Modo A e Modo B - ✅ Como usar
clipboard.writeeshare.sharea partir do Python tipado - ✅ Como modelar um fluxo assíncrono com
Phase(IDLE → BUSY → COPIED/SHARED/ERROR) - ✅ Como injetar capacidades no estado para tornar os handlers testáveis sem browser
- ✅ Como usar
FakeBridgeeErrorBridgepara testar todos os caminhos da máquina de estados - ✅ Por que
ShareOutcome.CANCELLEDeShareOutcome.UNSUPPORTEDsão valores normais, não exceções
Próximos passos¶
Experimente estender o exemplo:
- 💡 Adicione um botão Read que lê o texto atual do clipboard com
clipboard.read() - 💡 Desabilite o botão Share com
is_share_supported()quando a Web Share API não estiver disponível, em vez de mostrar mensagem após o clique - 💡 Explore o exemplo PWA + WebPush para ver outras capacidades nativas em ação
- 💡 Veja Modos de execução para entender em profundidade como
FFIBridgeeProxyBridgediferem