Skip to content

Commit f02b05b

Browse files
authored
Merge pull request #178 from plotly/andrew/refactor
Andrew/refactor A lot here, some kind of spooky behavior with pytest. In general, reflects small changes to the api: There is now an `open()` function so you don't have to `await` the constructure, `BrowserSync` has been factored out.
2 parents 6b44fde + 2bef2d9 commit f02b05b

File tree

116 files changed

+30781
-1945
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

116 files changed

+30781
-1945
lines changed

.github/workflows/publish_testpypi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
# don't modify sync file! messes up version!
2929
- run: uv sync --all-extras --frozen # does order matter?
3030
- run: uv build
31-
- run: uv run --no-sync choreo_get_browser -v --i ${{ matrix.chrome_v }}
31+
- run: uv run --no-sync choreo_get_chrome -v --i ${{ matrix.chrome_v }}
3232
- name: Reinstall from wheel
3333
run: >
3434
uv pip install dist/choreographer-$(uv

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: Install choreographer
2727
run: uv sync --all-extras
2828
- name: Install google-chrome-for-testing
29-
run: uv run choreo_get_browser
29+
run: uv run choreo_get_chrome
3030
- name: Diagnose
3131
run: uv run choreo_diagnose --no-run
3232
timeout-minutes: 1

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# See https://pre-commit.com/hooks.html for more hooks
33
%YAML 1.2
44
---
5+
exclude: "site/.*"
56
repos:
67
- repo: https://github.com/pre-commit/pre-commit-hooks
78
rev: v3.2.0

choreographer/DIR_INDEX.txt

Lines changed: 0 additions & 31 deletions
This file was deleted.

choreographer/__init__.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
1-
"""choreographer is a browser controller for python."""
1+
"""
2+
choreographer is a browser controller for python.
23
3-
import choreographer._devtools_protocol_layer as protocol
4+
choreographer is natively async, so while there are two main entrypoints:
5+
classes `Browser` and `BrowserSync`, the sync version is very limited, functioning
6+
as a building block for more featureful implementations.
47
5-
from ._browser import Browser, BrowserClosedError, browser_which, get_browser_path
6-
from ._cli_utils import get_browser, get_browser_sync
7-
from ._pipe import BlockWarning, PipeClosedError
8-
from ._system_utils._tempfile import TempDirectory, TempDirWarning
9-
from ._tab import Tab
8+
See the main README for a quickstart.
9+
"""
10+
11+
from .browser_async import (
12+
Browser,
13+
Tab,
14+
)
15+
from .browser_sync import (
16+
BrowserSync,
17+
TabSync,
18+
)
1019

1120
__all__ = [
12-
"BlockWarning",
1321
"Browser",
14-
"BrowserClosedError",
15-
"PipeClosedError",
22+
"BrowserSync",
1623
"Tab",
17-
"TempDirWarning",
18-
"TempDirectory",
19-
"browser_which",
20-
"get_browser",
21-
"get_browser_path",
22-
"get_browser_sync",
23-
"protocol",
24+
"TabSync",
2425
]

choreographer/_brokers/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from ._async import Broker
2+
from ._sync import BrokerSync
3+
4+
__all__ = [
5+
"Broker",
6+
"BrokerSync",
7+
]
8+
9+
# note: should brokers be responsible for closing browser on bad pipe?
10+
# note: should the broker be the watchdog, in that case?

choreographer/_brokers/_async.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import warnings
5+
from typing import TYPE_CHECKING
6+
7+
import logistro
8+
9+
from choreographer import channels, protocol
10+
11+
# afrom choreographer.channels import ChannelClosedError
12+
13+
if TYPE_CHECKING:
14+
from collections.abc import MutableMapping
15+
from typing import Any
16+
17+
from choreographer.browser_async import Browser
18+
from choreographer.channels._interface_type import ChannelInterface
19+
from choreographer.protocol.devtools_async import Session, Target
20+
21+
22+
_logger = logistro.getLogger(__name__)
23+
24+
25+
class UnhandledMessageWarning(UserWarning):
26+
pass
27+
28+
29+
class Broker:
30+
"""Broker is a middleware implementation for asynchronous implementations."""
31+
32+
_browser: Browser
33+
"""Browser is a reference to the Browser object this broker is brokering for."""
34+
_channel: ChannelInterface
35+
"""
36+
Channel will be the ChannelInterface implementation (pipe or websocket)
37+
that the broker communicates on.
38+
"""
39+
futures: MutableMapping[protocol.MessageKey, asyncio.Future[Any]]
40+
"""A mapping of all the futures for all sent commands."""
41+
42+
_subscriptions_futures: MutableMapping[
43+
str,
44+
MutableMapping[
45+
str,
46+
list[asyncio.Future[Any]],
47+
],
48+
]
49+
"""A mapping of session id: subscription: list[futures]"""
50+
51+
def __init__(self, browser: Browser, channel: ChannelInterface) -> None:
52+
"""
53+
Construct a broker for a synchronous arragenment w/ both ends.
54+
55+
Args:
56+
browser: The sync browser implementation.
57+
channel: The channel the browser uses to talk on.
58+
59+
"""
60+
self._browser = browser
61+
self._channel = channel
62+
self._background_tasks: set[asyncio.Task[Any]] = set()
63+
# if its a task you dont want canceled at close (like the close task)
64+
self._background_tasks_cancellable: set[asyncio.Task[Any]] = set()
65+
# if its a user task, can cancel
66+
self._current_read_task: asyncio.Task[Any] | None = None
67+
self.futures = {}
68+
self._subscriptions_futures = {}
69+
70+
def new_subscription_future(
71+
self,
72+
session_id: str,
73+
subscription: str,
74+
) -> asyncio.Future[Any]:
75+
if session_id not in self._subscriptions_futures:
76+
self._subscriptions_futures[session_id] = {}
77+
if subscription not in self._subscriptions_futures[session_id]:
78+
self._subscriptions_futures[session_id][subscription] = []
79+
future = asyncio.get_running_loop().create_future()
80+
self._subscriptions_futures[session_id][subscription].append(future)
81+
return future
82+
83+
def clean(self) -> None:
84+
_logger.debug("Cancelling message futures")
85+
for future in self.futures.values():
86+
if not future.done():
87+
_logger.debug(f"Cancelling {future}")
88+
future.cancel()
89+
_logger.debug("Cancelling read task")
90+
if self._current_read_task and not self._current_read_task.done():
91+
_logger.debug(f"Cancelling read: {self._current_read_task}")
92+
self._current_read_task.cancel()
93+
_logger.debug("Cancelling subscription-futures")
94+
for session in self._subscriptions_futures.values():
95+
for query in session.values():
96+
for future in query:
97+
if not future.done():
98+
_logger.debug(f"Cancelling {future}")
99+
future.cancel()
100+
_logger.debug("Cancelling background tasks")
101+
for task in self._background_tasks_cancellable:
102+
if not task.done():
103+
_logger.debug(f"Cancelling {task}")
104+
task.cancel()
105+
106+
def run_read_loop(self) -> None: # noqa: C901, PLR0915 complexity
107+
def check_error(result: asyncio.Future[Any]) -> None:
108+
try:
109+
e = result.exception()
110+
if e:
111+
self._background_tasks.add(
112+
asyncio.create_task(self._browser.close()),
113+
)
114+
if not isinstance(e, asyncio.CancelledError):
115+
_logger.error(f"Error in run_read_loop: {e!s}")
116+
raise e
117+
except asyncio.CancelledError:
118+
self._background_tasks.add(asyncio.create_task(self._browser.close()))
119+
120+
async def read_loop() -> None: # noqa: PLR0912, C901
121+
try:
122+
responses = await asyncio.to_thread(
123+
self._channel.read_jsons,
124+
blocking=True,
125+
)
126+
for response in responses:
127+
error = protocol.get_error_from_result(response)
128+
key = protocol.calculate_message_key(response)
129+
if not key and error:
130+
raise protocol.DevtoolsProtocolError(response)
131+
self._check_for_closed_session(response)
132+
# surrounding lines overlap in idea
133+
if protocol.is_event(response):
134+
event_session_id = response.get(
135+
"sessionId",
136+
"",
137+
)
138+
x = self._get_target_session_by_session_id(
139+
event_session_id,
140+
)
141+
if not x:
142+
continue
143+
_, event_session = x
144+
if not event_session:
145+
_logger.error("Found an event that returned no session.")
146+
continue
147+
148+
session_futures = self._subscriptions_futures.get(
149+
event_session_id,
150+
)
151+
if session_futures:
152+
for query in session_futures:
153+
match = (
154+
query.endswith("*")
155+
and response["method"].startswith(query[:-1])
156+
) or (response["method"] == query)
157+
if match:
158+
for future in session_futures[query]:
159+
if not future.done():
160+
future.set_result(response)
161+
session_futures[query] = []
162+
163+
for query in list(event_session.subscriptions):
164+
match = (
165+
query.endswith("*")
166+
and response["method"].startswith(query[:-1])
167+
) or (response["method"] == query)
168+
_logger.debug2(
169+
f"Checking subscription key: {query} "
170+
f"against event method {response['method']}",
171+
)
172+
if match:
173+
t: asyncio.Task[Any] = asyncio.create_task(
174+
event_session.subscriptions[query][0](response),
175+
)
176+
self._background_tasks_cancellable.add(t)
177+
if not event_session.subscriptions[query][1]:
178+
event_session.unsubscribe(query)
179+
180+
elif key:
181+
if key in self.futures:
182+
_logger.debug(f"run_read_loop() found future for key {key}")
183+
future = self.futures.pop(key)
184+
elif "error" in response:
185+
raise protocol.DevtoolsProtocolError(response)
186+
else:
187+
raise RuntimeError(f"Couldn't find a future for key: {key}")
188+
future.set_result(response)
189+
else:
190+
warnings.warn( # noqa: B028
191+
f"Unhandled message type:{response!s}",
192+
UnhandledMessageWarning,
193+
)
194+
except channels.ChannelClosedError:
195+
_logger.debug("PipeClosedError caught")
196+
self._background_tasks.add(asyncio.create_task(self._browser.close()))
197+
return
198+
read_task = asyncio.create_task(read_loop())
199+
read_task.add_done_callback(check_error)
200+
self._current_read_task = read_task
201+
202+
read_task = asyncio.create_task(read_loop())
203+
read_task.add_done_callback(check_error)
204+
self._current_read_task = read_task
205+
206+
async def write_json(
207+
self,
208+
obj: protocol.BrowserCommand,
209+
) -> protocol.BrowserResponse:
210+
_logger.debug2(f"In broker.write_json for {obj}")
211+
protocol.verify_params(obj)
212+
key = protocol.calculate_message_key(obj)
213+
if not key:
214+
raise RuntimeError(
215+
"Message strangely formatted and "
216+
"choreographer couldn't figure it out why.",
217+
)
218+
loop = asyncio.get_running_loop()
219+
future: asyncio.Future[protocol.BrowserResponse] = loop.create_future()
220+
self.futures[key] = future
221+
_logger.debug(f"Created future: {key} {future}")
222+
try:
223+
await asyncio.to_thread(self._channel.write_json, obj)
224+
except BaseException as e: # noqa: BLE001
225+
future.set_exception(e)
226+
del self.futures[key]
227+
_logger.debug(f"Future for {key} deleted.")
228+
return await future
229+
230+
def _get_target_session_by_session_id(
231+
self,
232+
session_id: str,
233+
) -> tuple[Target, Session] | None:
234+
if session_id == "":
235+
return (self._browser, self._browser.sessions[session_id])
236+
for tab in self._browser.tabs.values():
237+
if session_id in tab.sessions:
238+
return (tab, tab.sessions[session_id])
239+
if session_id in self._browser.sessions:
240+
return (self._browser, self._browser.sessions[session_id])
241+
return None
242+
243+
def _check_for_closed_session(self, response: protocol.BrowserResponse) -> bool:
244+
if "method" in response and response["method"] == "Target.detachedFromTarget":
245+
session_closed = response["params"].get(
246+
"sessionId",
247+
"",
248+
)
249+
if session_closed == "":
250+
return True
251+
252+
x = self._get_target_session_by_session_id(session_closed)
253+
if x:
254+
target_closed, _ = x
255+
else:
256+
return False
257+
258+
if target_closed:
259+
target_closed._remove_session(session_closed) # noqa: SLF001
260+
_logger.debug(
261+
"Using intern subscription key: "
262+
"'Target.detachedFromTarget'. "
263+
f"Session {session_closed} was closed.",
264+
)
265+
return True
266+
return False
267+
else:
268+
return False

0 commit comments

Comments
 (0)