Skip to content

Commit 4e6a8f7

Browse files
authored
Merge pull request #271 from plotly/andrew/add_profiler
andrew/add profiler
2 parents c7dddb1 + dd3026e commit 4e6a8f7

File tree

7 files changed

+136
-12
lines changed

7 files changed

+136
-12
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ repos:
6666
language: python
6767
additional_dependencies: ["./gitlint-core[trusted-deps]"]
6868
entry: gitlint
69-
args: [--staged, --msg-filename]
69+
args: [--staged, -c, "general.ignore=B6,T3", --msg-filename]
7070
stages: [commit-msg]
7171
- repo: https://github.com/crate-ci/typos
7272
rev: v1

CHANGELOG.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
- Add argument to Session/Target send_command with_perf to return
2+
timing information about browser write/read.
13
- Update default chrome from 135.0.7011.0/1418433 to 144.0.7527.0/1544685
24
- Fix: New chrome takes longer/doesn't populate targets right away, so add a
35
retry loop to populate targets

pyproject.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,17 @@ choreo_get_chrome = "choreographer.cli._cli_utils:get_chrome_cli"
4444

4545
[dependency-groups]
4646
dev = [
47-
"pytest",
48-
"pytest-asyncio; python_version < '3.14'",
49-
"pytest-asyncio>=1.2.0; python_version >= '3.14'",
50-
"pytest-xdist",
5147
"async-timeout",
5248
"numpy; python_version < '3.11'",
5349
"numpy>=2.3.3; python_version >= '3.11'",
5450
"mypy>=1.14.1",
5551
"types-simplejson>=3.19.0.20241221",
5652
"poethepoet>=0.30.0",
5753
"pyright>=1.1.406",
54+
"pytest",
55+
"pytest-asyncio; python_version < '3.14'",
56+
"pytest-asyncio>=1.2.0; python_version >= '3.14'",
57+
"pytest-xdist",
5858
]
5959

6060
# uv doens't allow dependency groups to have separate python requirements
@@ -106,6 +106,7 @@ ignore = [
106106
]
107107

108108
[tool.pytest.ini_options]
109+
asyncio_mode = "auto"
109110
asyncio_default_fixture_loop_scope = "function"
110111
log_cli = false
111112
addopts = "--import-mode=append"

src/choreographer/_brokers/_async.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import time
45
import warnings
56
from functools import partial
67
from typing import TYPE_CHECKING
@@ -22,6 +23,9 @@
2223

2324
_logger = logistro.getLogger(__name__)
2425

26+
PERFS_MAX = 5000 # maximum number of entries in the perf dicts
27+
TRIM_SIZE = 500 # what to save after trimming it
28+
2529

2630
class UnhandledMessageWarning(UserWarning):
2731
pass
@@ -49,6 +53,9 @@ class Broker:
4953
]
5054
"""A mapping of session id: subscription: list[futures]"""
5155

