diff --git a/.github/actions/action.yml b/.github/actions/action.yml index cac3506a..8fbeff68 100644 --- a/.github/actions/action.yml +++ b/.github/actions/action.yml @@ -1,3 +1,4 @@ +--- # not using this until actions supports timeout-minutes name: 'common-test-fragment' description: 'Run python common across OSes' @@ -8,12 +9,15 @@ runs: steps: - name: Install devtools run: pip install .[dev] + - name: DTDoctor run: dtdoctor - #timeout-minutes: 3 + # timeout-minutes: 3 + - name: Test Process Control run: pytest -vv -rA tests/test_process.py - #timeout-minutes: 1 + # timeout-minutes: 1 + - name: Test the Rest run: pytest -vv -rA --ignore=tests/test_process.py - #timeout-minutes: 1 + # timeout-minutes: 1 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index a89c56c7..3c58e4a2 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -1,3 +1,4 @@ +--- name: ruff-wf on: pull_request jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d10bbab2..1b517c1e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,95 +1,42 @@ +--- name: test-wf - on: pull_request jobs: - test-linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v1 # Would be nice to bump this? Regressions. - - name: Install Dependencies - run: sudo apt-get update && sudo apt-get install chromium-browser xvfb - timeout-minutes: 4 # because sometimes it dies - #- uses: ./.github/actions/ # it would be nice but it doesn't support timeout-minutes - - name: Install choreographer - run: pip install .[dev] - - name: DTDoctor - run: dtdoctor --no-run - timeout-minutes: 1 - - name: Test Process Control DEBUG - if: runner.debug - run: xvfb-run pytest -W error -vv -rA --capture=tee-sys tests/test_process.py - timeout-minutes: 5 - - name: Test Process Control - if: ${{ ! runner.debug }} - run: xvfb-run pytest -W error -n auto -v -rfE --capture=fd tests/test_process.py - timeout-minutes: 2 - - name: Test The Rest - if: ${{ ! runner.debug }} - run: pytest -W error -n auto -v -rfE --ignore=tests/test_process.py - timeout-minutes: 2 - - name: Test The Rest DEBUG - if: runner.debug - run: pytest -W error -vvv -rA --capture=tee-sys --ignore=tests/test_process.py - timeout-minutes: 5 - test-windows: - runs-on: windows-latest + test-all: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 - uses: actions/setup-python@v5 + with: + python-version-file: "pyproject.toml" + - name: Install Dependencies - run: choco install googlechrome -y --ignore-checksums - timeout-minutes: 4 # because sometimes it dies - #- uses: ./.github/actions/ - - name: Install choreographer - run: pip install .[dev] - - name: DTDoctor - run: dtdoctor --no-run + if: ${{ matrix.os == 'ubuntu-latest' }} + run: sudo apt-get update && sudo apt-get install xvfb timeout-minutes: 1 - - name: Test Process Control DEBUG - if: runner.debug - run: pytest -W error -vv -rA --capture=tee-sys tests/test_process.py - timeout-minutes: 5 - - name: Test Process Control - if: ${{ ! runner.debug }} - run: pytest -W error -n auto -v -rFe --capture=fd tests/test_process.py - timeout-minutes: 2 - - name: Test The Rest - if: ${{ ! runner.debug }} - run: pytest -W error -n auto -v -rfE --ignore=tests/test_process.py - timeout-minutes: 2 - - name: Test The Rest DEBUG - if: runner.debug - run: pytest -W error -vvv -rA --capture=tee-sys --ignore=tests/test_process.py - timeout-minutes: 5 - test-mac: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - - name: Install Dependencies - run: brew install google-chrome - timeout-minutes: 4 # because sometimes it dies - #- uses: ./.github/actions/ + - name: Install choreographer - run: pip install .[dev] - - name: DTDoctor - run: dtdoctor --no-run + run: uv sync --all-extras + - name: Install google-chrome-for-testing + run: uv run choreo_get_browser + - name: Diagnose + run: uv run choreo_diagnose --no-run timeout-minutes: 1 - - name: Test Process Control DEBUG - if: runner.debug - run: pytest -W error -vv -rA --capture=tee-sys tests/test_process.py - timeout-minutes: 5 - - name: Test Process Control - if: ${{ ! runner.debug }} - run: pytest -W error -n auto -v -rFe --capture=fd tests/test_process.py - timeout-minutes: 4 - - name: Test The Rest - if: ${{ ! runner.debug }} - run: pytest -W error -n auto -v -rfE --ignore=tests/test_process.py - timeout-minutes: 3 - - name: Test The Rest DEBUG - if: runner.debug - run: pytest -W error -vvv -rA --capture=tee-sys --ignore=tests/test_process.py - timeout-minutes: 5 + - name: Test + if: ${{ ! runner.debug && matrix.os != 'ubuntu-latest' }} + run: uv run poe test + timeout-minutes: 7 + + - name: Test (Linux) + if: ${{ ! runner.debug && matrix.os == 'ubuntu-latest' }} + run: xvfb-run uv run poe test + timeout-minutes: 7 + + - name: Test (Debug) + if: runner.debug + run: uv run poe debug-test diff --git a/.gitignore b/.gitignore index 4aba6eff..d64bad42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# specific +browser_exe + +# normal stuff .venv # ignore build artifacts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c59fa521 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,94 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +%YAML 1.2 +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-toml + - id: debug-statements + - repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma + # alternative: isort + # optional comments: # noreorder + - repo: https://github.com/asottile/reorder-python-imports + rev: v3.14.0 + hooks: + - id: reorder-python-imports + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.8.2 + hooks: + # Run the linter. + - id: ruff + types_or: [python, pyi] + # Run the formatter. + - id: ruff-format + types_or: [python, pyi] + # options: ignore one line things [E701] + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + name: yamllint + description: This hook runs yamllint. + entry: yamllint + language: python + types: [file, yaml] + args: ['-d', "{\ + extends: default,\ + rules: {\ + colons: { max-spaces-after: -1 }\ + }\ + }"] + - repo: https://github.com/rhysd/actionlint + rev: v1.7.4 + hooks: + - id: actionlint + name: Lint GitHub Actions workflow files + description: Runs actionlint to lint GitHub Actions workflow files + language: golang + types: ["yaml"] + files: ^\.github/workflows/ + entry: actionlint + - repo: https://github.com/jorisroovers/gitlint + rev: v0.19.1 + hooks: + - id: gitlint + name: gitlint + description: Checks your git commit messages for style. + language: python + additional_dependencies: ["./gitlint-core[trusted-deps]"] + entry: gitlint + args: [--staged, --msg-filename] + stages: [commit-msg] + - repo: https://github.com/crate-ci/typos + rev: v1.28.2 + hooks: + - id: typos + - repo: https://github.com/markdownlint/markdownlint + rev: v0.13.0 + hooks: + - id: markdownlint + name: Markdownlint + description: Run markdownlint on your Markdown files + entry: mdl --rules ~MD026 + language: ruby + files: \.(md|mdown|markdown)$ + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + name: Detect secrets + language: python + entry: detect-secrets-hook + args: [''] diff --git a/README.md b/README.md index 2b62421f..74e90188 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ You can also add "--no-headless" to these if you want to see the browser pop up. ### Writing Tests -- Put async and sync tests in different files. Add `_sync.py` to synchronous tests. -- If doing process tests, maybe use the same decorators and fixtures in the `test_process.py` file. -- If doing browser interaction tests, use `test_placeholder.py` as the minimum template. +- Put async and sync tests in different files. Add `_sync.py` to synchronous tests. +- For process tests, use the same decorators/fixtures like `test_process.py` file. +- For API tests, use `test_placeholder.py` as the minimum template. ## Help Wanted @@ -52,10 +52,10 @@ We need your help to test this package on different platforms and for different use cases. To get started: -1. Clone this repository. -1. Create and activate a Python virtual environment. -1. Install this repository using `pip install .` or the equivalent. -1. Run `dtdoctor` and paste the output into an issue in this repository. +1. Clone this repository. +1. Create and activate a Python virtual environment. +1. Install this repository using `pip install .` or the equivalent. +1. Run `dtdoctor` and paste the output into an issue in this repository. ## Quickstart with `asyncio` @@ -80,18 +80,18 @@ if __name__ == "__main__": Step by step, this example: -1. Imports the required libraries. -1. Defines an `async` function +1. Imports the required libraries. +1. Defines an `async` function (because `await` can only be used inside `async` functions). -1. Asks choreographer to create a browser. +1. Asks choreographer to create a browser. `headless=False` tells it to display the browser on the screen; the default is no display. -1. Wait three seconds for the browser to be created. -1. Create another tab. +1. Wait three seconds for the browser to be created. +1. Create another tab. (Note that users can't rearrange programmatically-generated tabs using the mouse, but that's OK: we're not trying to replace testing tools like [Puppeteer][puppeteer].) -1. Sleep again. -1. Runs the example function. +1. Sleep again. +1. Runs the example function. See [the devtools reference][devtools-ref] for a list of possible commands. @@ -130,23 +130,21 @@ You can use this library without `asyncio`, my_browser = choreo.Browser() # blocking until open ``` -However, -you are responsible for calling `browser.pipe.read_jsons(blocking=True|False)` when necessary +However, you must call `browser.pipe.read_jsons(blocking=True|False)` manually, and organizing the results. -`browser.run_output_thread()` will start a second thread that constantly prints all responses from the browser, -but it can't be used with `asyncio` and won't play nice with any other read. -In other words, -unles you're really, really sure you know what you're doing, + +`browser.run_output_thread()` starts another thread constantly printing +messages received from the browser but it can't be used with `asyncio` +nor will it play nice with any other read. + +In other words, unless you're really, really sure you know what you're doing, use `asyncio`. ## Low-Level Use -We provide a `Browser` and `Tab` interface, -but there is also a lower-level `Target` and `Session` interface that one can use if needed. -We will document these as the API stabilizes. +We provide a `Browser` and `Tab` interface, but there are lower-level `Target` +and `Session` interfaces if needed. [devtools-ref]: https://chromedevtools.github.io/devtools-protocol/ [kaleido]: https://pypi.org/project/kaleido/ [puppeteer]: https://pptr.dev/ - - diff --git a/choreographer/__init__.py b/choreographer/__init__.py index 39f1482e..66588b4a 100644 --- a/choreographer/__init__.py +++ b/choreographer/__init__.py @@ -1,3 +1,7 @@ -from .browser import Browser, which_browser +from .browser import Browser +from .browser import get_browser_path +from .browser import which_browser +from .cli_utils import get_browser +from .cli_utils import get_browser_sync -__all__ = [Browser, which_browser] +__all__ = [Browser, get_browser, get_browser_sync, which_browser, get_browser_path] diff --git a/choreographer/browser.py b/choreographer/browser.py index c21b2f95..05f4ec36 100644 --- a/choreographer/browser.py +++ b/choreographer/browser.py @@ -1,47 +1,64 @@ -import platform -import os -from pathlib import Path -import sys +import asyncio import io +import json +import os +import platform +import shutil +import stat import subprocess -import time +import sys import tempfile +import time import warnings -import json -import asyncio -import stat -import shutil - -from threading import Thread from collections import OrderedDict +from pathlib import Path +from threading import Thread from .pipe import Pipe -from .protocol import Protocol, DevtoolsProtocolError, ExperimentalFeatureWarning, TARGET_NOT_FOUND -from .target import Target +from .pipe import PipeClosedError +from .protocol import DevtoolsProtocolError +from .protocol import ExperimentalFeatureWarning +from .protocol import Protocol +from .protocol import TARGET_NOT_FOUND from .session import Session -from .tab import Tab from .system import which_browser +from .tab import Tab +from .target import Target -from .pipe import PipeClosedError class TempDirWarning(UserWarning): pass + + class UnhandledMessageWarning(UserWarning): pass + + class BrowserFailedError(RuntimeError): pass + + class BrowserClosedError(RuntimeError): pass -default_path = which_browser() # probably handle this better + with_onexc = bool(sys.version_info[:3] >= (3, 12)) -class Browser(Target): +def get_browser_path(): + return os.environ.get("BROWSER_PATH", which_browser()) + + +class Browser(Target): def _check_loop(self): # Lock - if not self.lock: self.lock = asyncio.Lock() - if platform.system() == "Windows" and self.loop and isinstance(self.loop, asyncio.SelectorEventLoop): + if not self.lock: + self.lock = asyncio.Lock() + if ( + platform.system() == "Windows" + and self.loop + and isinstance(self.loop, asyncio.SelectorEventLoop) + ): # I think using set_event_loop_policy is too invasive (is system wide) # and may not work in situations where a framework manually set SEL # https://github.com/jupyterlab/jupyterlab/issues/12545 @@ -57,7 +74,7 @@ def __init__( executor=None, debug=False, debug_browser=False, - **kwargs + **kwargs, ): # Configuration self.enable_gpu = kwargs.pop("enable_gpu", False) @@ -67,7 +84,7 @@ def __init__( raise ValueError(f"Unknown keyword arguments: {kwargs}") self.headless = headless self.debug = debug - self.loop_hack = False # subprocess needs weird stuff w/ SelectorEventLoop + self.loop_hack = False # subprocess needs weird stuff w/ SelectorEventLoop # Set up stderr if debug_browser is False: # false o None @@ -77,35 +94,35 @@ def __init__( else: stderr = debug_browser - if ( stderr - and stderr not in ( subprocess.PIPE, subprocess.STDOUT, subprocess.DEVNULL ) - and not isinstance(stderr, int) ): - try: stderr.fileno() + if ( + stderr + and stderr not in (subprocess.PIPE, subprocess.STDOUT, subprocess.DEVNULL) + and not isinstance(stderr, int) + ): + try: + stderr.fileno() except io.UnsupportedOperation: - warnings.warn("A value has been passed to debug_browser which is not compatible with python's Popen. This may be because one was passed to Browser or because sys.stderr has been overrided by a framework. Browser logs will not be handled by python in this case.") + warnings.warn( + "A value has been passed to debug_browser which is not compatible with python's Popen. This may be because one was passed to Browser or because sys.stderr has been overridden by a framework. Browser logs will not be handled by python in this case.", + ) stderr = None - - self._stderr = stderr if debug: print(f"STDERR: {stderr}", file=sys.stderr) - # Set up process env new_env = os.environ.copy() - if not path: # use argument first - path = os.environ.get("BROWSER_PATH", None) - if not path: - path = default_path - if path: - new_env["BROWSER_PATH"] = str(path) - else: - raise BrowserFailedError( - "Could not find an acceptable browser. Please set environmental variable BROWSER_PATH or pass `path=/path/to/browser` into the Browser() constructor." - ) + if not path: # use argument first + path = get_browser_path() + if not path: + raise BrowserFailedError( + "Could not find an acceptable browser. Please set environmental variable BROWSER_PATH or pass `path=/path/to/browser` into the Browser() constructor. See documentation for downloading browser from python.", + ) + + new_env["BROWSER_PATH"] = str(path) if self._tmpdir_path: temp_args = dict(dir=self._tmpdir_path) @@ -123,11 +140,14 @@ def __init__( vinfo = sys.version_info[:3] if vinfo >= (3, 12): self.temp_dir = tempfile.TemporaryDirectory( - delete=False, ignore_cleanup_errors=True, **temp_args + delete=False, + ignore_cleanup_errors=True, + **temp_args, ) elif vinfo >= (3, 10): self.temp_dir = tempfile.TemporaryDirectory( - ignore_cleanup_errors=True, **temp_args + ignore_cleanup_errors=True, + **temp_args, ) else: self.temp_dir = tempfile.TemporaryDirectory(**temp_args) @@ -151,7 +171,6 @@ def __init__( print(f"BROWSER_PATH: {new_env['BROWSER_PATH']}", file=sys.stderr) print(f"USER_DATA_DIR: {new_env['USER_DATA_DIR']}", file=sys.stderr) - # Defaults for loop if loop is None: try: @@ -198,7 +217,6 @@ def __aenter__(self): def __await__(self): return self.__aenter__().__await__() - def _open(self): stderr = self._stderr env = self._env @@ -207,7 +225,8 @@ def _open(self): [ sys.executable, os.path.join( - os.path.dirname(os.path.realpath(__file__)), "chrome_wrapper.py" + os.path.dirname(os.path.realpath(__file__)), + "chrome_wrapper.py", ), ], close_fds=True, @@ -218,18 +237,22 @@ def _open(self): ) else: from .chrome_wrapper import open_browser - self.subprocess = open_browser(to_chromium=self.pipe.read_to_chromium, - from_chromium=self.pipe.write_from_chromium, - stderr=stderr, - env=env, - loop_hack=self.loop_hack) + self.subprocess = open_browser( + to_chromium=self.pipe.read_to_chromium, + from_chromium=self.pipe.write_from_chromium, + stderr=stderr, + env=env, + loop_hack=self.loop_hack, + ) async def _watchdog(self): self._watchdog_healthy = True - if self.debug: print("Starting watchdog", file=sys.stderr) + if self.debug: + print("Starting watchdog", file=sys.stderr) await self.subprocess.wait() - if self.lock.locked(): return # it was locked and closed + if self.lock.locked(): + return self._watchdog_healthy = False if self.debug: print("Browser is being closed because chrom* closed", file=sys.stderr) @@ -242,8 +265,6 @@ async def _watchdog(self): warnings.filterwarnings("ignore", category=TempDirWarning) self._retry_delete_manual(self._temp_dir_name, delete=True) - - async def _open_async(self): try: stderr = self._stderr @@ -252,7 +273,8 @@ async def _open_async(self): self.subprocess = await asyncio.create_subprocess_exec( sys.executable, os.path.join( - os.path.dirname(os.path.realpath(__file__)), "chrome_wrapper.py" + os.path.dirname(os.path.realpath(__file__)), + "chrome_wrapper.py", ), stdin=self.pipe.read_to_chromium, stdout=self.pipe.write_from_chromium, @@ -262,22 +284,30 @@ async def _open_async(self): ) else: from .chrome_wrapper import open_browser - self.subprocess = await open_browser(to_chromium=self.pipe.read_to_chromium, - from_chromium=self.pipe.write_from_chromium, - stderr=stderr, - env=env, - loop=True, - loop_hack=self.loop_hack) + + self.subprocess = await open_browser( + to_chromium=self.pipe.read_to_chromium, + from_chromium=self.pipe.write_from_chromium, + stderr=stderr, + env=env, + loop=True, + loop_hack=self.loop_hack, + ) self.loop.create_task(self._watchdog()) await self.populate_targets() self.future_self.set_result(self) except (BrowserClosedError, BrowserFailedError, asyncio.CancelledError) as e: - raise BrowserFailedError("The browser seemed to close immediately after starting. Perhaps adding debug_browser=True will help.") from e + raise BrowserFailedError( + "The browser seemed to close immediately after starting. Perhaps adding debug_browser=True will help.", + ) from e def _retry_delete_manual(self, path, delete=False): if not os.path.exists(path): if self.debug: - print("No retry delete manual necessary, path doesn't exist", file=sys.stderr) + print( + "No retry delete manual necessary, path doesn't exist", + file=sys.stderr, + ) return 0, 0, [] n_dirs = 0 n_files = 0 @@ -293,7 +323,8 @@ def _retry_delete_manual(self, path, delete=False): try: os.chmod(fp, stat.S_IWUSR) os.remove(fp) - if self.debug: print("Success", file=sys.stderr) + if self.debug: + print("Success", file=sys.stderr) except BaseException as e: errors.append((fp, e)) for d in dirs: @@ -303,7 +334,8 @@ def _retry_delete_manual(self, path, delete=False): try: os.chmod(fp, stat.S_IWUSR) os.rmdir(fp) - if self.debug: print("Success", file=sys.stderr) + if self.debug: + print("Success", file=sys.stderr) except BaseException as e: errors.append((fp, e)) # clean up directory @@ -315,7 +347,8 @@ def _retry_delete_manual(self, path, delete=False): errors.append((path, e)) if errors: warnings.warn( - f"The temporary directory could not be deleted, execution will continue. errors: {errors}", TempDirWarning + f"The temporary directory could not be deleted, execution will continue. errors: {errors}", + TempDirWarning, ) return n_dirs, n_files, errors @@ -326,11 +359,13 @@ def _clean_temp(self): # no faith in this python implementation, always fails with windows # very unstable recently as well, lots new arguments in tempfile package self.temp_dir.cleanup() - clean=True + clean = True except BaseException as e: if self.debug: - print(f"First tempdir deletion failed: TempDirWarning: {str(e)}", file=sys.stderr) - + print( + f"First tempdir deletion failed: TempDirWarning: {str(e)}", + file=sys.stderr, + ) def remove_readonly(func, path, excinfo): os.chmod(path, stat.S_IWUSR) @@ -339,35 +374,47 @@ def remove_readonly(func, path, excinfo): try: if with_onexc: shutil.rmtree(self._temp_dir_name, onexc=remove_readonly) - clean=True + clean = True else: shutil.rmtree(self._temp_dir_name, onerror=remove_readonly) - clean=True + clean = True del self.temp_dir except FileNotFoundError: - pass # it worked! + pass # it worked! except BaseException as e: if self.debug: - print(f"Second tmpdir deletion failed (shutil.rmtree): {str(e)}", file=sys.stderr) + print( + f"Second tmpdir deletion failed (shutil.rmtree): {str(e)}", + file=sys.stderr, + ) if not clean: + def extra_clean(): time.sleep(2) self._retry_delete_manual(name, delete=True) + t = Thread(target=extra_clean) t.run() if self.debug: - print(f"Tempfile still exists?: {bool(os.path.exists(str(name)))}", file=sys.stderr) + print( + f"Tempfile still exists?: {bool(os.path.exists(str(name)))}", + file=sys.stderr, + ) async def _is_closed_async(self, wait=0): if self.debug: print(f"is_closed called with wait: {wait}", file=sys.stderr) if self.loop_hack: - if self.debug: print(f"Moving sync close to thread as self.loop_hack: {self.loop_hack}", file=sys.stderr) + if self.debug: + print( + f"Moving sync close to thread as self.loop_hack: {self.loop_hack}", + file=sys.stderr, + ) return await asyncio.to_thread(self._is_closed, wait) waiter = self.subprocess.wait() try: - if wait == 0: # this never works cause processing - wait = .15 + if wait == 0: # this never works cause processing + wait = 0.15 await asyncio.wait_for(waiter, wait) return True except Exception: @@ -383,23 +430,29 @@ def _is_closed(self, wait=0): try: self.subprocess.wait(wait) return True - except: # noqa + except: # noqa return False # _sync_close and _async_close are basically the same thing def _sync_close(self): if self._is_closed(): - if self.debug: print("Browser was already closed.", file=sys.stderr) + if self.debug: + print("Browser was already closed.", file=sys.stderr) return # check if no sessions or targets self.send_command("Browser.close") if self._is_closed(): - if self.debug: print("Browser.close method closed browser", file=sys.stderr) + if self.debug: + print("Browser.close method closed browser", file=sys.stderr) return self.pipe.close() - if self._is_closed(wait = 1): - if self.debug: print("pipe.close() (or slow Browser.close) method closed browser", file=sys.stderr) + if self._is_closed(wait=1): + if self.debug: + print( + "pipe.close() (or slow Browser.close) method closed browser", + file=sys.stderr, + ) return # Start a kill @@ -410,33 +463,37 @@ def _sync_close(self): stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) - if self._is_closed(wait = 4): + if self._is_closed(wait=4): return else: raise RuntimeError("Couldn't kill browser subprocess") else: self.subprocess.terminate() if self._is_closed(): - if self.debug: print("terminate() closed the browser", file=sys.stderr) + if self.debug: + print("terminate() closed the browser", file=sys.stderr) return self.subprocess.kill() - if self._is_closed(wait = 4): - if self.debug: print("kill() closed the browser", file=sys.stderr) + if self._is_closed(wait=4): + if self.debug: + print("kill() closed the browser", file=sys.stderr) return - async def _async_close(self): if await self._is_closed_async(): - if self.debug: print("Browser was already closed.", file=sys.stderr) + if self.debug: + print("Browser was already closed.", file=sys.stderr) return await asyncio.wait([self.send_command("Browser.close")], timeout=1) if await self._is_closed_async(): - if self.debug: print("Browser.close method closed browser", file=sys.stderr) + if self.debug: + print("Browser.close method closed browser", file=sys.stderr) return self.pipe.close() if await self._is_closed_async(wait=1): - if self.debug: print("pipe.close() method closed browser", file=sys.stderr) + if self.debug: + print("pipe.close() method closed browser", file=sys.stderr) return # Start a kill @@ -447,50 +504,72 @@ async def _async_close(self): stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) - if await self._is_closed_async(wait = 4): + if await self._is_closed_async(wait=4): return else: raise RuntimeError("Couldn't kill browser subprocess") else: self.subprocess.terminate() if await self._is_closed_async(): - if self.debug: print("terminate() closed the browser", file=sys.stderr) + if self.debug: + print("terminate() closed the browser", file=sys.stderr) return self.subprocess.kill() - if await self._is_closed_async(wait = 4): - if self.debug: print("kill() closed the browser", file=sys.stderr) + if await self._is_closed_async(wait=4): + if self.debug: + print("kill() closed the browser", file=sys.stderr) return - def close(self): if self.loop: + async def close_task(): if self.lock.locked(): return await self.lock.acquire() if not self.future_self.done(): - self.future_self.set_exception(BrowserFailedError("Close() was called before the browser finished opening- maybe it crashed?")) + self.future_self.set_exception( + BrowserFailedError( + "Close() was called before the browser finished opening- maybe it crashed?", + ), + ) for future in self.futures.values(): - if future.done(): continue - future.set_exception(BrowserClosedError("Command not completed because browser closed.")) + if future.done(): + continue + future.set_exception( + BrowserClosedError( + "Command not completed because browser closed.", + ), + ) for session in self.sessions.values(): for futures in session.subscriptions_futures.values(): for future in futures: - if future.done(): continue - future.set_exception(BrowserClosedError("Event not complete because browser closed.")) + if future.done(): + continue + future.set_exception( + BrowserClosedError( + "Event not complete because browser closed.", + ), + ) for tab in self.tabs.values(): for session in tab.sessions.values(): for futures in session.subscriptions_futures.values(): for future in futures: - if future.done(): continue - future.set_exception(BrowserClosedError("Event not completed because browser closed.")) + if future.done(): + continue + future.set_exception( + BrowserClosedError( + "Event not completed because browser closed.", + ), + ) try: await self._async_close() except ProcessLookupError: pass self.pipe.close() - self._clean_temp() # can we make async + self._clean_temp() # can we make async + return asyncio.create_task(close_task()) else: try: @@ -509,7 +588,7 @@ def __exit__(self, type, value, traceback): async def __aexit__(self, type, value, traceback): await self.close() - # Basic syncronous functions + # Basic synchronous functions def _add_tab(self, tab): if not isinstance(tab, Tab): @@ -525,16 +604,16 @@ def get_tab(self): if self.tabs.values(): return list(self.tabs.values())[0] - # Better functions that require asyncronous + # Better functions that require asynchronous async def create_tab(self, url="", width=None, height=None): if not self.loop: raise RuntimeError( - "There is no eventloop, or was not passed to browser. Cannot use async methods" + "There is no eventloop, or was not passed to browser. Cannot use async methods", ) if self.headless and (width or height): warnings.warn( - "Width and height only work for headless chrome mode, they will be ignored." + "Width and height only work for headless chrome mode, they will be ignored.", ) width = None height = None @@ -547,7 +626,7 @@ async def create_tab(self, url="", width=None, height=None): response = await self.browser.send_command("Target.createTarget", params=params) if "error" in response: raise RuntimeError("Could not create tab") from DevtoolsProtocolError( - response + response, ) target_id = response["result"]["targetId"] new_tab = Tab(target_id, self) @@ -558,7 +637,7 @@ async def create_tab(self, url="", width=None, height=None): async def close_tab(self, target_id): if not self.loop: raise RuntimeError( - "There is no eventloop, or was not passed to browser. Cannot use async methods" + "There is no eventloop, or was not passed to browser. Cannot use async methods", ) if self.lock.locked(): raise BrowserClosedError("Calling commands after closed browser") @@ -575,23 +654,23 @@ async def close_tab(self, target_id): self._remove_tab(target_id) if "error" in response: raise RuntimeError("Could not close tab") from DevtoolsProtocolError( - response + response, ) return response async def create_session(self): if not self.browser.loop: raise RuntimeError( - "There is no eventloop, or was not passed to browser. Cannot use async methods" + "There is no eventloop, or was not passed to browser. Cannot use async methods", ) warnings.warn( "Creating new sessions on Browser() only works with some versions of Chrome, it is experimental.", - ExperimentalFeatureWarning + ExperimentalFeatureWarning, ) response = await self.browser.send_command("Target.attachToBrowserTarget") if "error" in response: raise RuntimeError("Could not create session") from DevtoolsProtocolError( - response + response, ) session_id = response["result"]["sessionId"] new_session = Session(self, session_id) @@ -604,7 +683,7 @@ async def populate_targets(self): response = await self.browser.send_command("Target.getTargets") if "error" in response: raise RuntimeError("Could not get targets") from Exception( - response["error"] + response["error"], ) for json_response in response["result"]["targetInfos"]: @@ -621,8 +700,8 @@ async def populate_targets(self): if self.debug: print( f"Target {target_id} not found (could be closed before)", - file=sys.stderr - ) + file=sys.stderr, + ) continue else: raise e @@ -639,7 +718,8 @@ def run_output_thread(self, debug=None): debug = self.debug def run_print(debug): - if debug: print("Starting run_print loop", file=sys.stderr) + if debug: + print("Starting run_print loop", file=sys.stderr) while True: try: responses = self.pipe.read_jsons(debug=debug) @@ -669,10 +749,14 @@ def check_error(result): print(f"Error in run_read_loop: {str(e)}", file=sys.stderr) if not isinstance(e, asyncio.CancelledError): raise e + async def read_loop(): try: responses = await self.loop.run_in_executor( - self.executor, self.pipe.read_jsons, True, self.debug + self.executor, + self.pipe.read_jsons, + True, + self.debug, ) for response in responses: error = self.protocol.get_error(response) @@ -681,10 +765,12 @@ async def read_loop(): raise DevtoolsProtocolError(response) elif self.protocol.is_event(response): ### INFORMATION WE NEED FOR EVERY EVENT - event_session_id = response.get("sessionId", "") # GET THE SESSION THAT THE EVENT CAME IN ON + event_session_id = response.get( + "sessionId", + "", + ) # GET THE SESSION THAT THE EVENT CAME IN ON event_session = self.protocol.sessions[event_session_id] - ### INFORMATION FOR JUST USER SUBSCRIPTIONS subscriptions = event_session.subscriptions subscriptions_futures = event_session.subscriptions_futures @@ -696,33 +782,53 @@ async def read_loop(): ].startswith(sub_key[:-1]) equals_method = response["method"] == sub_key if self.debug: - print(f"Checking subscription key: {sub_key} against event method {response['method']}", file=sys.stderr) + print( + f"Checking subscription key: {sub_key} against event method {response['method']}", + file=sys.stderr, + ) if similar_strings or equals_method: self.loop.create_task( - subscriptions[sub_key][0](response) + subscriptions[sub_key][0](response), ) - if not subscriptions[sub_key][1]: # if not repeating - self.protocol.sessions[event_session_id].unsubscribe(sub_key) + if not subscriptions[sub_key][1]: # if not repeating + self.protocol.sessions[ + event_session_id + ].unsubscribe(sub_key) ### THIS IS FOR SUBSCRIBE_ONCE (that's not clear from variable names) for sub_key, futures in list(subscriptions_futures.items()): - similar_strings = sub_key.endswith("*") and response["method"].startswith(sub_key[:-1]) + similar_strings = sub_key.endswith("*") and response[ + "method" + ].startswith(sub_key[:-1]) equals_method = response["method"] == sub_key if self.debug: - print(f"Checking subscription key: {sub_key} against event method {response['method']}", file=sys.stderr) + print( + f"Checking subscription key: {sub_key} against event method {response['method']}", + file=sys.stderr, + ) if similar_strings or equals_method: for future in futures: if self.debug: - print(f"Processing future {id(future)}", file=sys.stderr) + print( + f"Processing future {id(future)}", + file=sys.stderr, + ) future.set_result(response) if self.debug: - print(f"Future resolved with response {future}", file=sys.stderr) + print( + f"Future resolved with response {future}", + file=sys.stderr, + ) del event_session.subscriptions_futures[sub_key] ### JUST INTERNAL STUFF if response["method"] == "Target.detachedFromTarget": - session_closed = response["params"].get("sessionId", "") # GET THE SESSION THAT WAS CLOSED - if session_closed == "": continue # not actually possible to close browser session this way... + session_closed = response["params"].get( + "sessionId", + "", + ) # GET THE SESSION THAT WAS CLOSED + if session_closed == "": + continue # not actually possible to close browser session this way... target_closed = self._get_target_for_session(session_closed) if target_closed: target_closed._remove_session(session_closed) @@ -730,16 +836,16 @@ async def read_loop(): if self.debug: print( f"Use intern subscription key: 'Target.detachedFromTarget'. Session {session_closed} was closed.", - file=sys.stderr - ) - + file=sys.stderr, + ) elif key: future = None if key in self.futures: if self.debug: print( - f"run_read_loop() found future for key {key}", file=sys.stderr + f"run_read_loop() found future for key {key}", + file=sys.stderr, ) future = self.futures.pop(key) elif "error" in response: @@ -751,7 +857,10 @@ async def read_loop(): else: future.set_result(response) else: - warnings.warn(f"Unhandled message type:{str(response)}", UnhandledMessageWarning) + warnings.warn( + f"Unhandled message type:{str(response)}", + UnhandledMessageWarning, + ) except PipeClosedError: if self.debug: print("PipeClosedError caught", file=sys.stderr) @@ -769,8 +878,11 @@ def write_json(self, obj): future = self.loop.create_future() self.futures[key] = future res = self.loop.run_in_executor( - self.executor, self.pipe.write_json, obj + self.executor, + self.pipe.write_json, + obj, ) # ignore result + def check_future(fut): if fut.exception(): if self.debug: @@ -780,73 +892,9 @@ def check_future(fut): future.set_exception(fut.exception()) print("Exception set", file=sys.stderr) self.close() + res.add_done_callback(check_future) return future else: self.pipe.write_json(obj) return key - -# this is the dtdoctor.exe function to help get debug reports -# it is not really part of this program -def diagnose(): - import subprocess, sys, time # noqa - import argparse - parser = argparse.ArgumentParser(description='tool to help debug problems') - parser.add_argument('--no-run', dest='run', action='store_false') - parser.set_defaults(run=True) - run = parser.parse_args().run - fail = [] - print("*".center(50, "*")) - print("Collecting information about the system:".center(50, "*")) - print(platform.system()) - print(platform.release()) - print(platform.version()) - print(platform.uname()) - print("Looking for browser:".center(50, "*")) - print(which_browser(debug=True)) - try: - print("Looking for version info:".center(50, "*")) - print(subprocess.check_output([sys.executable, '-m', 'pip', 'freeze'])) - print(subprocess.check_output(["git", "describe", "--all", "--tags", "--long", "--always",])) - print(sys.version) - print(sys.version_info) - except BaseException as e: - fail.append(("System Info", e)) - finally: - print("Done with version info.".center(50, "*")) - pass - if run: - try: - print("Sync test headless".center(50, "*")) - browser = Browser(debug=True, debug_browser=True, headless=True) - time.sleep(3) - browser.close() - except BaseException as e: - fail.append(("Sync test headless", e)) - finally: - print("Done with sync test headless".center(50, "*")) - async def test_headless(): - browser = await Browser(debug=True, debug_browser=True, headless=True) - await asyncio.sleep(3) - await browser.close() - try: - print("Async Test headless".center(50, "*")) - asyncio.run(test_headless()) - except BaseException as e: - fail.append(("Async test headless", e)) - finally: - print("Done with async test headless".center(50, "*")) - print("") - sys.stdout.flush() - sys.stderr.flush() - if fail: - import traceback - for exception in fail: - try: - print(f"Error in: {exception[0]}") - traceback.print_exception(exception[1]) - except BaseException: - print("Couldn't print traceback for:") - print(str(exception)) - raise BaseException("There was an exception, see above.") - print("Thank you! Please share these results with us!") diff --git a/choreographer/chrome_wrapper.py b/choreographer/chrome_wrapper.py index 12095951..e46fb4ee 100644 --- a/choreographer/chrome_wrapper.py +++ b/choreographer/chrome_wrapper.py @@ -15,8 +15,8 @@ import subprocess # noqa import signal # noqa import platform # noqa -import asyncio #noqa -import sys #noqa +import asyncio # noqa +import sys # noqa system = platform.system() if system == "Windows": @@ -25,7 +25,15 @@ os.set_inheritable(4, True) os.set_inheritable(3, True) -def open_browser(to_chromium, from_chromium, stderr=sys.stderr, env=None, loop=None, loop_hack=False): + +def open_browser( + to_chromium, + from_chromium, + stderr=sys.stderr, + env=None, + loop=None, + loop_hack=False, +): path = env.get("BROWSER_PATH") if not path: raise RuntimeError("No browser path was passed to run") @@ -41,7 +49,7 @@ def open_browser(to_chromium, from_chromium, stderr=sys.stderr, env=None, loop=N "--enable-logging=stderr", f"--user-data-dir={user_data_dir}", "--no-first-run", - "--enable-unsafe-swiftshader" + "--enable-unsafe-swiftshader", ] if not env.get("GPU_ENABLED", False): cli.append("--disable-gpu") @@ -49,7 +57,7 @@ def open_browser(to_chromium, from_chromium, stderr=sys.stderr, env=None, loop=N cli.append("--no-sandbox") if "HEADLESS" in env: - cli.append("--headless=old") # temporary fix + cli.append("--headless") system_dependent = {} if system == "Windows": @@ -58,12 +66,12 @@ def open_browser(to_chromium, from_chromium, stderr=sys.stderr, env=None, loop=N from_chromium_handle = msvcrt.get_osfhandle(from_chromium) os.set_handle_inheritable(from_chromium_handle, True) cli += [ - f"--remote-debugging-io-pipes={str(to_chromium_handle)},{str(from_chromium_handle)}" + f"--remote-debugging-io-pipes={str(to_chromium_handle)},{str(from_chromium_handle)}", ] system_dependent["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP system_dependent["close_fds"] = False else: - system_dependent["pass_fds"]=(to_chromium, from_chromium) + system_dependent["pass_fds"] = (to_chromium, from_chromium) if not loop: return subprocess.Popen( @@ -72,19 +80,22 @@ def open_browser(to_chromium, from_chromium, stderr=sys.stderr, env=None, loop=N **system_dependent, ) elif loop_hack: + def run(): return subprocess.Popen( cli, stderr=stderr, **system_dependent, ) + return asyncio.to_thread(run) else: return asyncio.create_subprocess_exec( - cli[0], - *cli[1:], - stderr=stderr, - **system_dependent) + cli[0], + *cli[1:], + stderr=stderr, + **system_dependent, + ) # THIS MAY BE PART OF KILL @@ -94,6 +105,7 @@ def kill_proc(*nope): process.wait(3) # 3 seconds to clean up nicely, it's a lot process.kill() + if __name__ == "__main__": process = open_browser(to_chromium=3, from_chromium=4, env=os.environ) signal.signal(signal.SIGTERM, kill_proc) diff --git a/choreographer/cli_utils.py b/choreographer/cli_utils.py new file mode 100644 index 00000000..dcc40b74 --- /dev/null +++ b/choreographer/cli_utils.py @@ -0,0 +1,220 @@ +import argparse +import asyncio +import json +import os +import platform +import shutil +import subprocess +import sys +import time +import urllib.request +import zipfile + +platforms = ["linux64", "win32", "win64", "mac-x64", "mac-arm64"] + +default_local_exe_path = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + "browser_exe", +) + +platform_detected = platform.system() +arch_size_detected = "64" if sys.maxsize > 2**32 else "32" +arch_detected = "arm" if platform.processor() == "arm" else "x" + +if platform_detected == "Windows": + chrome_platform_detected = "win" + arch_size_detected +elif platform_detected == "Linux": + chrome_platform_detected = "linux" + arch_size_detected +elif platform_detected == "Darwin": + chrome_platform_detected = "mac-" + arch_detected + arch_size_detected + +default_exe_name = None +if platform_detected.startswith("Linux"): + default_exe_name = os.path.join( + default_local_exe_path, + f"chrome-{chrome_platform_detected}", + "chrome", + ) +elif platform_detected.startswith("Darwin"): + default_exe_name = os.path.join( + default_local_exe_path, + f"chrome-{chrome_platform_detected}", + "Google Chrome for Testing.app", + "Contents", + "MacOS", + "Google Chrome for Testing", + ) +elif platform_detected.startswith("Win"): + default_exe_name = os.path.join( + default_local_exe_path, + f"chrome-{chrome_platform_detected}", + "chrome.exe", + ) + + +# https://stackoverflow.com/questions/39296101/python-zipfile-removes-execute-permissions-from-binaries +class ZipFilePermissions(zipfile.ZipFile): + def _extract_member(self, member, targetpath, pwd): + if not isinstance(member, zipfile.ZipInfo): + member = self.getinfo(member) + + path = super()._extract_member(member, targetpath, pwd) + # High 16 bits are os specific (bottom is st_mode flag) + attr = member.external_attr >> 16 + if attr != 0: + os.chmod(path, attr) + return path + + +def get_browser_cli(): + parser = argparse.ArgumentParser(description="tool to help debug problems") + parser.add_argument("--i", "-i", type=int, dest="i") + parser.add_argument("--platform", dest="platform") + parser.add_argument("--path", dest="path") # TODO, unused + parser.set_defaults(i=-1) + parser.set_defaults(path=default_local_exe_path) + parser.set_defaults(platform=chrome_platform_detected) + parsed = parser.parse_args() + i = parsed.i + platform = parsed.platform + path = parsed.path + if not platform or platform not in platforms: + raise RuntimeError( + f"You must specify a platform: linux64, win32, win64, mac-x64, mac-arm64, not {platform}", + ) + print(get_browser_sync(platform, i, path)) + + +def get_browser_sync( + platform=chrome_platform_detected, + i=-1, + path=default_local_exe_path, +): + browser_list = json.loads( + urllib.request.urlopen( + "https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json", + ).read(), + ) + chromium_sources = browser_list["versions"][i]["downloads"]["chrome"] + url = None + for src in chromium_sources: + if src["platform"] == platform: + url = src["url"] + break + if not os.path.exists(path): + os.makedirs(path) + filename = os.path.join(path, "chrome.zip") + with urllib.request.urlopen(url) as response, open(filename, "wb") as out_file: + shutil.copyfileobj(response, out_file) + with ZipFilePermissions(filename, "r") as zip_ref: + zip_ref.extractall(path) + + if platform.startswith("linux"): + exe_name = os.path.join(path, f"chrome-{platform}", "chrome") + elif platform.startswith("mac"): + exe_name = os.path.join( + path, + f"chrome-{platform}", + "Google Chrome for Testing.app", + "Contents", + "MacOS", + "Google Chrome for Testing", + ) + elif platform.startswith("win"): + exe_name = os.path.join(path, f"chrome-{platform}", "chrome.exe") + + return exe_name + + +# to_thread everything +async def get_browser( + platform=chrome_platform_detected, + i=-1, + path=default_local_exe_path, +): + return await asyncio.to_thread(get_browser_sync, platform=platform, i=i, path=path) + + +def diagnose(): + from choreographer import Browser, which_browser + + parser = argparse.ArgumentParser(description="tool to help debug problems") + parser.add_argument("--no-run", dest="run", action="store_false") + parser.add_argument("--show", dest="headless", action="store_false") + parser.set_defaults(run=True) + parser.set_defaults(headless=True) + args = parser.parse_args() + run = args.run + headless = args.headless + fail = [] + print("*".center(50, "*")) + print("SYSTEM:".center(50, "*")) + print(platform.system()) + print(platform.release()) + print(platform.version()) + print(platform.uname()) + print("BROWSER:".center(50, "*")) + print(which_browser(debug=True)) + print("VERSION INFO:".center(50, "*")) + try: + print("PIP:".center(25, "*")) + print(subprocess.check_output([sys.executable, "-m", "pip", "freeze"]).decode()) + except BaseException as e: + print(f"Error w/ pip: {e}") + try: + print("UV:".center(25, "*")) + print(subprocess.check_output(["uv", "pip", "freeze"]).decode()) + except BaseException as e: + print(f"Error w/ uv: {e}") + try: + print("GIT:".center(25, "*")) + print( + subprocess.check_output( + ["git", "describe", "--all", "--tags", "--long", "--always"], + ).decode(), + ) + except BaseException as e: + print(f"Error w/ git: {e}") + finally: + print(sys.version) + print(sys.version_info) + print("Done with version info.".center(50, "*")) + pass + if run: + try: + print("Sync Test Headless".center(50, "*")) + browser = Browser(debug=True, debug_browser=True, headless=headless) + time.sleep(3) + browser.close() + except BaseException as e: + fail.append(("Sync test headless", e)) + finally: + print("Done with sync test headless".center(50, "*")) + + async def test_headless(): + browser = await Browser(debug=True, debug_browser=True, headless=headless) + await asyncio.sleep(3) + await browser.close() + + try: + print("Async Test Headless".center(50, "*")) + asyncio.run(test_headless()) + except BaseException as e: + fail.append(("Async test headless", e)) + finally: + print("Done with async test headless".center(50, "*")) + print("") + sys.stdout.flush() + sys.stderr.flush() + if fail: + import traceback + + for exception in fail: + try: + print(f"Error in: {exception[0]}") + traceback.print_exception(exception[1]) + except BaseException: + print("Couldn't print traceback for:") + print(str(exception)) + raise BaseException("There was an exception, see above.") + print("Thank you! Please share these results with us!") diff --git a/choreographer/pipe.py b/choreographer/pipe.py index efefd62c..13f83b71 100644 --- a/choreographer/pipe.py +++ b/choreographer/pipe.py @@ -1,31 +1,27 @@ import os -import sys -import simplejson import platform +import sys import warnings from threading import Lock +import simplejson + with_block = bool(sys.version_info[:3] >= (3, 12) or platform.system() != "Windows") + + class BlockWarning(UserWarning): pass + # TODO: don't know about this # TODO: use has_attr instead of np.integer, you'll be fine class MultiEncoder(simplejson.JSONEncoder): """Special json encoder for numpy types""" def default(self, obj): - if ( - hasattr(obj, "dtype") - and obj.dtype.kind == "i" - and obj.shape == () - ): + if hasattr(obj, "dtype") and obj.dtype.kind == "i" and obj.shape == (): return int(obj) - elif ( - hasattr(obj, "dtype") - and obj.dtype.kind == "f" - and obj.shape == () - ): + elif hasattr(obj, "dtype") and obj.dtype.kind == "f" and obj.shape == (): return float(obj) elif hasattr(obj, "dtype") and obj.shape != (): return obj.tolist() @@ -37,6 +33,7 @@ def default(self, obj): class PipeClosedError(IOError): pass + class Pipe: def __init__(self, debug=False, json_encoder=MultiEncoder): self.read_from_chromium, self.write_from_chromium = list(os.pipe()) @@ -48,11 +45,16 @@ def __init__(self, debug=False, json_encoder=MultiEncoder): self.shutdown_lock = Lock() def serialize(self, obj): - message = simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, cls=self.json_encoder) - #if debug: + message = simplejson.dumps( + obj, + ensure_ascii=False, + ignore_nan=True, + cls=self.json_encoder, + ) + # if debug: # print(f"write_json: {message}", file=sys.stderr) - # windows may print weird characters if we set utf-8 instead of utf-16 - # check this TODO + # windows may print weird characters if we set utf-8 instead of utf-16 + # check this TODO return message.encode("utf-8") + b"\0" def deserialize(self, message): @@ -61,7 +63,8 @@ def deserialize(self, message): def write_json(self, obj, debug=None): if self.shutdown_lock.locked(): raise PipeClosedError() - if not debug: debug = self.debug + if not debug: + debug = self.debug if debug: print("write_json:", file=sys.stderr) encoded_message = self.serialize(obj) @@ -77,29 +80,39 @@ def read_jsons(self, blocking=True, debug=None): if self.shutdown_lock.locked(): raise PipeClosedError() if not with_block and not blocking: - warnings.warn("Windows python version < 3.12 does not support non-blocking", BlockWarning) + warnings.warn( + "Windows python version < 3.12 does not support non-blocking", + BlockWarning, + ) if not debug: debug = self.debug if debug: - print(f"read_jsons ({'blocking' if blocking else 'not blocking'}):", file=sys.stderr) + print( + f"read_jsons ({'blocking' if blocking else 'not blocking'}):", + file=sys.stderr, + ) jsons = [] try: - if with_block: os.set_blocking(self.read_from_chromium, blocking) + if with_block: + os.set_blocking(self.read_from_chromium, blocking) except OSError as e: self.close() raise PipeClosedError() from e try: - raw_buffer = None # if we fail in read, we already defined + raw_buffer = None # if we fail in read, we already defined raw_buffer = os.read( - self.read_from_chromium, 10000 + self.read_from_chromium, + 10000, ) # 10MB buffer, nbd, doesn't matter w/ this - if not raw_buffer or raw_buffer == b'{bye}\n': + if not raw_buffer or raw_buffer == b"{bye}\n": # we seem to need {bye} even if chrome closes NOTE - if debug: print("read_jsons pipe was closed, raising", file=sys.stderr) + if debug: + print("read_jsons pipe was closed, raising", file=sys.stderr) raise PipeClosedError() while raw_buffer[-1] != 0: # still not great, return what you have - if with_block: os.set_blocking(self.read_from_chromium, True) + if with_block: + os.set_blocking(self.read_from_chromium, True) raw_buffer += os.read(self.read_from_chromium, 10000) except BlockingIOError: if debug: @@ -109,7 +122,7 @@ def read_jsons(self, blocking=True, debug=None): self.close() if debug: print(f"caught OSError in read() {str(e)}", file=sys.stderr) - if not raw_buffer or raw_buffer == b'{bye}\n': + if not raw_buffer or raw_buffer == b"{bye}\n": raise PipeClosedError() # TODO this could be hard to test as it is a real OS corner case # but possibly raw_buffer is partial @@ -123,7 +136,10 @@ def read_jsons(self, blocking=True, debug=None): jsons.append(self.deserialize(raw_message)) except BaseException as e: if debug: - print(f"Problem with {raw_message} in json: {e}", file=sys.stderr) + print( + f"Problem with {raw_message} in json: {e}", + file=sys.stderr, + ) if debug: # This debug is kinda late but the jsons package helps with decoding, since JSON optionally # allows escaping unicode characters, which chrome does (oof) @@ -132,7 +148,8 @@ def read_jsons(self, blocking=True, debug=None): def _unblock_fd(self, fd): try: - if with_block: os.set_blocking(fd, False) + if with_block: + os.set_blocking(fd, False) except BaseException as e: if self.debug: print(f"Expected error unblocking {str(fd)}: {str(e)}", file=sys.stderr) @@ -147,10 +164,13 @@ def _close_fd(self, fd): def _fake_bye(self): self._unblock_fd(self.write_from_chromium) try: - os.write(self.write_from_chromium, b'{bye}\n') + os.write(self.write_from_chromium, b"{bye}\n") except BaseException as e: if self.debug: - print(f"Caught expected error in self-wrte bye: {str(e)}", file=sys.stderr) + print( + f"Caught expected error in self-wrte bye: {str(e)}", + file=sys.stderr, + ) def close(self): if self.shutdown_lock.acquire(blocking=False): @@ -160,7 +180,7 @@ def close(self): self._unblock_fd(self.read_from_chromium) self._unblock_fd(self.write_to_chromium) self._unblock_fd(self.read_to_chromium) - self._close_fd(self.write_to_chromium) # no more writes - self._close_fd(self.write_from_chromium) # we're done with writes - self._close_fd(self.read_from_chromium) # no more attemps at read - self._close_fd(self.read_to_chromium) # + self._close_fd(self.write_to_chromium) # no more writes + self._close_fd(self.write_from_chromium) # we're done with writes + self._close_fd(self.read_from_chromium) # no more attempts at read + self._close_fd(self.read_to_chromium) # diff --git a/choreographer/protocol.py b/choreographer/protocol.py index b5ebed61..b994580a 100644 --- a/choreographer/protocol.py +++ b/choreographer/protocol.py @@ -12,14 +12,14 @@ class MessageTypeError(TypeError): def __init__(self, key, value, expected_type): value = type(value) if not isinstance(value, type) else value super().__init__( - f"Message with key {key} must have type {expected_type}, not {value}." + f"Message with key {key} must have type {expected_type}, not {value}.", ) class MissingKeyError(ValueError): def __init__(self, key, obj): super().__init__( - f"Message missing required key/s {key}. Message received: {obj}" + f"Message missing required key/s {key}. Message received: {obj}", ) @@ -61,7 +61,7 @@ def verify_json(self, obj): if len(obj.keys()) != n_keys: raise RuntimeError( - "Message objects must have id and method keys, and may have params and sessionId keys." + "Message objects must have id and method keys, and may have params and sessionId keys.", ) def match_key(self, response, key): diff --git a/choreographer/session.py b/choreographer/session.py index e75229fd..be1dae7e 100644 --- a/choreographer/session.py +++ b/choreographer/session.py @@ -31,7 +31,7 @@ def subscribe(self, string, callback, repeating=True): raise ValueError("You may use this method with a loop in Browser") if string in self.subscriptions: raise ValueError( - "You are already subscribed to this string, duplicate subscriptions are not allowed." + "You are already subscribed to this string, duplicate subscriptions are not allowed.", ) else: self.subscriptions[string] = (callback, repeating) @@ -41,7 +41,7 @@ def unsubscribe(self, string): raise ValueError("You may use this method with a loop in Browser") if string not in self.subscriptions: raise ValueError( - "Cannot unsubscribe as string is not present in subscriptions" + "Cannot unsubscribe as string is not present in subscriptions", ) del self.subscriptions[string] diff --git a/choreographer/system.py b/choreographer/system.py index 33475b56..4c6d7c75 100644 --- a/choreographer/system.py +++ b/choreographer/system.py @@ -1,9 +1,21 @@ -import shutil -import platform import os +import platform +import shutil import sys -chrome = ["chrome", "Chrome", "google-chrome", "google-chrome-stable", "Chrome.app", "Google Chrome", "Google Chrome.app", "chromium", "chromium-browser"] +from .cli_utils import default_exe_name + +chrome = [ + "chrome", + "Chrome", + "google-chrome", + "google-chrome-stable", + "Chrome.app", + "Google Chrome", + "Google Chrome.app", + "chromium", + "chromium-browser", +] chromium = ["chromium", "chromium-browser"] # firefox = // this needs to be tested # brave = // this needs to be tested @@ -12,21 +24,23 @@ system = platform.system() default_path_chrome = None + if system == "Windows": default_path_chrome = [ - r"c:\Program Files\Google\Chrome\Application\chrome.exe", - f"c:\\Users\\{os.environ.get('USER', 'default')}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe", - ] + r"c:\Program Files\Google\Chrome\Application\chrome.exe", + f"c:\\Users\\{os.environ.get('USER', 'default')}\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe", + ] elif system == "Linux": default_path_chrome = [ - "/usr/bin/google-chrome-stable", - "/usr/bin/google-chrome", - "/usr/bin/chrome", - ] -else: # assume mac, or system == "Darwin" + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chrome", + ] +else: # assume mac, or system == "Darwin" default_path_chrome = [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - ] + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ] + def which_windows_chrome(): try: @@ -47,6 +61,7 @@ def which_windows_chrome(): except BaseException: return None + def _is_exe(path): res = False try: @@ -54,28 +69,39 @@ def _is_exe(path): finally: return res -def which_browser(executable_name=chrome, debug=False): + +def which_browser(executable_name=chrome, debug=False, skip_local=False): + if debug: + print(f"Checking {default_exe_name}", file=sys.stderr) + if not skip_local and os.path.exists(default_exe_name): + if debug: + print(f"Found {default_exe_name}", file=sys.stderr) + return default_exe_name path = None if isinstance(executable_name, str): executable_name = [executable_name] if platform.system() == "Windows": os.environ["NoDefaultCurrentDirectoryInExePath"] = "0" for exe in executable_name: - if debug: print(f"looking for {exe}", file=sys.stderr) + if debug: + print(f"looking for {exe}", file=sys.stderr) if platform.system() == "Windows" and exe == "chrome": path = which_windows_chrome() if path and _is_exe(path): return path path = shutil.which(exe) - if debug: print(f"looking for {path}", file=sys.stderr) + if debug: + print(f"looking for {path}", file=sys.stderr) if path and _is_exe(path): return path default_path = [] if executable_name == chrome: default_path = default_path_chrome for candidate in default_path: - if debug: print(f"Looking at {candidate}", file=sys.stderr) + if debug: + print(f"Looking at {candidate}", file=sys.stderr) if _is_exe(candidate): return candidate - if debug: print("Found nothing...", file=sys.stderr) + if debug: + print("Found nothing...", file=sys.stderr) return None diff --git a/choreographer/target.py b/choreographer/target.py index 54979562..ef9df52f 100644 --- a/choreographer/target.py +++ b/choreographer/target.py @@ -1,9 +1,8 @@ import sys - from collections import OrderedDict -from .session import Session from .protocol import DevtoolsProtocolError +from .session import Session class Target: @@ -32,14 +31,15 @@ def _remove_session(self, session_id): async def create_session(self): if not self.browser.loop: raise RuntimeError( - "There is no eventloop, or was not passed to browser. Cannot use async methods" + "There is no eventloop, or was not passed to browser. Cannot use async methods", ) response = await self.browser.send_command( - "Target.attachToTarget", params=dict(targetId=self.target_id, flatten=True) + "Target.attachToTarget", + params=dict(targetId=self.target_id, flatten=True), ) if "error" in response: raise RuntimeError("Could not create session") from DevtoolsProtocolError( - response + response, ) session_id = response["result"]["sessionId"] new_session = Session(self.browser, session_id) @@ -49,7 +49,7 @@ async def create_session(self): async def close_session(self, session_id): if not self.browser.loop: raise RuntimeError( - "There is no eventloop, or was not passed to browser. Cannot use async methods" + "There is no eventloop, or was not passed to browser. Cannot use async methods", ) if isinstance(session_id, Session): session_id = session_id.session_id @@ -60,7 +60,7 @@ async def close_session(self, session_id): self._remove_session(session_id) if "error" in response: raise RuntimeError("Could not close session") from DevtoolsProtocolError( - response + response, ) print(f"The session {session_id} has been closed", file=sys.stderr) return response @@ -73,7 +73,7 @@ def send_command(self, command, params=None): def _get_first_session(self): if not self.sessions.values(): raise RuntimeError( - "Cannot use this method without at least one valid session" + "Cannot use this method without at least one valid session", ) return list(self.sessions.values())[0] diff --git a/pyproject.toml b/pyproject.toml index 7f50f945..5fa25d89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,10 +36,12 @@ dev = [ ] [project.scripts] -dtdoctor = "choreographer.browser:diagnose" +choreo_diagnose = "choreographer.cli_utils:diagnose" +choreo_get_browser = "choreographer.cli_utils:get_browser_cli" -[tool.ruff.lint] -ignore = ["E701"] +# Format breaks this anyway +# [tool.ruff.lint] +# ignore = ["E701"] [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" diff --git a/tests/conftest.py b/tests/conftest.py index c61769be..5070e46d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,22 +15,27 @@ ##### Parameterized Arguments # Are used to re-run tests under different conditions + @pytest.fixture(params=[True, False], ids=["enable_sandbox", ""]) def sandbox(request): return request.param + @pytest.fixture(params=[True, False], ids=["enable_gpu", ""]) def gpu(request): return request.param + @pytest.fixture(params=[True, False], ids=["headless", ""]) def headless(request): return request.param + @pytest.fixture(params=[True, False], ids=["debug", ""]) def debug(request): return request.param + @pytest.fixture(params=[True, False], ids=["debug_browser", ""]) def debug_browser(request): return request.param @@ -41,6 +46,7 @@ def pytest_addoption(parser): parser.addoption("--headless", action="store_true", dest="headless", default=True) parser.addoption("--no-headless", dest="headless", action="store_false") + # browser fixture will supply a browser for you @pytest_asyncio.fixture(scope="function", loop_scope="function") async def browser(request): @@ -48,7 +54,9 @@ async def browser(request): debug = request.config.get_verbosity() > 2 debug_browser = None if debug else False browser = await choreo.Browser( - headless=headless, debug=debug, debug_browser=debug_browser + headless=headless, + debug=debug, + debug_browser=debug_browser, ) temp_dir = browser._temp_dir_name yield browser @@ -59,6 +67,7 @@ async def browser(request): if os.path.exists(temp_dir): raise RuntimeError(f"Temporary directory not deleted successfully: {temp_dir}") + # add a timeout if tests requests browser # but if tests creates their own browser they are responsible # a fixture can be used to specify the timeout: timeout=10 @@ -69,21 +78,32 @@ def pytest_runtest_setup(item: pytest.Item): if "browser" in item.funcargs: raw_test_fn = item.obj timeouts = [k for k in item.funcargs if k.startswith("timeout")] - timeout = item.funcargs[timeouts[-1]] if len(timeouts) else pytest.default_timeout - if item.get_closest_marker("asyncio") and timeout: # "closest" because markers can be function/session/package etc + timeout = ( + item.funcargs[timeouts[-1]] if len(timeouts) else pytest.default_timeout + ) + if ( + item.get_closest_marker("asyncio") and timeout + ): # "closest" because markers can be function/session/package etc + async def wrapped_test_fn(*args, **kwargs): try: return await asyncio.wait_for( - raw_test_fn(*args, **kwargs), timeout=timeout - ) + raw_test_fn(*args, **kwargs), + timeout=timeout, + ) except TimeoutError: - pytest.fail(f"Test {item.name} failed a timeout. This can be extended, but shouldn't be. See conftest.py.") + pytest.fail( + f"Test {item.name} failed a timeout. This can be extended, but shouldn't be. See conftest.py.", + ) + item.obj = wrapped_test_fn + def pytest_configure(): # change this by command line TODO pytest.default_timeout = 5 + # pytests capture-but-display mechanics for output are somewhat spaghetti # more information here: # https://github.com/pytest-dev/pytest/pull/12854 @@ -94,25 +114,36 @@ def pytest_configure(): def capteesys(request): from _pytest import capture import warnings + if hasattr(capture, "capteesys"): - warnings.warn(( "You are using a polyfill for capteesys, but this" - " version of pytest supports it natively- you may" - f" remove the polyfill from your {__file__}"), - DeprecationWarning) + warnings.warn( + ( + "You are using a polyfill for capteesys, but this" + " version of pytest supports it natively- you may" + f" remove the polyfill from your {__file__}" + ), + DeprecationWarning, + ) # Remove next two lines if you don't want to ever switch to native version yield request.getfixturevalue("capteesys") return capman = request.config.pluginmanager.getplugin("capturemanager") - capture_fixture = capture.CaptureFixture(capture.SysCapture, request, _ispytest=True) + capture_fixture = capture.CaptureFixture( + capture.SysCapture, + request, + _ispytest=True, + ) + def _inject_start(): - self = capture_fixture # closure seems easier than importing Type or Partial + self = capture_fixture # closure seems easier than importing Type or Partial if self._capture is None: self._capture = capture.MultiCapture( - in_ = None, - out = self.captureclass(1, tee=True), - err = self.captureclass(2, tee=True) + in_=None, + out=self.captureclass(1, tee=True), + err=self.captureclass(2, tee=True), ) self._capture.start_capturing() + capture_fixture._start = _inject_start capman.set_fixture(capture_fixture) capture_fixture._start() diff --git a/tests/test_browser.py b/tests/test_browser.py index ed92d169..90f29e2d 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -4,6 +4,7 @@ # We no longer use live URLs to as not depend on the network + @pytest.mark.asyncio async def test_create_and_close_tab(browser): tab = await browser.create_tab("") @@ -58,7 +59,7 @@ async def test_browser_write_json(browser): RuntimeError, ): await browser.write_json( - {"id": 0, "method": "Target.getTargets", "invalid_parameter": "kamksamdk"} + {"id": 0, "method": "Target.getTargets", "invalid_parameter": "kamksamdk"}, ) # Test int method should return error @@ -95,10 +96,7 @@ async def test_browser_send_command(browser): async def test_populate_targets(browser): await browser.send_command(command="Target.createTarget", params={"url": ""}) await browser.populate_targets() - if browser.headless is False: - assert len(browser.tabs) == 2 - else: - assert len(browser.tabs) == 1 + assert len(browser.tabs) >= 1 @pytest.mark.asyncio diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py index 2ada6836..7a727ee4 100644 --- a/tests/test_placeholder.py +++ b/tests/test_placeholder.py @@ -5,6 +5,7 @@ # allows to create a browser pool for tests pytestmark = pytest.mark.asyncio(loop_scope="function") + async def test_placeholder(browser, capteesys): print("") assert "result" in await browser.send_command("Target.getTargets") diff --git a/tests/test_process.py b/tests/test_process.py index 2a718eab..8dbb11e9 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -9,29 +9,33 @@ import choreographer as choreo + @pytest.mark.asyncio(loop_scope="function") async def test_context(capteesys, headless, debug, debug_browser, sandbox, gpu): - async with choreo.Browser( - headless=headless, - debug=debug, - debug_browser=None if debug_browser else False, - enable_sandbox=sandbox, - enable_gpu=gpu - ) as browser, timeout(pytest.default_timeout): + async with ( + choreo.Browser( + headless=headless, + debug=debug, + debug_browser=None if debug_browser else False, + enable_sandbox=sandbox, + enable_gpu=gpu, + ) as browser, + timeout(pytest.default_timeout), + ): temp_dir = browser._temp_dir_name response = await browser.send_command(command="Target.getTargets") assert "result" in response and "targetInfos" in response["result"] - assert (len(response["result"]["targetInfos"]) != 0) != headless - if not headless: - assert isinstance(browser.get_tab(), choreo.tab.Tab) - assert len(browser.get_tab().sessions) == 1 - print("") # this makes sure that capturing is working + assert len(response["result"]["targetInfos"]) != 0 + assert isinstance(browser.get_tab(), choreo.tab.Tab) + assert len(browser.get_tab().sessions) == 1 + print("") # this makes sure that capturing is working # stdout should be empty, but not because capsys is broken, because nothing was print assert capteesys.readouterr().out == "\n", "stdout should be silent!" # let asyncio do some cleaning up if it wants, may prevent warnings await asyncio.sleep(0) assert not os.path.exists(temp_dir) + @pytest.mark.asyncio(loop_scope="function") async def test_no_context(capteesys, headless, debug, debug_browser, sandbox, gpu): browser = await choreo.Browser( @@ -39,25 +43,25 @@ async def test_no_context(capteesys, headless, debug, debug_browser, sandbox, gp debug=debug, debug_browser=None if debug_browser else False, enable_sandbox=sandbox, - enable_gpu=gpu + enable_gpu=gpu, ) temp_dir = browser._temp_dir_name try: async with timeout(pytest.default_timeout): response = await browser.send_command(command="Target.getTargets") assert "result" in response and "targetInfos" in response["result"] - assert (len(response["result"]["targetInfos"]) != 0) != headless - if not headless: - assert isinstance(browser.get_tab(), choreo.tab.Tab) - assert len(browser.get_tab().sessions) == 1 + assert len(response["result"]["targetInfos"]) != 0 + assert isinstance(browser.get_tab(), choreo.tab.Tab) + assert len(browser.get_tab().sessions) == 1 finally: await browser.close() - print("") # this make sure that capturing is working + print("") # this make sure that capturing is working assert capteesys.readouterr().out == "\n", "stdout should be silent!" await asyncio.sleep(0) assert not os.path.exists(temp_dir) -# Harrass choreographer with a kill in this test to see if its clean in a way + +# Harass choreographer with a kill in this test to see if its clean in a way # tempdir may survive protected by chromium subprocess surviving the kill @pytest.mark.asyncio(loop_scope="function") async def test_watchdog(capteesys, headless, debug, debug_browser): @@ -77,7 +81,9 @@ async def test_watchdog(capteesys, headless, debug, debug_browser): os.kill(browser.subprocess.pid, signal.SIGKILL) await asyncio.sleep(1.5) - with pytest.raises((choreo.browser.PipeClosedError, choreo.browser.BrowserClosedError)): + with pytest.raises( + (choreo.browser.PipeClosedError, choreo.browser.BrowserClosedError), + ): await browser.send_command(command="Target.getTargets") await browser.close() diff --git a/tests/test_serializer.py b/tests/test_serializer.py index c3e78f05..b75e922e 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -8,16 +8,17 @@ expected_message = b'[1, 2.0, 3, null, null, null, "1970-01-01T00:00:00"]\x00' converted_type = [int, float, int, type(None), type(None), type(None), str] + def test_de_serialize(): pipe = Pipe() message = pipe.serialize(data) assert message == expected_message - obj = pipe.deserialize(message[:-1]) # split out \0 + obj = pipe.deserialize(message[:-1]) # split out \0 for o, t in zip(obj, converted_type): assert isinstance(o, t) message_np = pipe.serialize(np.array(data)) assert message_np == expected_message - obj_np = pipe.deserialize(message_np[:-1]) # split out \0 + obj_np = pipe.deserialize(message_np[:-1]) # split out \0 for o, t in zip(obj_np, converted_type): assert isinstance(o, t) diff --git a/tests/test_tab.py b/tests/test_tab.py index ec370069..388a6725 100644 --- a/tests/test_tab.py +++ b/tests/test_tab.py @@ -5,6 +5,7 @@ import choreographer as choreo + # this ignores extra stuff in received- only that we at least have what is expected def check_response_dictionary(response_received, response_expected): for k, v in response_expected.items(): diff --git a/uv.lock b/uv.lock index bd63c342..11ea4b3d 100644 --- a/uv.lock +++ b/uv.lock @@ -16,7 +16,7 @@ wheels = [ [[package]] name = "choreographer" -version = "0.99.6.post14+git.dcbf2ad7.dirty" +version = "0.99.6.post35+git.843eb5ac.dirty" source = { editable = "." } dependencies = [ { name = "simplejson" }, @@ -229,16 +229,16 @@ wheels = [ [[package]] name = "poethepoet" -version = "0.31.1" +version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pastel" }, { name = "pyyaml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/96/15bce512c8027696b20586b18ac4b239afe8d312dbaa2a0099e8fb8f8424/poethepoet-0.31.1.tar.gz", hash = "sha256:d6b66074edf85daf115bb916eae0afd6387d19e1562e1c9ef7d61d5c585696aa", size = 61520 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/f6/1692a42cf426494d89dbc693ba55ebd653bd2e84bbb6b3da4127b87956df/poethepoet-0.32.0.tar.gz", hash = "sha256:a700be02e932e1a8907ae630928fc769ea9a77986189ba6867e6e3fd8f60e5b7", size = 62962 } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/e1/04f56c9d848d6135ca3328c5a2ca84d3303c358ad7828db290385e36a8cc/poethepoet-0.31.1-py3-none-any.whl", hash = "sha256:7fdfa0ac6074be9936723e7231b5bfaad2923e96c674a9857e81d326cf8ccdc2", size = 80238 }, + { url = "https://files.pythonhosted.org/packages/27/12/2994011e33d37772228439fe215fc022ff180b161ab7bd8ea5ac92717556/poethepoet-0.32.0-py3-none-any.whl", hash = "sha256:fba84c72d923feac228d1ea7734c5a54701f2e71fad42845f027c0fbf998a073", size = 81717 }, ] [[package]] @@ -260,14 +260,14 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.24.0" +version = "0.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +sdist = { url = "https://files.pythonhosted.org/packages/94/18/82fcb4ee47d66d99f6cd1efc0b11b2a25029f303c599a5afda7c1bca4254/pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609", size = 53298 } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, + { url = "https://files.pythonhosted.org/packages/88/56/2ee0cab25c11d4e38738a2a98c645a8f002e2ecf7b5ed774c70d53b92bb1/pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3", size = 19245 }, ] [[package]]