Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Andrew/refactor #178

Open
wants to merge 65 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
62f3564
Change subpackage name
ayjayt Jan 4, 2025
cc0898a
Remove system() shortcut
ayjayt Jan 5, 2025
140251c
Fix up _pipe.py for refactor
ayjayt Jan 5, 2025
46c58d6
Better organize _pipe for refactor:
ayjayt Jan 5, 2025
15f920f
Create a new browser package for brand support:
ayjayt Jan 5, 2025
c997d51
Fix formatting
ayjayt Jan 5, 2025
d8dbfec
Further fix up namespaces
ayjayt Jan 5, 2025
c2ebdf6
Refactor sys/cli
ayjayt Jan 6, 2025
d51fccc
Change names of protocol a bit
ayjayt Jan 6, 2025
241960d
Change namespace of protocol
ayjayt Jan 6, 2025
34d30c3
Add factored sys into chromium.py
ayjayt Jan 6, 2025
4e6eee4
Refactor wire out of pipe
ayjayt Jan 6, 2025
d7db1b5
Pull chrome constants out of which
ayjayt Jan 6, 2025
656cf71
Fix up namespaces and chromium.py
ayjayt Jan 6, 2025
73ff2e1
Add dev readmes
ayjayt Jan 6, 2025
3d30378
Create basic broker subpackage
ayjayt Jan 6, 2025
239e0d6
Init session after open
ayjayt Jan 6, 2025
4d8472b
Remove async version of browser before test
ayjayt Jan 6, 2025
5bde5fa
Refactor sync
ayjayt Jan 6, 2025
479c409
Fix imports
ayjayt Jan 6, 2025
3fbee4b
Fix up metadata
ayjayt Jan 6, 2025
1a1503f
Fix up namespaces
ayjayt Jan 6, 2025
7a3ae8a
Fix up import errors
ayjayt Jan 6, 2025
c4e79dc
Redo chromium.py changes?
ayjayt Jan 6, 2025
afaf7ad
Remember to return
ayjayt Jan 6, 2025
a592200
Move path to non-kwargs arg chromium
ayjayt Jan 6, 2025
7ae4fae
Fix some bad syntax
ayjayt Jan 6, 2025
3b68fc8
Fix syntax
ayjayt Jan 6, 2025
ac96c0b
Add process logger
ayjayt Jan 6, 2025
c7b2bf8
Improve logging
ayjayt Jan 6, 2025
c84ac06
Improve logging
ayjayt Jan 6, 2025
670fea2
Fix download path in CLI
ayjayt Jan 6, 2025
eef038c
Add lock to BrowserSync
ayjayt Jan 6, 2025
9f0d038
Make sure Chromium cleans its tmp dir
ayjayt Jan 7, 2025
2479441
Add logs and docs
ayjayt Jan 7, 2025
f55268e
Commit for mkdocs - no push
ayjayt Jan 7, 2025
7a73a59
Lint test_serializer
ayjayt Jan 8, 2025
b50b3d1
Format docs
ayjayt Jan 8, 2025
d324401
Format for docs
ayjayt Jan 9, 2025
65afc13
Fix up namespaces (plain renames)
ayjayt Jan 10, 2025
d4efa56
Fix bad naming
ayjayt Jan 10, 2025
3d890e3
Fix up naming a bit
ayjayt Jan 10, 2025
4a07382
Rename more
ayjayt Jan 10, 2025
d55f8b5
Keep renaming
ayjayt Jan 10, 2025
d192d42
Remove unnecessary tools from CLI
ayjayt Jan 10, 2025
c54755e
Add first build of documentation
ayjayt Jan 10, 2025
c696002
Move attributes to private
ayjayt Jan 13, 2025
7f971be
Add mypy to project
ayjayt Jan 13, 2025
c41e704
Fix types and fix import
ayjayt Jan 15, 2025
f0b1228
Fix poorly references variable
ayjayt Jan 15, 2025
af5a6db
Move all_sessions to broker:
ayjayt Jan 15, 2025
d6ab519
Continue typing
ayjayt Jan 20, 2025
1e1f3ab
--amend
ayjayt Jan 20, 2025
de569a9
Finish typing
ayjayt Jan 20, 2025
11fa71d
Update documentation.
ayjayt Jan 20, 2025
86c6d36
Make browser_sync
ayjayt Jan 20, 2025
7aa2929
Add async watchdog and sendcommand
ayjayt Jan 21, 2025
87cfb29
Add populate_targets + create_sesion
ayjayt Jan 21, 2025
c72e041
Fix tests
ayjayt Jan 22, 2025
cdbb13f
Readd subscribe once
ayjayt Jan 22, 2025
69c23f5
Update mkdocs
ayjayt Jan 22, 2025
437611d
Remove toxic docs deps:
ayjayt Jan 22, 2025
b24a6be
Ruff for lesser version
ayjayt Jan 22, 2025
f5dac5b
Add debugging for windows
ayjayt Jan 22, 2025
1522dc8
Remove casts and any's from typing
ayjayt Jan 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish_testpypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
# don't modify sync file! messes up version!
- run: uv sync --all-extras --frozen # does order matter?
- run: uv build
- run: uv run --no-sync choreo_get_browser -v --i ${{ matrix.chrome_v }}
- run: uv run --no-sync choreo_get_chrome -v --i ${{ matrix.chrome_v }}
- name: Reinstall from wheel
run: >
uv pip install dist/choreographer-$(uv
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- name: Install choreographer
run: uv sync --all-extras
- name: Install google-chrome-for-testing
run: uv run choreo_get_browser
run: uv run choreo_get_chrome
- name: Diagnose
run: uv run choreo_diagnose --no-run
timeout-minutes: 1
Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# See https://pre-commit.com/hooks.html for more hooks
%YAML 1.2
---
exclude: "site/.*"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
Expand Down
31 changes: 0 additions & 31 deletions choreographer/DIR_INDEX.txt

This file was deleted.

35 changes: 18 additions & 17 deletions choreographer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
"""choreographer is a browser controller for python."""
"""
choreographer is a browser controller for python.

import choreographer._devtools_protocol_layer as protocol
choreographer is natively async, so while there are two main entrypoints:
classes `Browser` and `BrowserSync`, the sync version is very limited, functioning
as a building block for more featureful implementations.

from ._browser import Browser, BrowserClosedError, browser_which, get_browser_path
from ._cli_utils import get_browser, get_browser_sync
from ._pipe import BlockWarning, PipeClosedError
from ._system_utils._tempfile import TempDirectory, TempDirWarning
from ._tab import Tab
See the main README for a quickstart.
"""

from .browser_async import (
Browser,
Tab,
)
from .browser_sync import (
BrowserSync,
TabSync,
)

__all__ = [
"BlockWarning",
"Browser",
"BrowserClosedError",
"PipeClosedError",
"BrowserSync",
"Tab",
"TempDirWarning",
"TempDirectory",
"browser_which",
"get_browser",
"get_browser_path",
"get_browser_sync",
"protocol",
"TabSync",
]
10 changes: 10 additions & 0 deletions choreographer/_brokers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from ._async import Broker
from ._sync import BrokerSync

__all__ = [
"Broker",
"BrokerSync",
]

# note: should brokers be responsible for closing browser on bad pipe?
# note: should the broker be the watchdog, in that case?
258 changes: 258 additions & 0 deletions choreographer/_brokers/_async.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
from __future__ import annotations

import asyncio
import warnings
from typing import TYPE_CHECKING

import logistro

from choreographer import channels, protocol

# afrom choreographer.channels import ChannelClosedError

if TYPE_CHECKING:
from collections.abc import MutableMapping
from typing import Any

from choreographer.browser_async import Browser
from choreographer.channels._interface_type import ChannelInterface
from choreographer.protocol.devtools_async import Session, Target


_logger = logistro.getLogger(__name__)


class UnhandledMessageWarning(UserWarning):
pass


class Broker:
"""Broker is a middleware implementation for asynchronous implementations."""

_browser: Browser
"""Browser is a reference to the Browser object this broker is brokering for."""
_channel: ChannelInterface
"""
Channel will be the ChannelInterface implementation (pipe or websocket)
that the broker communicates on.
"""
futures: MutableMapping[protocol.MessageKey, asyncio.Future[Any]]
"""A mapping of all the futures for all sent commands."""

_subscriptions_futures: MutableMapping[
str,
MutableMapping[
str,
list[asyncio.Future[Any]],
],
]
"""A mapping of session id: subscription: list[futures]"""

def __init__(self, browser: Browser, channel: ChannelInterface) -> None:
"""
Construct a broker for a synchronous arragenment w/ both ends.

Args:
browser: The sync browser implementation.
channel: The channel the browser uses to talk on.

"""
self._browser = browser
self._channel = channel
self._background_tasks: set[asyncio.Task[Any]] = set()
# if its a task you dont want canceled at close (like the close task)
self._background_tasks_cancellable: set[asyncio.Task[Any]] = set()
# if its a user task, can cancel
self._current_read_task: asyncio.Task[Any] | None = None
self.futures = {}
self._subscriptions_futures = {}

def new_subscription_future(
self,
session_id: str,
subscription: str,
) -> asyncio.Future[Any]:
if session_id not in self._subscriptions_futures:
self._subscriptions_futures[session_id] = {}
if subscription not in self._subscriptions_futures[session_id]:
self._subscriptions_futures[session_id][subscription] = []
future = asyncio.get_running_loop().create_future()
self._subscriptions_futures[session_id][subscription].append(future)
return future

async def clean(self) -> None:
for future in self.futures.values():
if not future.done():
future.cancel()
if self._current_read_task and not self._current_read_task.done():
self._current_read_task.cancel()
for session in self._subscriptions_futures.values():
for query in session.values():
for future in query:
if not future.done():
future.cancel()
for task in self._background_tasks_cancellable:
if not task.done():
task.cancel()

def run_read_loop(self) -> None: # noqa: C901, PLR0915 complexity
def check_error(result: asyncio.Future[Any]) -> None:
try:
e = result.exception()
if e:
self._background_tasks.add(
asyncio.create_task(self._browser.close()),
)
if not isinstance(e, asyncio.CancelledError):
_logger.error(f"Error in run_read_loop: {e!s}")
raise e
except asyncio.CancelledError:
self._background_tasks.add(asyncio.create_task(self._browser.close()))

async def read_loop() -> None: # noqa: PLR0912, C901
try:
responses = await asyncio.to_thread(
self._channel.read_jsons,
blocking=True,
)
for response in responses:
error = protocol.get_error_from_result(response)
key = protocol.calculate_message_key(response)
if not key and error:
raise protocol.DevtoolsProtocolError(response)
self._check_for_closed_session(response)
# surrounding lines overlap in idea
if protocol.is_event(response):
event_session_id = response.get(
"sessionId",
"",
)
x = self._get_target_session_by_session_id(
event_session_id,
)
if not x:
continue
_, event_session = x
if not event_session:
_logger.error("Found an event that returned no session.")
continue

session_futures = self._subscriptions_futures.get(
event_session_id,
)
if session_futures:
for query in session_futures:
match = (
query.endswith("*")
and response["method"].startswith(query[:-1])
) or (response["method"] == query)
if match:
for future in session_futures[query]:
if not future.done():
future.set_result(response)
session_futures[query] = []

for query in list(event_session.subscriptions):
match = (
query.endswith("*")
and response["method"].startswith(query[:-1])
) or (response["method"] == query)
_logger.debug(
f"Checking subscription key: {query} "
"against event method {response['method']}",
)
if match:
t: asyncio.Task[Any] = asyncio.create_task(
event_session.subscriptions[query][0](response),
)
self._background_tasks_cancellable.add(t)
if not event_session.subscriptions[query][1]:
event_session.unsubscribe(query)

elif key:
if key in self.futures:
_logger.debug(f"run_read_loop() found future for key {key}")
future = self.futures.pop(key)
elif "error" in response:
raise protocol.DevtoolsProtocolError(response)
else:
raise RuntimeError(f"Couldn't find a future for key: {key}")
future.set_result(response)
else:
warnings.warn( # noqa: B028
f"Unhandled message type:{response!s}",
UnhandledMessageWarning,
)
except channels.ChannelClosedError:
_logger.debug("PipeClosedError caught")
self._background_tasks.add(asyncio.create_task(self._browser.close()))
return
read_task = asyncio.create_task(read_loop())
read_task.add_done_callback(check_error)
self._current_read_task = read_task

read_task = asyncio.create_task(read_loop())
read_task.add_done_callback(check_error)
self._current_read_task = read_task

async def write_json(
self,
obj: protocol.BrowserCommand,
) -> protocol.BrowserResponse:
_logger.debug2(f"In broker.write_json for {obj}")
protocol.verify_params(obj)
key = protocol.calculate_message_key(obj)
if not key:
raise RuntimeError(
"Message strangely formatted and "
"choreographer couldn't figure it out why.",
)
loop = asyncio.get_running_loop()
future: asyncio.Future[protocol.BrowserResponse] = loop.create_future()
self.futures[key] = future
try:
await asyncio.to_thread(self._channel.write_json, obj)
except BaseException as e: # noqa: BLE001
future.set_exception(e)
del self.futures[key]
return await future

def _get_target_session_by_session_id(
self,
session_id: str,
) -> tuple[Target, Session] | None:
if session_id == "":
return (self._browser, self._browser.sessions[session_id])
for tab in self._browser.tabs.values():
if session_id in tab.sessions:
return (tab, tab.sessions[session_id])
if session_id in self._browser.sessions:
return (self._browser, self._browser.sessions[session_id])
return None

def _check_for_closed_session(self, response: protocol.BrowserResponse) -> bool:
if "method" in response and response["method"] == "Target.detachedFromTarget":
session_closed = response["params"].get(
"sessionId",
"",
)
if session_closed == "":
return True

x = self._get_target_session_by_session_id(session_closed)
if x:
target_closed, _ = x
else:
return False

if target_closed:
target_closed._remove_session(session_closed) # noqa: SLF001
_logger.debug(
"Using intern subscription key: "
"'Target.detachedFromTarget'. "
f"Session {session_closed} was closed.",
)
return True
return False
else:
return False
Loading
Loading