diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6b0c235 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" # See documentation for possible values + directory: ".github/workflows" # Location of package manifests + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..38971cc --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,22 @@ +name: Automated test + +on: + workflow_dispatch: + pull_request: + push: + +jobs: + test: + uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v1 + with: + envs: | + - linux: py310-test + - linux: py311-test + - linux: py312-test + - linux: py313-test + - macos: py310-test + - macos: py311-test + - macos: py312-test + - macos: py313-test + - windows: py312-test + - windows: py313-test diff --git a/.gitignore b/.gitignore index 0ff5bf4..73489df 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ dist build .ipynb_checkpoints +__pycache__ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..45988c9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +ci: + autofix_prs: false + autoupdate_schedule: 'monthly' + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ["--enforce-all", "--maxkb=300"] + - id: check-case-conflict + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: check-xml + - id: check-yaml + exclude: ".*(.github.*)$" + - id: detect-private-key + - id: end-of-file-fixer + exclude: ".*(data.*|extern.*|licenses.*|_static.*|_parsetab.py)$" + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.3.4" + hooks: + - id: ruff + args: ["--fix", "--show-fixes"] + - id: ruff-format diff --git a/README.md b/README.md index adb5155..9dd581c 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,53 @@ This repository contains an experimental utility to monitor the visual output of cells from Jupyter notebooks. -## Requirements - -On the machine being used to run the ``monitor_cells.py``: +## Installing -* [numpy](https://numpy.org) -* [click](https://click.palletsprojects.com/en/stable/) -* [pillow](https://python-pillow.org/) -* [playwright](https://pypi.org/project/playwright/) +To install, check out this repository and: -On the Jupyter Lab server, optionally (but recommended): + pip install -e . -* [jupyter-collaboration](https://github.com/jupyterlab/jupyter-collaboration) +Python 3.10 or later is supported (Python 3.12 or later on Windows). -If this is the first time using playwright, you will need to run:: +If this is the first time using playwright, you will also need to run: playwright install firefox -## Installing +## Quick start -To install, check out this repository and: +First, write one or more blocks of code you want to benchmark each in a cell. In +addition, as early as possible in the notebook, make sure you set the border +color on any ipywidget layout you want to record: - pip install -e . + widget.layout.border = '1px solid rgb(143, 56, 3)' + +The R and G values should be kept as (143, 56), and the B color should be unique for each widget and be a value between 0 and 255 (inclusive). + +Then, to run the notebook and monitor the changes in widget output, run: + + jupyter-output-monitor --notebook mynotebook.ipynb + +Where ``mynotebook.ipynb`` is the name of your notebook. By default, this will +open a window showing you what is happening, but you can also pass ``--headless`` +to run in headless mode. + +## Using this on a remote Jupyter Lab instance + +If you want to test this on an existing Jupyter Lab instance, including +remote ones, you can use ``--url`` instead of ``--notebook``: + + jupyter-output-monitor http://localhost:8987/lab/tree/notebook.ipynb?token=7bb9a... + +Note that the URL should include the path to the notebook, and will likely +require the token too. + +You should make sure that all output cells in the notebook have been cleared +before running the above command, and that the widget border color has been +set as mention in the **Quick start** guide above. + +If you make use of the [jupyter-collaboration](https://github.com/jupyterlab/jupyter-collaboration) plugin on the Jupyter Lab server, you will be able to +more easily e.g. clear the output between runs and edit the notebook in +between runs of ``jupyter-output-monitor``. ## How this works @@ -83,46 +108,8 @@ and if using jdaviz: To stop recording output for a given cell, you can set the border attribute to ``''``. -## Headless vs non-headless mode - -By default, the script will open up a window and show what it is doing. It will -also wait until it detects any input cells before proceeding. This then gives -you the opportunity to enter any required passwords, and open the correct -notebook. However, note that if Jupyter Lab opens up with a different notebook -to the one you want by default, it will start executing that one! It's also -better if the notebook starts off with output cells cleared, otherwise the script -may start taking screenshots straight away. - -The easiest way to ensure that the correct notebook gets executed and that it -has had its output cells cleared is to make use of the -[jupyter-collaboration](https://github.com/jupyterlab/jupyter-collaboration) -plugin. With this plugin installed, you can open Jupyter Lab in a regular browser window, -and set it up so that the correct notebook is open by default and has its cells cleared, -and you can then launch the monitoring script. In fact, if you do this you can then -also run the script in headless mode since you know it should be doing the right thing. - -One final note is that to avoid any jumping up and down of the notebook during -execution, the window opened by the script has a very large height so that the -full notebook fits inside the window without scrolling. - -## How to use - -* Assuming you have installed - [jupyter-collaboration](https://github.com/jupyterlab/jupyter-collaboration), - start up Jupyter Lab instance on a regular browser and go to the notebook you - want to profile. -* If not already done, write one or more blocks of code you want to benchmark - each in a cell. In addition, as early as possible in the notebook, make sure - you set the border color on any ipywidget layout you want to record. -* Make sure the notebook you want to profile is the main one opened and that - you have cleared any output cells. -* Run the main command in this package, specifying the URL to connect to for Jupyter Lab, e.g.: - - jupyter-output-monitor http://localhost:8987 - ## Settings - ### Headless To run in headless mode, include ``--headless`` diff --git a/jupyter_output_monitor/__init__.py b/jupyter_output_monitor/__init__.py index 0069135..598ec7d 100644 --- a/jupyter_output_monitor/__init__.py +++ b/jupyter_output_monitor/__init__.py @@ -1,2 +1,4 @@ from ._monitor import monitor from ._version import __version__ + +__all__ = ["monitor", "__version__"] diff --git a/jupyter_output_monitor/__main__.py b/jupyter_output_monitor/__main__.py new file mode 100644 index 0000000..12c22cd --- /dev/null +++ b/jupyter_output_monitor/__main__.py @@ -0,0 +1,4 @@ +from ._monitor import monitor + +if __name__ == "__main__": + monitor() diff --git a/jupyter_output_monitor/__pycache__/__init__.cpython-311.pyc b/jupyter_output_monitor/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 49610c7..0000000 Binary files a/jupyter_output_monitor/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/jupyter_output_monitor/__pycache__/_monitor.cpython-311.pyc b/jupyter_output_monitor/__pycache__/_monitor.cpython-311.pyc deleted file mode 100644 index 810be36..0000000 Binary files a/jupyter_output_monitor/__pycache__/_monitor.cpython-311.pyc and /dev/null differ diff --git a/jupyter_output_monitor/__pycache__/_version.cpython-311.pyc b/jupyter_output_monitor/__pycache__/_version.cpython-311.pyc deleted file mode 100644 index 1f1bd39..0000000 Binary files a/jupyter_output_monitor/__pycache__/_version.cpython-311.pyc and /dev/null differ diff --git a/jupyter_output_monitor/_convert.py b/jupyter_output_monitor/_convert.py new file mode 100644 index 0000000..92c3bc0 --- /dev/null +++ b/jupyter_output_monitor/_convert.py @@ -0,0 +1,107 @@ +# Experimental command to convert a notebook to a script that tests widget +# output with solara but without launching a Jupyter Lab instance. Not yet +# exposed via the public API. + +import os +from textwrap import indent + +import click +import nbformat + + +def remove_magics(source): + lines = [line for line in source.splitlines() if not line.startswith("%")] + return os.linesep.join(lines) + + +def remove_excludes(source): + lines = [ + line for line in source.splitlines() if not line.strip().endswith("# EXCLUDE") + ] + return os.linesep.join(lines) + + +HEADER = """ +import time +import solara +import playwright +import pytest_playwright +from IPython.display import display + +def watch_screenshot(widget): + display_start = time.time() + last_change_time = display_start + last_screenshot = None + while time.time() - display_start < 5: + screenshot_bytes = widget.screenshot() + if screenshot_bytes != last_screenshot: + last_screenshot = screenshot_bytes + last_change_time = time.time() + return last_screenshot, last_change_time - display_start + +def test_main(page_session, solara_test): + +""" + +DISPLAY_CODE = """ +object_to_capture.add_class("test-object") +display(object_to_capture) +captured_object = page_session.locator(".test-object") +captured_object.wait_for() +""" + +PROFILING_CODE = """ +last_screenshot, time_elapsed = watch_screenshot(captured_object) +print(f"Extra time waiting for display to update: {time_elapsed:.2f}s") +""" + + +@click.command() +@click.argument("input_notebook") +@click.argument("output_script") +def convert(input_notebook, output_script): + nb = nbformat.read(input_notebook, as_version=4) + + with open(output_script, "w") as f: + f.write(HEADER) + + captured = False + + for icell, cell in enumerate(nb["cells"]): + if cell.cell_type == "markdown": + f.write(indent(cell.source, " # ") + "\n\n") + elif cell.cell_type == "code": + if cell.source.strip() == "": + continue + + lines = cell.source.splitlines() + + new_lines = [] + + new_lines.append("cell_start = time.time()\n\n") + + for line in lines: + if line.startswith("%") or line.strip().endswith("# EXCLUDE"): + continue + elif line.endswith("# SCREENSHOT"): + new_lines.append("object_to_capture = " + line) + new_lines.extend(DISPLAY_CODE.splitlines()) + captured = True + else: + new_lines.append(line) + + new_lines.append("cell_end = time.time()\n") + new_lines.append( + f'print(f"Cell {icell:2d} Python code executed in {{cell_end - cell_start:.2f}}s")', + ) + + if captured: + new_lines.extend(PROFILING_CODE.splitlines()) + + source = os.linesep.join(new_lines) + + f.write(indent(source, " ") + "\n\n") + + +if __name__ == "__main__": + convert() diff --git a/jupyter_output_monitor/_monitor.py b/jupyter_output_monitor/_monitor.py index 5a08779..6791394 100644 --- a/jupyter_output_monitor/_monitor.py +++ b/jupyter_output_monitor/_monitor.py @@ -4,57 +4,91 @@ import os import sys +import tempfile import time -import click -import datetime +from io import BytesIO -import numpy as np +import click from PIL import Image from playwright.sync_api import sync_playwright -from io import BytesIO +from ._server import jupyter_server +from ._utils import clear_notebook, isotime RG_SPECIAL = (143, 56) -def isotime(): - return datetime.datetime.now().isoformat() @click.command() -@click.argument('url') -@click.option('--output', default=None, help='Output directory - if not specified, this defaults to output_') -@click.option('--wait-after-execute', default=10, help='Time in s to wait after executing each cell') -@click.option('--headless', is_flag=True, help='Whether to run in headless mode') -def monitor(url, output, wait_after_execute, headless): - +@click.option( + "--notebook", + default=None, + help="The notebook to profile. If specified a local Jupyter Lab instance will be run", +) +@click.option( + "--url", + default=None, + help="The URL hosting the notebook to profile, including any token and notebook path.", +) +@click.option( + "--output", + default=None, + help="Output directory - if not specified, this defaults to output_", +) +@click.option( + "--wait-after-execute", + default=10, + help="Time in s to wait after executing each cell", +) +@click.option("--headless", is_flag=True, help="Whether to run in headless mode") +def monitor(notebook, url, output, wait_after_execute, headless): if output is None: - output = f'output-{isotime()}' + output = f"output-{isotime()}" if os.path.exists(output): - print('Output directory {output} already exists') + print(f"Output directory {output} already exists") sys.exit(1) os.makedirs(output) + if notebook is None and url is None: + print("Either --notebook or --url should be specified") + sys.exit(1) + elif notebook is not None and url is not None: + print("Only one of --notebook or --url should be specified") + sys.exit(1) + elif notebook is not None: + # Create a temporary directory with a clean version of the notebook + notebook_dir = tempfile.mkdtemp() + clear_notebook(notebook, os.path.join(notebook_dir, "notebook.ipynb")) + with jupyter_server(notebook_dir) as server: + url = server.base_url + "/lab/tree/notebook.ipynb" + _monitor_output(url, output, wait_after_execute, headless) + else: + _monitor_output(url, output, wait_after_execute, headless) + + +def _monitor_output(url, output, wait_after_execute, headless): # Index of the current last screenshot, by output index last_screenshot = {} - with sync_playwright() as p, open(os.path.join(output, 'event_log.csv'), 'w') as log: - - log.write('time,event,index,screenshot\n') + with ( + sync_playwright() as p, + open(os.path.join(output, "event_log.csv"), "w") as log, + ): + log.write("time,event,index,screenshot\n") log.flush() # Launch browser and open URL browser = p.firefox.launch(headless=headless) - page = browser.new_page(viewport={'width':2000, 'height':10000}) + page = browser.new_page(viewport={"width": 2000, "height": 10000}) page.goto(url) while True: - - print('Checking for input cells') + print("Checking for input cells") # Construct list of input and output cells in the notebook - input_cells = list(page.query_selector_all('.jp-InputArea-editor')) + input_cells = list(page.query_selector_all(".jp-InputArea-editor")) # Keep only input cells that are visible input_cells = [cell for cell in input_cells if cell.is_visible()] @@ -62,23 +96,22 @@ def monitor(url, output, wait_after_execute, headless): if len(input_cells) > 0: break - print('-> No input cells found, waiting before checking again') + print("-> No input cells found, waiting before checking again") # If no visible input cells, wait and try again page.wait_for_timeout(1000) - print(f'{len(input_cells)} input cells found') + print(f"{len(input_cells)} input cells found") last_screenshot = {} # Now loop over each input cell and execute for input_index, input_cell in enumerate(input_cells): - - if input_cell.text_content().strip() == '': - print(f'Skipping empty input cell {input_index}') + if input_cell.text_content().strip() == "": + print(f"Skipping empty input cell {input_index}") continue - print(f'Execute input cell {input_index}') + print(f"Execute input cell {input_index}") # Take screenshot before we start executing cell but save it after screenshot_bytes = input_cell.screenshot() @@ -87,48 +120,51 @@ def monitor(url, output, wait_after_execute, headless): input_cell.click() # Execute it - page.keyboard.press('Shift+Enter') + page.keyboard.press("Shift+Enter") timestamp = isotime() - screenshot_filename = os.path.join(output, f'input-{input_index:03d}-{timestamp}.png') + # Colons are invalid in filenames on Windows + filename_timestamp = timestamp.replace(":", "-") + + screenshot_filename = os.path.join( + output, + f"input-{input_index:03d}-{filename_timestamp}.png", + ) image = Image.open(BytesIO(screenshot_bytes)) image.save(screenshot_filename) - log.write(f'{timestamp},execute-input,{input_index},{screenshot_filename}\n') + log.write( + f"{timestamp},execute-input,{input_index},{screenshot_filename}\n", + ) # Now loop and check for changes in any of the output cells - if a cell # output changes, save a screenshot - print('Watching for changes in output cells') + print("Watching for changes in output cells") start = time.time() while time.time() - start < wait_after_execute: - - output_cells = list(page.query_selector_all('.jp-OutputArea-output')) + output_cells = list(page.query_selector_all(".jp-OutputArea-output")) for output_cell in output_cells: - if not output_cell.is_visible(): continue # The element we are interested in is one level down - div = output_cell.query_selector('div') - - if div is None: - continue - - style = div.get_attribute('style') - - if style is None or 'border-color: rgb(' not in style: + for child in output_cell.query_selector_all("*"): + style = child.get_attribute("style") + if style is not None and "border-color: rgb(" in style: + break + else: continue # Parse rgb values for border - start_pos = style.index('border-color:') - start_pos = style.index('(', start_pos) + 1 - end_pos = style.index(')', start_pos) - r, g, b = [int(x) for x in style[start_pos:end_pos].split(',')] + start_pos = style.index("border-color:") + start_pos = style.index("(", start_pos) + 1 + end_pos = style.index(")", start_pos) + r, g, b = (int(x) for x in style[start_pos:end_pos].split(",")) # The (r,g) pair is chosen to be random and unlikely to # happen by chance on the page. If this values don't match, we @@ -142,30 +178,43 @@ def monitor(url, output, wait_after_execute, headless): # which should be sufficient output_index = b - print(f'- taking screenshot of output cell {output_index}') + print(f"- taking screenshot of output cell {output_index}") - screenshot_bytes = div.screenshot() + screenshot_bytes = child.screenshot() # If screenshot didn't exist before for this cell or if it has # changed, we save it to a file and keep track of it. - if output_index not in last_screenshot or last_screenshot[output_index] != screenshot_bytes: - - print(f' -> change detected!') + if ( + output_index not in last_screenshot + or last_screenshot[output_index] != screenshot_bytes + ): + print(" -> change detected!") timestamp = isotime() - screenshot_filename = os.path.join(output, f'output-{output_index:03d}-{timestamp}.png') + + # Colons are invalid in filenames on Windows + filename_timestamp = timestamp.replace(":", "-") + + screenshot_filename = os.path.join( + output, + f"output-{output_index:03d}-{filename_timestamp}.png", + ) image = Image.open(BytesIO(screenshot_bytes)) image.save(screenshot_filename) - log.write(f'{timestamp},output-changed,{output_index},{screenshot_filename}\n') + log.write( + f"{timestamp},output-changed,{output_index},{screenshot_filename}\n", + ) log.flush() - print(f"Saving screenshot of output {output_index} at {timestamp}") + print( + f"Saving screenshot of output {output_index} at {timestamp}", + ) last_screenshot[output_index] = screenshot_bytes - print('Stopping monitoring output and moving on to next input cell') + print("Stopping monitoring output and moving on to next input cell") -if __name__ == '__main__': +if __name__ == "__main__": monitor() diff --git a/jupyter_output_monitor/_server.py b/jupyter_output_monitor/_server.py new file mode 100644 index 0000000..4881aea --- /dev/null +++ b/jupyter_output_monitor/_server.py @@ -0,0 +1,20 @@ +from contextlib import contextmanager + +from solara.test.pytest_plugin import ( + ServerJupyter, +) + +from ._utils import get_free_port + +__all__ = ["jupyter_server"] + + +@contextmanager +def jupyter_server(notebook_path): + server = ServerJupyter(notebook_path, get_free_port(), "localhost") + try: + server.serve_threaded() + server.wait_until_serving() + yield server + finally: + server.stop_serving() diff --git a/jupyter_output_monitor/_utils.py b/jupyter_output_monitor/_utils.py new file mode 100644 index 0000000..bc2888a --- /dev/null +++ b/jupyter_output_monitor/_utils.py @@ -0,0 +1,33 @@ +import datetime +import socket + +from nbconvert import NotebookExporter +from traitlets.config import Config + +__all__ = ["get_free_port", "clear_notebook", "isotime"] + + +def get_free_port(): + """Return a free port number.""" + sock = socket.socket() + sock.bind(("", 0)) + return sock.getsockname()[1] + + +def clear_notebook(input_notebook, output_notebook): + """Write out a copy of the notebook with output and metadata removed.""" + c = Config() + c.NotebookExporter.preprocessors = [ + "nbconvert.preprocessors.ClearOutputPreprocessor", + "nbconvert.preprocessors.ClearMetadataPreprocessor", + ] + + exporter = NotebookExporter(config=c) + body, resources = exporter.from_filename(input_notebook) + + with open(output_notebook, "w") as f: + f.write(body) + + +def isotime(): + return datetime.datetime.now().isoformat() diff --git a/jupyter_output_monitor/tests/__init__.py b/jupyter_output_monitor/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jupyter_output_monitor/tests/data/simple.ipynb b/jupyter_output_monitor/tests/data/simple.ipynb new file mode 100644 index 0000000..c1e1d70 --- /dev/null +++ b/jupyter_output_monitor/tests/data/simple.ipynb @@ -0,0 +1,97 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b26895d5-f326-446e-b847-a4bd71c30b33", + "metadata": { + "execution": { + "iopub.execute_input": "2024-11-13T11:58:55.701204Z", + "iopub.status.busy": "2024-11-13T11:58:55.701016Z", + "iopub.status.idle": "2024-11-13T11:58:55.706466Z", + "shell.execute_reply": "2024-11-13T11:58:55.705916Z", + "shell.execute_reply.started": "2024-11-13T11:58:55.701186Z" + } + }, + "source": [ + "Test a few widgets!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c53acb0f-394f-45a1-99ef-dbbd0bb16afd", + "metadata": {}, + "outputs": [], + "source": [ + "import time" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd9213d9-cbac-4dcc-898e-5364b038b9bc", + "metadata": {}, + "outputs": [], + "source": [ + "from ipywidgets import Button, Textarea" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e4d91f0-19b8-4603-8077-b30ad6ccb19f", + "metadata": {}, + "outputs": [], + "source": [ + "button = Button(description='Test')\n", + "button.layout.border = '1px solid rgb(143, 56, 3)'\n", + "button" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74a37995-adf9-4b0c-8211-5611e0974a2c", + "metadata": {}, + "outputs": [], + "source": [ + "area = Textarea()\n", + "area.layout.border = '1px solid rgb(143, 56, 33)'\n", + "area" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a116202-6c97-4ae3-b3b4-3accda88c8e5", + "metadata": {}, + "outputs": [], + "source": [ + "area.value = 'Test1'\n", + "time.sleep(3)\n", + "area.value = 'Test2'" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/jupyter_output_monitor/tests/test_monitor.py b/jupyter_output_monitor/tests/test_monitor.py new file mode 100644 index 0000000..51fb922 --- /dev/null +++ b/jupyter_output_monitor/tests/test_monitor.py @@ -0,0 +1,42 @@ +import csv +import subprocess +import sys +from pathlib import Path + +DATA = Path(__file__).parent / "data" + + +def test_simple(tmp_path): + output_path = tmp_path / "output" + subprocess.run( + [ + sys.executable, + "-m", + "jupyter_output_monitor", + "--notebook", + str(DATA / "simple.ipynb"), + "--output", + str(output_path), + "--headless", + ], + check=True, + ) + + # Check that the expected screenshots are there + + # Input cells + assert len(list(output_path.glob("input-*.png"))) == 5 + + # Output screenshots + assert len(list(output_path.glob("output-*.png"))) == 4 + + # Specifically for cell with index 33 + assert len(list(output_path.glob("output-003-*.png"))) == 1 + + # Specifically for cell with index 33 + assert len(list(output_path.glob("output-033-*.png"))) == 3 + + # Check that event log exists and is parsable + with open(output_path / "event_log.csv") as f: + reader = csv.reader(f, delimiter=",") + assert len(list(reader)) == 10 diff --git a/pyproject.toml b/pyproject.toml index 9a42fe5..ee5ff53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ dependencies = [ "numpy>=1.23", "click", "pillow", - "playwright" + "playwright", + "solara[pytest]" ] dynamic = ["version"] @@ -38,8 +39,29 @@ find = {namespaces = false} write_to = "jupyter_output_monitor/_version.py" [tool.ruff] -lint.select = [ - "B", # flake8-bugbear - "I", # isort - "UP", # pyupgrade +lint.select = ["ALL"] +lint.ignore = [ + "A00", + "ANN", + "T201", + "PTH", + "D100", + "D103", + "D104", + "C901", + "PLR0915", + "PLR2004", + "DTZ", + "E501", + "RET", + "INP", + "S101", + "SIM108", + "S603" +] + +[project.optional-dependencies] +test = [ + "pytest>=7.0", + "ipywidgets" ] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..8c9e5a6 --- /dev/null +++ b/tox.ini @@ -0,0 +1,14 @@ +[tox] +envlist = py{38,39,310,311,312}-test + +[testenv] +passenv = + DISPLAY +changedir = + test: .tmp/{envname} +extras = + test +commands = + pip freeze + playwright install firefox + pytest --pyargs jupyter_output_monitor {posargs}