56+
write_perfs: MutableMapping[protocol.MessageKey, tuple[float, float]]
57+
read_perfs: MutableMapping[protocol.MessageKey, float]
58+
5259
def __init__(self, browser: Browser, channel: ChannelInterface) -> None:
5360
"""
5461
Construct a broker for a synchronous arragenment w/ both ends.
@@ -66,6 +73,8 @@ def __init__(self, browser: Browser, channel: ChannelInterface) -> None:
6673
# if its a user task, can cancel
6774
self._current_read_task: asyncio.Task[Any] | None = None
6875
self.futures = {}
76+
self.write_perfs = {}
77+
self.read_perfs = {}
6978
self._subscriptions_futures = {}
7079

7180
self._write_lock = asyncio.Lock()
@@ -223,6 +232,14 @@ async def read_loop() -> None: # noqa: PLR0912, PLR0915, C901
223232
raise RuntimeError(f"Couldn't find a future for key: {key}")
224233
if not future.done():
225234
future.set_result(response)
235+
self.read_perfs[key] = time.perf_counter()
236+
if len(self.write_perfs) > PERFS_MAX:
237+
self.write_perfs = dict(
238+
list(self.write_perfs.items())[TRIM_SIZE:],
239+
)
240+
self.read_perfs = dict(
241+
list(self.read_perfs.items())[TRIM_SIZE:],
242+
)
226243
else:
227244
warnings.warn(
228245
f"Unhandled message type:{response!s}",
@@ -237,6 +254,16 @@ async def read_loop() -> None: # noqa: PLR0912, PLR0915, C901
237254
read_task.add_done_callback(check_read_loop_error)
238255
self._current_read_task = read_task
239256

257+
def get_perf(
258+
self,
259+
obj: protocol.BrowserCommand,
260+
) -> tuple[float, float, float]:
261+
"""Get the performance tuple for a certain BrowserCommand."""
262+
key = protocol.calculate_message_key(obj)
263+
if not key:
264+
return (0, 0, 0)
265+
return (*self.write_perfs[key], self.read_perfs[key])
266+
240267
async def write_json(
241268
self,
242269
obj: protocol.BrowserCommand,
@@ -254,13 +281,15 @@ async def write_json(
254281
self.futures[key] = future
255282
_logger.debug(f"Created future: {key} {future}")
256283
try:
284+
perf_start = time.perf_counter()
257285
async with self._write_lock: # this should be a queue not a lock
258286
loop = asyncio.get_running_loop()
259287
await loop.run_in_executor(
260288
self._executor,
261289
self._channel.write_json,
262290
obj,
263291
)
292+
self.write_perfs[key] = (perf_start, time.perf_counter())
264293
except (_manual_thread_pool.ExecutorClosedError, asyncio.CancelledError) as e:
265294
if not future.cancel() or not future.cancelled():
266295
await future # it wasn't canceled, so listen to it before raising

src/choreographer/protocol/devtools_async.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
from __future__ import annotations
44

55
import inspect
6-
from typing import TYPE_CHECKING
6+
from typing import TYPE_CHECKING, overload
77

88
import logistro
99

1010
from choreographer import protocol
1111

1212
if TYPE_CHECKING:
1313
import asyncio
14-
from typing import Any, Callable, Coroutine, MutableMapping
14+
from typing import Any, Callable, Coroutine, Literal, MutableMapping
1515

1616
from choreographer._brokers import Broker
1717

@@ -56,11 +56,34 @@ def __init__(self, session_id: str, broker: Broker) -> None:
5656
self.message_id = 0
5757
self.subscriptions = {}
5858

59+
@overload
5960
async def send_command(
6061
self,
6162
command: str,
6263
params: MutableMapping[str, Any] | None = None,
63-
) -> protocol.BrowserResponse:
64+
*,
65+
with_perf: Literal[False] = False,
66+
) -> protocol.BrowserResponse: ...
67+
68+
@overload
69+
async def send_command(
70+
self,
71+
command: str,
72+
params: MutableMapping[str, Any] | None = None,
73+
*,
74+
with_perf: Literal[True],
75+
) -> tuple[protocol.BrowserResponse, tuple[float, float, float]]: ...
76+
77+
async def send_command(
78+
self,
79+
command: str,
80+
params: MutableMapping[str, Any] | None = None,
81+
*,
82+
with_perf: bool = False,
83+
) -> (
84+
tuple[protocol.BrowserResponse, tuple[float, float, float]]
85+
| protocol.BrowserResponse
86+
):
6487
"""
6588
Send a devtools command on the session.
6689
@@ -69,9 +92,12 @@ async def send_command(
6992
Args:
7093
command: devtools command to send
7194
params: the parameters to send
95+
with_perf (bool): Return the optional tuple.
7296
7397
Returns:
7498
A message key (session, message id) tuple or None
99+
(Optional) A tuple[float, float, float] representing
100+
perf_counters() for write start, end, and read end.
75101
76102
"""
77103
current_id = self.message_id
@@ -92,6 +118,11 @@ async def send_command(
92118
f"sessionId '{self.session_id}'",
93119
)
94120
_logger.debug2(f"Full params: {str(params).replace('%', '%%')}")
121+
if with_perf:
122+
return (
123+
await self._broker.write_json(json_command),
124+
self._broker.get_perf(json_command),
125+
)
95126
return await self._broker.write_json(json_command)
96127

97128
def subscribe(
@@ -201,11 +232,34 @@ def get_session(self) -> Session:
201232
session = next(iter(self.sessions.values()))
202233
return session
203234

235+
@overload
204236
async def send_command(
205237
self,
206238
command: str,
207239
params: MutableMapping[str, Any] | None = None,
208-
) -> protocol.BrowserResponse:
240+
*,
241+
with_perf: Literal[False] = False,
242+
) -> protocol.BrowserResponse: ...
243+
244+
@overload
245+
async def send_command(
246+
self,
247+
command: str,
248+
params: MutableMapping[str, Any] | None = None,
249+
*,
250+
with_perf: Literal[True],
251+
) -> tuple[protocol.BrowserResponse, tuple[float, float, float]]: ...
252+
253+
async def send_command(
254+
self,
255+
command: str,
256+
params: MutableMapping[str, Any] | None = None,
257+
*,
258+
with_perf: bool = False,
259+
) -> (
260+
protocol.BrowserResponse
261+
| tuple[protocol.BrowserResponse, tuple[float, float, float]]
262+
):
209263
"""
210264
Send a command to the first session in a target.
211265
@@ -214,12 +268,13 @@ async def send_command(
214268
Args:
215269
command: devtools command to send
216270
params: the parameters to send
271+
with_perf (bool): Also return perf tuple
217272
218273
"""
219274
if not self.sessions.values():
220275
raise RuntimeError("Cannot send_command without at least one valid session")
221276
session = self.get_session()
222-
return await session.send_command(command, params)
277+
return await session.send_command(command, params, with_perf=with_perf)
223278

224279
async def create_session(self) -> Session:
225280
"""Create a new session on this target."""

tests/test_browser.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import choreographer as choreo
1+
import asyncio
2+
23
import logistro
34
import pytest
5+
6+
import choreographer as choreo
47
from choreographer import errors
58
from choreographer.protocol import devtools_async
69

@@ -115,6 +118,39 @@ async def test_browser_send_command(browser):
115118
await browser.send_command(command=12345)
116119

117120

121+
@pytest.mark.asyncio
122+
async def test_browser_send_command_with_perf(browser):
123+
_logger.info("testing...")
124+
perfs = []
125+
126+
# Run multiple commands and collect perf data
127+
for _ in range(3):
128+
response, perf = await browser.send_command(
129+
command="Target.getTargets",
130+
with_perf=True,
131+
)
132+
assert "result" in response and "targetInfos" in response["result"] # noqa: PT018 I like this assertion
133+
134+
# Validate perf is a tuple of 3 floats
135+
assert isinstance(perf, tuple)
136+
assert all(isinstance(t, float) for t in perf)
137+
138+
# Validate timing makes sense (write_start <= write_end <= read_end)
139+
write_start, write_end, read_end = perf
140+
assert write_start <= write_end <= read_end
141+
142+
perfs.append(perf)
143+
await asyncio.sleep(0.1)
144+
145+
# Verify each perf tuple is distinct and sequential
146+
for i in range(len(perfs) - 1):
147+
_, _, prev_read_end = perfs[i]
148+
next_write_start, _, _ = perfs[i + 1]
149+
150+
# Next command should start after previous command ended
151+
assert prev_read_end <= next_write_start, "Commands should be sequential"
152+
153+
118154
@pytest.mark.asyncio
119155
async def test_populate_targets(browser):
120156
_logger.info("testing...")

tests/test_tab.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logistro
44
import pytest
5+
56
from choreographer import errors
67
from choreographer.protocol import devtools_async
78

@@ -80,7 +81,7 @@ async def count_event(_r):
8081
assert "Page.*" in next(iter(tab.sessions.values())).subscriptions
8182
await tab.send_command("Page.enable")
8283
await tab.send_command("Page.reload")
83-
await asyncio.sleep(0.5)
84+
await asyncio.sleep(0.15)
8485
assert counter > old_counter
8586

8687
tab.unsubscribe("Page.*")

0 commit comments

Comments
 (0)