From d0461d2f6845ba0bc46f3060bd52a784f33128e9 Mon Sep 17 00:00:00 2001 From: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:14:01 -0400 Subject: [PATCH] feat: support Chromium proxy servers --- CHANGELOG.md | 1 + README.md | 20 ++++++++ src/choreographer/browsers/chromium.py | 21 +++++++- tests/test_chromium.py | 71 ++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/test_chromium.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ebf867d..ba7fa23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ where X.Y.Z is the semver of the most recent choreographer release. ### Added - Add `enable_extensions` option to control browser extension loading [[#303](https://github.com/plotly/choreographer/pull/303)], with thanks to @hirohira9119 for the contribution! +- Add `proxy_server` browser configuration with a `CHOREO_PROXY_SERVER` environment fallback [[#301](https://github.com/plotly/choreographer/issues/301)] ### Fixed - Improve platform architecture detection for arm on Linux and Windows [[#290](https://github.com/plotly/choreographer/pull/290)], with thanks to @juliabeliaeva for the contribution! diff --git a/README.md b/README.md index 1793e2fd..94d90c4d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,26 @@ not absolutely required. The synchronous functions in this package are intended as building blocks for other asynchronous strategies that Python may favor over async/await in the future. +## Proxy Configuration + +Pass `proxy_server` when constructing a browser to route Chromium traffic +through a proxy: + +```python +browser = await choreo.Browser(proxy_server="http://proxy.example:8080") +``` + +For callers such as Kaleido and Plotly that construct the browser internally, +set `CHOREO_PROXY_SERVER` before starting the Python process: + +```shell +CHOREO_PROXY_SERVER=http://proxy.example:8080 python app.py +``` + +The value is passed directly to Chromium's `--proxy-server` command-line flag. +Avoid including credentials in the URL because command-line arguments may be +visible to other users on the system. + ## Testing ### Process Control Tests diff --git a/src/choreographer/browsers/chromium.py b/src/choreographer/browsers/chromium.py index 0b32239a..908ded34 100644 --- a/src/choreographer/browsers/chromium.py +++ b/src/choreographer/browsers/chromium.py @@ -68,6 +68,8 @@ class Chromium: """True if we should use the gpu. False by default for compatibility.""" headless: bool """True if we should not show the browser. True by default.""" + proxy_server: str | None + """Proxy server passed to Chromium, if configured.""" sandbox_enabled: bool """True to enable the sandbox. False by default.""" skip_local: bool @@ -147,6 +149,8 @@ def __init__( enable_extensions (default True): Enable extensions? enable_gpu (default False): Turn on GPU? Doesn't work in all envs. headless (default True): Run the browser in headless mode? + proxy_server (default None): Proxy server URL passed to Chromium. + Falls back to the `CHOREO_PROXY_SERVER` environment variable. enable_sandbox (default False): Enable sandbox- a persnickety thing depending on environment, OS, user, etc tmp_dir (default None): Manually set the temporary directory @@ -156,11 +160,18 @@ def __init__( NotImplementedError: Pipe is the only channel type it'll accept right now. """ - _logger.info(f"Chromium init'ed with kwargs {kwargs}") + log_kwargs = kwargs.copy() + if "proxy_server" in log_kwargs: + log_kwargs["proxy_server"] = "" + _logger.info(f"Chromium init'ed with kwargs {log_kwargs}") self.path = path self.extensions_enabled = kwargs.pop("enable_extensions", True) self.gpu_enabled = kwargs.pop("enable_gpu", False) self.headless = kwargs.pop("headless", True) + self.proxy_server = kwargs.pop( + "proxy_server", + os.environ.get("CHOREO_PROXY_SERVER"), + ) self.sandbox_enabled = kwargs.pop("enable_sandbox", False) self._tmp_dir_path = kwargs.pop("tmp_dir", None) if kwargs: @@ -247,6 +258,8 @@ def get_cli(self) -> Sequence[str]: cli.append("--disable-gpu") if self.headless: cli.append("--headless") + if self.proxy_server: + cli.append(f"--proxy-server={self.proxy_server}") if not self.sandbox_enabled: cli.append("--no-sandbox") @@ -291,7 +304,11 @@ def get_cli(self) -> Sequence[str]: cli += [ f"--remote-debugging-io-pipes={r_handle!s},{w_handle!s}", ] - _logger.debug(f"Returning cli: {cli}") + log_cli = [ + "--proxy-server=" if arg.startswith("--proxy-server=") else arg + for arg in cli + ] + _logger.debug(f"Returning cli: {log_cli}") return cli def get_env(self) -> MutableMapping[str, str]: diff --git a/tests/test_chromium.py b/tests/test_chromium.py new file mode 100644 index 00000000..3d715a05 --- /dev/null +++ b/tests/test_chromium.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging + +from choreographer.browsers.chromium import Chromium +from choreographer.channels import Pipe + + +def _get_cli(tmp_path, **kwargs) -> list[str]: + executable = tmp_path / "chrome" + executable.touch() + channel = Pipe() + browser = Chromium(channel, executable, **kwargs) + browser.pre_open() + try: + return list(browser.get_cli()) + finally: + browser.clean() + channel.close() + + +def test_proxy_server_argument(tmp_path, monkeypatch): + monkeypatch.delenv("CHOREO_PROXY_SERVER", raising=False) + + cli = _get_cli(tmp_path, proxy_server="http://proxy.example:8080") + + assert "--proxy-server=http://proxy.example:8080" in cli + + +def test_proxy_server_environment_variable(tmp_path, monkeypatch): + monkeypatch.setenv("CHOREO_PROXY_SERVER", "socks5://proxy.example:1080") + + cli = _get_cli(tmp_path) + + assert "--proxy-server=socks5://proxy.example:1080" in cli + + +def test_proxy_server_argument_overrides_environment(tmp_path, monkeypatch): + monkeypatch.setenv("CHOREO_PROXY_SERVER", "http://environment.example:8080") + + cli = _get_cli(tmp_path, proxy_server="http://argument.example:8080") + + assert "--proxy-server=http://argument.example:8080" in cli + assert "--proxy-server=http://environment.example:8080" not in cli + + +def test_proxy_server_credentials_are_redacted_from_logs(tmp_path, caplog): + proxy_server = "http://user:secret@proxy.example:8080" + caplog.set_level(logging.DEBUG) + + cli = _get_cli(tmp_path, proxy_server=proxy_server) + + assert f"--proxy-server={proxy_server}" in cli + assert proxy_server not in caplog.text + assert "--proxy-server=" in caplog.text + + +def test_proxy_server_is_not_added_by_default(tmp_path, monkeypatch): + monkeypatch.delenv("CHOREO_PROXY_SERVER", raising=False) + + cli = _get_cli(tmp_path) + + assert not any(arg.startswith("--proxy-server=") for arg in cli) + + +def test_none_proxy_server_disables_environment_fallback(tmp_path, monkeypatch): + monkeypatch.setenv("CHOREO_PROXY_SERVER", "http://environment.example:8080") + + cli = _get_cli(tmp_path, proxy_server=None) + + assert not any(arg.startswith("--proxy-server=") for arg in cli)