diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..f8d631d35 --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +exclude=alembic/versions,.venv +docstring-convention=google +max-line-length = 88 +extend-ignore = ANN101 diff --git a/.github/ISSUE_TEMPLATE/bug_form.yml b/.github/ISSUE_TEMPLATE/bug_form.yml index 8c656d07f..48b9f49e8 100644 --- a/.github/ISSUE_TEMPLATE/bug_form.yml +++ b/.github/ISSUE_TEMPLATE/bug_form.yml @@ -21,9 +21,9 @@ body: label: To Reproduce description: What Operating System did you use and steps would we take to reproduce the behaviour? placeholder: "I use [macOS/Microsoft Windows]. - Steps: - 1. Go to '...' - 2. Click on '....' + Steps: + 1. Go to '...' + 2. Click on '....' 3. Scroll down to '....' " validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a49eab2f6..0086358db 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1 @@ -blank_issues_enabled: true \ No newline at end of file +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/use_case.yml b/.github/ISSUE_TEMPLATE/use_case.yml index cc204e54a..585caa786 100644 --- a/.github/ISSUE_TEMPLATE/use_case.yml +++ b/.github/ISSUE_TEMPLATE/use_case.yml @@ -36,10 +36,10 @@ body: attributes: label: Workflow description: How is the task accomplished? Please place each step on a new line. - placeholder: | + placeholder: | 1. Open Chrome 2. Navigate to Google Flights 3. Set the departure city 4. ... validations: - required: true \ No newline at end of file + required: true diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6ca6be6a2..1124dc546 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,15 @@ on: jobs: run-ci: - runs-on: windows-latest + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + + strategy: + matrix: + # TODO: add windows matrix + os: [macos-latest] steps: - name: Checkout code @@ -18,25 +26,32 @@ jobs: with: python-version: '3.10' - - name: Install dependencies - run: | - pip install wheel - pip install -r requirements.txt - pip install -e . + - name: Run tests using the shell script (macOS compatible). + if: matrix.os == 'macos-latest' + run: SKIP_POETRY_SHELL=1 sh install/install_openadapt.sh - - name: Check formatting with Black - run: | - black --check --exclude "(src|alembic)/" . + - name: Checkout code + uses: actions/checkout@v3 - - name: Run headless tests - uses: coactions/setup-xvfb@v1 + - name: Install poetry + uses: snok/install-poetry@v1 with: - run: python -m pytest - working-directory: ./ # Optional: Specify the working directory if needed - options: # Optional: Add any additional options or arguments for pytest + version: 1.5.1 + virtualenvs-create: true + virtualenvs-in-project: true - - name: flake8 Lint - uses: py-actions/flake8@v2 + - name: Cache deps + id: cache-deps + uses: actions/cache@v2 with: - plugins: "flake8-docstrings" - extra-args: "--docstring-convention=google --exclude=alembic/versions" + path: .venv + key: pydeps-${{ hashFiles('**/poetry.lock') }} + + - run: poetry install --no-interaction --no-root + if: steps.cache-deps.outputs.cache-hit != 'true' + + - name: Check formatting with Black + run: poetry run black --check . + + - name: Run Flake8 + run: poetry run flake8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e55d1dbbe..3ccb575b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # How to contribute -We would love to implement your contributions to this project! We simply ask that you observe the following guidelines. +We would love to implement your contributions to this project! We simply ask that you observe the following guidelines. ## Code Style @@ -30,11 +30,11 @@ In order to effectively communicate any bugs or request new features, please sel ## Testing [GitHub Actions](https://github.com/MLDSAI/OpenAdapt/actions/new) are automatically run on each pull request to ensure consistent behavior and style. The Actions are composed of PyTest, [black](https://github.com/psf/black) and [flake8](https://flake8.pycqa.org/en/latest/user/index.html). -You can run these tests on your own computer by downloading the dependencies using `poetry` (see [here](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md#install)) and then running `pytest` in the root directory. +You can run these tests on your own computer by downloading the dependencies using `poetry` (see [here](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md#install)) and then running `pytest` in the root directory. ## Pull Request Format -To speed up the review process, please use the provided pull request template and create a draft pull request to get initial feedback. +To speed up the review process, please use the provided pull request template and create a draft pull request to get initial feedback. The pull request template includes areas to explain the changes, and a checklist with boxes for code style, testing, and documentation. diff --git a/README.md b/README.md index 14ad56549..712d004e2 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ alembic revision --autogenerate -m "" ### Pre-commit Hooks -To ensure code quality and consistency, OpenAdapt uses pre-commit hooks. These hooks +To ensure code quality and consistency, OpenAdapt uses pre-commit hooks. These hooks will be executed automatically before each commit to perform various checks and validations on your codebase. @@ -269,7 +269,7 @@ The following pre-commit hooks are used in OpenAdapt: To set up the pre-commit hooks, follow these steps: 1. Navigate to the root directory of your OpenAdapt repository. - + 2. Run the following command to install the hooks: ``` diff --git a/alembic/README b/alembic/README index 98e4f9c44..2500aa1bc 100644 --- a/alembic/README +++ b/alembic/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/alembic/env.py b/alembic/env.py index c1aa5688c..23191b849 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,37 +1,39 @@ +"""Alembic Environment Configuration. + +This module provides the environment configuration for Alembic. +""" + from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool +from sqlalchemy import engine_from_config, pool from alembic import context +from openadapt.config import DB_URL +from openadapt.db import Base -# this is the Alembic Config object, which provides +# This is the Alembic Config object, which provides # access to the values within the .ini file in use. alembic_config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. -if alembic_config .config_file_name is not None: - fileConfig(alembic_config .config_file_name) +if alembic_config.config_file_name is not None: + fileConfig(alembic_config.config_file_name) -# add your model's MetaData object here +# Add your model's MetaData object here # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -from openadapt.config import DB_URL -from openadapt.models import * -from openadapt.db import Base - target_metadata = Base.metadata -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - def get_url() -> str: - print(f"{DB_URL=}") + """Get the database URL. + + Returns: + str: The database URL. + """ + print(f"DB_URL={DB_URL}") return DB_URL @@ -40,14 +42,12 @@ def run_migrations_offline() -> None: This configures the context with just a URL and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation + here as well. By skipping the Engine creation, we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. - """ - # url = config.get_main_option("sqlalchemy.url") url = get_url() context.configure( url=url, @@ -64,11 +64,10 @@ def run_migrations_offline() -> None: def run_migrations_online() -> None: """Run migrations in 'online' mode. - In this scenario we need to create an Engine + In this scenario, we need to create an Engine and associate a connection with the context. - """ - configuration = alembic_config .get_section(alembic_config .config_ini_section) + configuration = alembic_config.get_section(alembic_config.config_ini_section) configuration["sqlalchemy.url"] = get_url() connectable = engine_from_config( configuration=configuration, diff --git a/install/install_openadapt.sh b/install/install_openadapt.sh index 99311dab2..277a820cc 100644 --- a/install/install_openadapt.sh +++ b/install/install_openadapt.sh @@ -22,7 +22,6 @@ Cleanup() { # Run a command and ensure it did not fail RunAndCheck() { - if $1 ; then echo "Success: $2" else @@ -97,12 +96,12 @@ if ! CheckCMDExists "brew"; then # Check the type of chip cpu=$(sysctl machdep.cpu.brand_string) - if [[ $cpu == *"Apple"* ]]; then - # Add brew to PATH - echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile - eval "$(/opt/homebrew/bin/brew shellenv)" - fi - + if [[ $cpu == *"Apple"* ]]; then + # Add brew to PATH + echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile + eval "$(/opt/homebrew/bin/brew shellenv)" + fi + brewExists=$(CheckCMDExists "brew") if ! CheckCMDExists "brew"; then echo "Failed to download brew" @@ -130,5 +129,7 @@ RunAndCheck "pip3.10 install poetry" "Install Poetry" RunAndCheck "poetry install" "Install Python dependencies" RunAndCheck "poetry run alembic upgrade head" "Update database" RunAndCheck "poetry run pytest" "Run tests" -RunAndCheck "poetry shell" "Activate virtual environment" +if [ -z "$SKIP_POETRY_SHELL" ]; then + RunAndCheck "poetry shell" "Activate virtual environment" +fi echo OpenAdapt installed successfully! diff --git a/openadapt/__init__.py b/openadapt/__init__.py index dd9b22ccc..d249878a5 100644 --- a/openadapt/__init__.py +++ b/openadapt/__init__.py @@ -1 +1,5 @@ +"""OpenAdapt package. + +This package contains modules for the OpenAdapt project. +""" __version__ = "0.5.1" diff --git a/openadapt/app/__init__.py b/openadapt/app/__init__.py index 2cde6f2f2..bd0790818 100644 --- a/openadapt/app/__init__.py +++ b/openadapt/app/__init__.py @@ -1,3 +1,12 @@ +"""openadapt.app package. + +This package contains modules for the OpenAdapt application. + +Example usage: + from openadapt.app import run_app + + run_app() +""" from openadapt.app.main import run_app if __name__ == "__main__": diff --git a/openadapt/app/build.py b/openadapt/app/build.py index b7e3b7c33..4182f077d 100644 --- a/openadapt/app/build.py +++ b/openadapt/app/build.py @@ -1,6 +1,14 @@ +"""openadapt.app.build module. + +This module provides functionality for building the OpenAdapt application. + +Example usage: + python build.py +""" + +from pathlib import Path import os import subprocess -from pathlib import Path import nicegui @@ -19,7 +27,8 @@ subprocess.call(spec) -# add import sys ; sys.setrecursionlimit(sys.getrecursionlimit() * 5) to line 2 of OpenAdapt.spec +# add import sys ; sys.setrecursionlimit(sys.getrecursionlimit() * 5) +# to line 2 of OpenAdapt.spec with open("OpenAdapt.spec", "r+") as f: lines = f.readlines() lines[1] = "import sys ; sys.setrecursionlimit(sys.getrecursionlimit() * 5)\n" diff --git a/openadapt/app/cards.py b/openadapt/app/cards.py index 3aa8c8cfa..ce2f61c41 100644 --- a/openadapt/app/cards.py +++ b/openadapt/app/cards.py @@ -1,23 +1,44 @@ +"""openadapt.app.cards module. + +This module provides functions for managing UI cards in the OpenAdapt application. +""" + +from subprocess import Popen import signal + from nicegui import ui -from subprocess import Popen + from openadapt.app.objects.local_file_picker import LocalFilePicker from openadapt.app.util import set_dark, sync_switch PROC = None -def settings(dark_mode): +def settings(dark_mode: bool) -> None: + """Display the settings dialog. + + Args: + dark_mode (bool): Current dark mode setting. + """ with ui.dialog() as settings, ui.card(): - s = ui.switch("Dark mode", on_change=lambda: set_dark(dark_mode, s.value)) + s = ui.switch( + "Dark mode", + on_change=lambda: set_dark(dark_mode, s.value), + ) sync_switch(s, dark_mode) ui.button("Close", on_click=lambda: settings.close()) settings.open() -def select_import(f): - async def pick_file(): +def select_import(f: callable) -> None: + """Display the import file selection dialog. + + Args: + f (callable): Function to call when import button is clicked. + """ + + async def pick_file() -> None: result = await LocalFilePicker(".") ui.notify(f"Selected {result[0]}" if result else "No file selected.") selected_file.text = result[0] if result else "" @@ -29,7 +50,8 @@ async def pick_file(): selected_file = ui.label("") selected_file.visible = False import_button = ui.button( - "Import", on_click=lambda: f(selected_file.text, delete.value) + "Import", + on_click=lambda: f(selected_file.text, delete.value), ) import_button.enabled = False delete = ui.checkbox("Delete file after import") @@ -37,7 +59,13 @@ async def pick_file(): import_dialog.open() -def recording_prompt(options, record_button): +def recording_prompt(options: list[str], record_button: ui.widgets.Button) -> None: + """Display the recording prompt dialog. + + Args: + options (list): List of autocomplete options. + record_button (nicegui.widgets.Button): Record button widget. + """ if PROC is None: with ui.dialog() as dialog, ui.card(): ui.label("Enter a name for the recording: ") @@ -55,34 +83,29 @@ def recording_prompt(options, record_button): dialog.open() - def terminate(): - global PROC - PROC.send_signal(signal.SIGINT) + def terminate() -> None: + global process + process.send_signal(signal.SIGINT) - # wait for process to terminate - PROC.wait() + # Wait for process to terminate + process.wait() ui.notify("Stopped recording") record_button._props["name"] = "radio_button_checked" record_button.on("click", lambda: recording_prompt(options, record_button)) - PROC = None + process = None - def begin(): + def begin() -> None: name = result.text.__getattribute__("value") - ui.notify( - f"Recording {name}... Press CTRL + C in terminal window to cancel", - ) - PROC = Popen( - "python3 -m openadapt.record " + name, - shell=True, - ) + ui.notify(f"Recording {name}... Press CTRL + C in terminal window to cancel") + PROC = Popen("python3 -m openadapt.record " + name, shell=True) record_button._props["name"] = "stop" record_button.on("click", lambda: terminate()) record_button.update() return PROC - def on_record(): + def on_record() -> None: global PROC dialog.close() PROC = begin() diff --git a/openadapt/app/main.py b/openadapt/app/main.py index e07e6faf6..3c62dd69d 100644 --- a/openadapt/app/main.py +++ b/openadapt/app/main.py @@ -1,18 +1,29 @@ -import threading +"""openadapt.app.main module. + +This module provides the main entry point for running the OpenAdapt application. + +Example usage: + from openadapt.app import run_app + + run_app() +""" + import base64 import os +import threading from nicegui import app, ui from openadapt import replay, visualize from openadapt.app.cards import recording_prompt, select_import, settings -from openadapt.app.util import clear_db, on_export, on_import from openadapt.app.objects.console import Console +from openadapt.app.util import clear_db, on_export, on_import SERVER = "127.0.0.1:8000/upload" -def run_app(): +def run_app() -> None: + """Run the OpenAdapt application.""" file = os.path.dirname(__file__) app.native.window_args["resizable"] = False # too many issues with resizing app.native.start_args["debug"] = False @@ -21,11 +32,9 @@ def run_app(): logger = None # Add logo - # right align icon + # Right-align icon with ui.row().classes("w-full justify-right"): # settings - - # alignment trick with ui.avatar(color="white" if dark else "black", size=128): logo_base64 = base64.b64encode(open(f"{file}/assets/logo.png", "rb").read()) img = bytes( @@ -42,7 +51,8 @@ def run_app(): "click", lambda: select_import(on_import) ) ui.icon("share").tooltip("Share").on( - "click", lambda: (_ for _ in ()).throw(Exception(NotImplementedError)) + "click", + lambda: (_ for _ in ()).throw(Exception(NotImplementedError)), ) # Recording description autocomplete @@ -54,15 +64,20 @@ def run_app(): with ui.column().classes("w-full h-full"): record_button = ( ui.icon("radio_button_checked", size="64px") - .on("click", lambda: recording_prompt(options, record_button)) + .on( + "click", + lambda: recording_prompt(options, record_button), + ) .tooltip("Record a new replay / Stop recording") ) ui.icon("visibility", size="64px").on( - "click", lambda: threading.Thread(target=visualize.main).start() + "click", + lambda: threading.Thread(target=visualize.main).start(), ).tooltip("Visualize the latest replay") ui.icon("play_arrow", size="64px").on( - "click", lambda: replay.replay("NaiveReplayStrategy") + "click", + lambda: replay.replay("NaiveReplayStrategy"), ).tooltip("Play the latest replay") with splitter.after: logger = Console() diff --git a/openadapt/app/objects/console.py b/openadapt/app/objects/console.py index 1ea0256c4..32dd709ba 100644 --- a/openadapt/app/objects/console.py +++ b/openadapt/app/objects/console.py @@ -1,21 +1,40 @@ +"""openadapt.app.objects.console module. + +This module provides the Console class for redirecting stderr to a NiceGUI log. + +Example usage: + logger = Console() + logger.write("Error message") +""" + import sys from nicegui import ui class Console(object): - def __init__(self): + """Console class for redirecting stderr to a NiceGUI log.""" + + def __init__(self) -> None: + """Initialize the Console object.""" self.log = ui.log().classes("w-full h-20") self.old_stderr = sys.stderr sys.stderr = self - def write(self, data): + def write(self, data: str) -> None: + """Write data to the log. + + Args: + data (str): Data to be written. + """ self.log.push(data[:-1]) self.log.update() - def flush(self): + def flush(self) -> None: + """Flush the log.""" self.log.update() - def reset(self): + def reset(self) -> None: + """Reset the log and restore stderr.""" self.log.clear() sys.stderr = self.old_stderr diff --git a/openadapt/app/objects/local_file_picker.py b/openadapt/app/objects/local_file_picker.py index d563ff501..d01a6aae0 100644 --- a/openadapt/app/objects/local_file_picker.py +++ b/openadapt/app/objects/local_file_picker.py @@ -1,4 +1,17 @@ -# retrieved from https://github.com/zauberzeug/nicegui/tree/main/examples/local_file_picker +"""openadapt.app.objects.local_file_picker module. + +This module provides the LocalFilePicker class for selecting + a file from the local filesystem. +# retrieved from +https://github.com/zauberzeug/nicegui/tree/main/examples/local_file_picker + +Example usage: + from openadapt.app.objects.local_file_picker import LocalFilePicker + + async def pick_file(): + result = await LocalFilePicker("~", multiple=True) + ui.notify(f"You chose {result}") +""" from pathlib import Path from typing import Dict, Optional @@ -7,6 +20,8 @@ class LocalFilePicker(ui.dialog): + """LocalFilePicker class for selecting a file from the local filesystem.""" + def __init__( self, directory: str, @@ -16,14 +31,15 @@ def __init__( show_hidden_files: bool = False, dark_mode: bool = False, ) -> None: - """Local File Picker - - This is a simple file picker that allows you to select a file from the local filesystem where NiceGUI is running. - - :param directory: The directory to start in. - :param upper_limit: The directory to stop at (None: no limit, default: same as the starting directory). - :param multiple: Whether to allow multiple files to be selected. - :param show_hidden_files: Whether to show hidden files. + """Initialize the LocalFilePicker object. + + Args: + directory (str): The directory to start in. + upper_limit (Optional[str]): The directory to stop at + (None: no limit, default: same as the starting directory). + multiple (bool): Whether to allow multiple files to be selected. + show_hidden_files (bool): Whether to show hidden files. + dark_mode (bool): Whether to use dark mode for the file picker. """ super().__init__() @@ -54,6 +70,7 @@ def __init__( self.update_grid() def update_grid(self) -> None: + """Update the grid with file data.""" paths = list(self.path.glob("*")) if not self.show_hidden_files: paths = [p for p in paths if not p.name.startswith(".")] @@ -84,20 +101,27 @@ def update_grid(self) -> None: self.grid.update() async def handle_double_click(self, msg: Dict) -> None: + """Handle the double-click event on a cell in the grid. + + Args: + msg (Dict): Message containing the event data. + """ self.path = Path(msg["args"]["data"]["path"]) if self.path.is_dir(): self.update_grid() else: self.submit([str(self.path)]) - async def _handle_ok(self): + async def _handle_ok(self) -> None: + """Handle the Ok button click event.""" rows = await ui.run_javascript( f"getElement({self.grid.id}).gridOptions.api.getSelectedRows()" ) self.submit([r["path"] for r in rows]) -async def pick_file(): +async def pick_file() -> None: + """Async function for picking a file using LocalFilePicker.""" result = await LocalFilePicker("~", multiple=True) ui.notify(f"You chose {result}") diff --git a/openadapt/app/util.py b/openadapt/app/util.py index 980ad3fcc..3209a414b 100644 --- a/openadapt/app/util.py +++ b/openadapt/app/util.py @@ -1,12 +1,34 @@ +"""openadapt.app.util module. + +This module provides utility functions for the OpenAdapt application. + +Example usage: + from openadapt.app.util import clear_db, on_import, on_export, sync_switch, set_dark + + clear_db() + on_import(selected_file, delete=False, src="openadapt.db") + on_export(dest) + sync_switch(switch, prop) + set_dark(dark_mode, value) +""" + +from shutil import copyfileobj import bz2 import os import sys -from shutil import copyfileobj -from nicegui import ui + +from nicegui import elements, ui + +from openadapt.app import objects from openadapt.scripts.reset_db import reset_db -def clear_db(log=None): +def clear_db(log: objects.console.Console) -> None: + """Clear the database. + + Args: + log: Optional NiceGUI log object. + """ if log: log.log.clear() o = sys.stdout @@ -17,7 +39,18 @@ def clear_db(log=None): sys.stdout = o -def on_import(selected_file, delete=False, src="openadapt.db"): +def on_import( + selected_file: str, + delete: bool = False, + src: str = "openadapt.db", +) -> None: + """Import data from a selected file. + + Args: + selected_file (str): The path of the selected file. + delete (bool): Whether to delete the selected file after import. + src (str): The source file name to save the imported data. + """ with open(src, "wb") as f: with bz2.BZ2File(selected_file, "rb") as f2: copyfileobj(f2, f) @@ -28,7 +61,12 @@ def on_import(selected_file, delete=False, src="openadapt.db"): ui.notify("Imported data.") -def on_export(dest): +def on_export(dest: str) -> None: + """Export data to a destination. + + Args: + dest (str): The destination to export the data to. + """ # TODO: add ui card for configuration ui.notify("Exporting data...") @@ -50,9 +88,23 @@ def on_export(dest): ui.notify("Exported data.") -def sync_switch(switch, prop): +def sync_switch( + switch: elements.switch.Switch, prop: elements.mixins.value_element.ValueElement +) -> None: + """Synchronize the value of a switch with a property. + + Args: + switch: The switch object. + prop: The property object. + """ switch.value = prop.value -def set_dark(dark_mode, value): +def set_dark(dark_mode: ui.DarkMode, value: bool) -> None: + """Set the dark mode. + + Args: + dark_mode: The dark mode object. + value: The value to set. + """ dark_mode.value = value diff --git a/openadapt/cache.py b/openadapt/cache.py index 1e2874ed1..91c64ebd0 100644 --- a/openadapt/cache.py +++ b/openadapt/cache.py @@ -1,26 +1,63 @@ +"""openadapt.cache module. + +This module provides a caching decorator for functions. + +Example usage: + from openadapt.cache import cache + + @cache() + def my_function(): + # Function body + pass +""" + from functools import wraps +from typing import Any, Callable, Optional, Union import time from joblib import Memory from loguru import logger - from openadapt import config -def default(val, default): +def default(val: Optional[Any], default: Any) -> Any: + """Set a default value if the given value is None. + + Args: + val: The value to check. + default: The default value to set. + + Returns: + The value or the default value. + """ return val if val is not None else default -def cache(dir_path=None, enabled=None, verbosity=None, **cache_kwargs): - """TODO""" +def cache( + dir_path: Optional[str] = None, + enabled: Optional[bool] = None, + verbosity: Optional[int] = None, + **cache_kwargs: Union[str, int, bool], +) -> Callable[[Callable], Callable]: + """Cache decorator for functions. + + Args: + dir_path (str): The path to the cache directory. + enabled (bool): Whether caching is enabled. + verbosity (int): The verbosity level of the cache. + **cache_kwargs: Additional keyword arguments to pass to the cache. + Returns: + The decorator function. + """ cache_dir_path = default(dir_path, config.CACHE_DIR_PATH) cache_enabled = default(enabled, config.CACHE_ENABLED) cache_verbosity = default(verbosity, config.CACHE_VERBOSITY) - def decorator(fn): + + def decorator(fn: Callable) -> Callable: @wraps(fn) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: logger.debug(f"{cache_enabled=}") if cache_enabled: memory = Memory(cache_dir_path, verbose=cache_verbosity) @@ -34,5 +71,7 @@ def wrapper(*args, **kwargs): duration = time.time() - start_time logger.debug(f"{fn=} {duration=}") return rval + return wrapper + return decorator diff --git a/openadapt/common.py b/openadapt/common.py index 5d364009c..040f07bd0 100644 --- a/openadapt/common.py +++ b/openadapt/common.py @@ -1,3 +1,14 @@ +"""openadapt.common module. + +This module defines common constants and variables used in OpenAdapt. + +Example usage: + from openadapt.common import ALL_EVENTS + + for event in ALL_EVENTS: + print(event) +""" + MOUSE_EVENTS = ( # raw "move", diff --git a/openadapt/config.py b/openadapt/config.py index 9dd2d9018..3b10c2045 100644 --- a/openadapt/config.py +++ b/openadapt/config.py @@ -16,7 +16,6 @@ from dotenv import load_dotenv from loguru import logger - _DEFAULTS = { "CACHE_DIR_PATH": ".cache", "CACHE_ENABLED": True, @@ -42,7 +41,7 @@ "SCRUB_CHAR": "*", "SCRUB_LANGUAGE": "en", # TODO support lists in getenv_fallback - "SCRUB_FILL_COLOR": 0x0000FF, # BGR format + "SCRUB_FILL_COLOR": 0x0000FF, # BGR format "SCRUB_CONFIG_TRF": { "nlp_engine_name": "spacy", "models": [{"lang_code": "en", "model_name": "en_core_web_trf"}], @@ -86,7 +85,8 @@ "PLOT_PERFORMANCE": True, } -# each string in STOP_STRS should only contain strings that don't contain special characters +# each string in STOP_STRS should only contain strings +# that don't contain special characters STOP_STRS = [ "oa.stop", # TODO: @@ -101,9 +101,25 @@ ] + SPECIAL_CHAR_STOP_SEQUENCES -def getenv_fallback(var_name): +def getenv_fallback(var_name: str) -> str: + """Get the value of an environment variable or fallback to the default value. + + Args: + var_name (str): The name of the environment variable. + + Returns: + The value of the environment variable or the fallback default value. + + Raises: + ValueError: If the environment variable is not defined. + """ rval = os.getenv(var_name) or _DEFAULTS.get(var_name) - if type(rval) is str and rval.lower() in ("true", "false", "1", "0"): + if type(rval) is str and rval.lower() in ( + "true", + "false", + "1", + "0", + ): rval = rval.lower() == "true" or rval == "1" if rval is None: raise ValueError(f"{var_name=} not defined") @@ -122,7 +138,20 @@ def getenv_fallback(var_name): DIRNAME_PERFORMANCE_PLOTS = "performance" -def obfuscate(val, pct_reveal=0.1, char="*"): +def obfuscate(val: str, pct_reveal: float = 0.1, char: str = "*") -> str: + """Obfuscates a value by replacing a portion of characters. + + Args: + val (str): The value to obfuscate. + pct_reveal (float, optional): Percentage of characters to reveal (default: 0.1). + char (str, optional): Obfuscation character (default: "*"). + + Returns: + str: Obfuscated value with a portion of characters replaced. + + Raises: + AssertionError: If length of obfuscated value does not match original value. + """ num_reveal = int(len(val) * pct_reveal) num_obfuscate = len(val) - num_reveal obfuscated = char * num_obfuscate @@ -132,34 +161,30 @@ def obfuscate(val, pct_reveal=0.1, char="*"): return rval - _OBFUSCATE_KEY_PARTS = ("KEY", "PASSWORD", "TOKEN") if multiprocessing.current_process().name == "MainProcess": for key, val in dict(locals()).items(): if not key.startswith("_") and key.isupper(): parts = key.split("_") if ( - any([part in parts for part in _OBFUSCATE_KEY_PARTS]) and - val != _DEFAULTS[key] + any([part in parts for part in _OBFUSCATE_KEY_PARTS]) + and val != _DEFAULTS[key] ): val = obfuscate(val) logger.info(f"{key}={val}") -def filter_log_messages(data): - """ - This function filters log messages by ignoring any message that contains a specific string. +def filter_log_messages(data: dict) -> bool: + """Filter log messages by ignoring specific strings. Args: - data: The input parameter "data" is expected to be data from a loguru logger. + data (dict): Data from a loguru logger. Returns: - a boolean value indicating whether the message in the input data should be ignored or not. If the - message contains any of the messages in the `messages_to_ignore` list, the function returns `False` - indicating that the message should be ignored. Otherwise, it returns `True` indicating that the - message should not be ignored. + bool: True if the message should not be ignored, False if it should be ignored. """ - # TODO: ultimately, we want to fix the underlying issues, but for now, we can ignore these messages + # TODO: ultimately, we want to fix the underlying issues, but for now, + # we can ignore these messages messages_to_ignore = [ "Cannot pickle Objective-C objects", ] diff --git a/openadapt/crud.py b/openadapt/crud.py index 677e792ad..4a314a4ce 100644 --- a/openadapt/crud.py +++ b/openadapt/crud.py @@ -1,16 +1,23 @@ +"""Implements basic CRUD operations for interacting with a database. + +Module: crud.py +""" + +from typing import Any + from loguru import logger import sqlalchemy as sa -from openadapt.db import Session +from openadapt.config import STOP_SEQUENCES +from openadapt.db import BaseModel, Session from openadapt.models import ( ActionEvent, - Screenshot, + MemoryStat, + PerformanceStat, Recording, + Screenshot, WindowEvent, - PerformanceStat, - MemoryStat ) -from openadapt.config import STOP_SEQUENCES BATCH_SIZE = 1 @@ -22,10 +29,23 @@ memory_stats = [] +def _insert( + event_data: dict[str, Any], + table: sa.Table, + buffer: list[dict[str, Any]] | None = None, +) -> sa.engine.Result | None: + """Insert using Core API for improved performance (no rows are returned). -def _insert(event_data, table, buffer=None): - """Insert using Core API for improved performance (no rows are returned)""" + Args: + event_data (dict): The event data to be inserted. + table (sa.Table): The SQLAlchemy table to insert the data into. + buffer (list, optional): A buffer list to store the inserted objects + before committing. Defaults to None. + Returns: + sa.engine.Result | None: The SQLAlchemy Result object if a buffer is + not provided. None if a buffer is provided. + """ db_obj = {column.name: None for column in table.__table__.columns} for key in db_obj: if key in event_data: @@ -49,7 +69,16 @@ def _insert(event_data, table, buffer=None): return result -def insert_action_event(recording_timestamp, event_timestamp, event_data): +def insert_action_event( + recording_timestamp: int, event_timestamp: int, event_data: dict[str, Any] +) -> None: + """Insert an action event into the database. + + Args: + recording_timestamp (int): The timestamp of the recording. + event_timestamp (int): The timestamp of the event. + event_data (dict): The data of the event. + """ event_data = { **event_data, "timestamp": event_timestamp, @@ -58,7 +87,16 @@ def insert_action_event(recording_timestamp, event_timestamp, event_data): _insert(event_data, ActionEvent, action_events) -def insert_screenshot(recording_timestamp, event_timestamp, event_data): +def insert_screenshot( + recording_timestamp: int, event_timestamp: int, event_data: dict[str, Any] +) -> None: + """Insert a screenshot into the database. + + Args: + recording_timestamp (int): The timestamp of the recording. + event_timestamp (int): The timestamp of the event. + event_data (dict): The data of the event. + """ event_data = { **event_data, "timestamp": event_timestamp, @@ -67,7 +105,18 @@ def insert_screenshot(recording_timestamp, event_timestamp, event_data): _insert(event_data, Screenshot, screenshots) -def insert_window_event(recording_timestamp, event_timestamp, event_data): +def insert_window_event( + recording_timestamp: int, + event_timestamp: int, + event_data: dict[str, Any], +) -> None: + """Insert a window event into the database. + + Args: + recording_timestamp (int): The timestamp of the recording. + event_timestamp (int): The timestamp of the event. + event_data (dict): The data of the event. + """ event_data = { **event_data, "timestamp": event_timestamp, @@ -76,11 +125,20 @@ def insert_window_event(recording_timestamp, event_timestamp, event_data): _insert(event_data, WindowEvent, window_events) -def insert_perf_stat(recording_timestamp, event_type, start_time, end_time): - """ - Insert event performance stat into db - """ +def insert_perf_stat( + recording_timestamp: int, + event_type: str, + start_time: float, + end_time: float, +) -> None: + """Insert an event performance stat into the database. + Args: + recording_timestamp (int): The timestamp of the recording. + event_type (str): The type of the event. + start_time (float): The start time of the event. + end_time (float): The end time of the event. + """ event_perf_stat = { "recording_timestamp": recording_timestamp, "event_type": event_type, @@ -90,11 +148,15 @@ def insert_perf_stat(recording_timestamp, event_type, start_time, end_time): _insert(event_perf_stat, PerformanceStat, performance_stats) -def get_perf_stats(recording_timestamp): - """ - return performance stats for a given recording - """ +def get_perf_stats(recording_timestamp: int) -> list[PerformanceStat]: + """Get performance stats for a given recording. + + Args: + recording_timestamp (int): The timestamp of the recording. + Returns: + list[PerformanceStat]: A list of performance stats for the recording. + """ return ( db.query(PerformanceStat) .filter(PerformanceStat.recording_timestamp == recording_timestamp) @@ -103,11 +165,10 @@ def get_perf_stats(recording_timestamp): ) -def insert_memory_stat(recording_timestamp, memory_usage_bytes, timestamp): - """ - Insert memory stat into db - """ - +def insert_memory_stat( + recording_timestamp: int, memory_usage_bytes: int, timestamp: int +) -> None: + """Insert memory stat into db.""" memory_stat = { "recording_timestamp": recording_timestamp, "memory_usage_bytes": memory_usage_bytes, @@ -116,21 +177,18 @@ def insert_memory_stat(recording_timestamp, memory_usage_bytes, timestamp): _insert(memory_stat, MemoryStat, memory_stats) -def get_memory_stats(recording_timestamp): - """ - return memory stats for a given recording - """ - +def get_memory_stats(recording_timestamp: int) -> None: + """Return memory stats for a given recording.""" return ( - db - .query(MemoryStat) - .filter(MemoryStat.recording_timestamp == recording_timestamp) - .order_by(MemoryStat.timestamp) - .all() + db.query(MemoryStat) + .filter(MemoryStat.recording_timestamp == recording_timestamp) + .order_by(MemoryStat.timestamp) + .all() ) -def insert_recording(recording_data): +def insert_recording(recording_data: Recording) -> Recording: + """Insert the recording into to the db.""" db_obj = Recording(**recording_data) db.add(db_obj) db.commit() @@ -138,15 +196,38 @@ def insert_recording(recording_data): return db_obj -def get_latest_recording(): +def get_latest_recording() -> Recording: + """Get the latest recording. + + Returns: + Recording: The latest recording object. + """ return db.query(Recording).order_by(sa.desc(Recording.timestamp)).limit(1).first() -def get_recording(timestamp): +def get_recording(timestamp: int) -> Recording: + """Get a recording by timestamp. + + Args: + timestamp (int): The timestamp of the recording. + + Returns: + Recording: The recording object. + """ return db.query(Recording).filter(Recording.timestamp == timestamp).first() -def _get(table, recording_timestamp): +def _get(table: BaseModel, recording_timestamp: int) -> list[BaseModel]: + """Retrieve records from the database table based on the recording timestamp. + + Args: + table (BaseModel): The database table to query. + recording_timestamp (int): The recording timestamp to filter the records. + + Returns: + list[BaseModel]: A list of records retrieved from the database table, + ordered by timestamp. + """ return ( db.query(table) .filter(table.recording_timestamp == recording_timestamp) @@ -155,7 +236,15 @@ def _get(table, recording_timestamp): ) -def get_action_events(recording): +def get_action_events(recording: Recording) -> list[ActionEvent]: + """Get action events for a given recording. + + Args: + recording (Recording): The recording object. + + Returns: + list[ActionEvent]: A list of action events for the recording. + """ assert recording, "Invalid recording." action_events = _get(ActionEvent, recording.timestamp) # filter out stop sequences listed in STOP_SEQUENCES and Ctrl + C @@ -163,7 +252,15 @@ def get_action_events(recording): return action_events -def filter_stop_sequences(action_events): +def filter_stop_sequences(action_events: list[ActionEvent]) -> None: + """Filter stop sequences. + + Args: + List[ActionEvent]: A list of action events for the recording. + + Returns: + None + """ # check for ctrl c first # TODO: want to handle sequences like ctrl c the same way as normal sequences if len(action_events) >= 2: @@ -205,7 +302,8 @@ def filter_stop_sequences(action_events): action_events[j].canonical_key_char in STOP_SEQUENCES[i] or action_events[j].canonical_key_name in STOP_SEQUENCES[i] ): - # can consider any release event with any sequence char as part of the sequence + # can consider any release event with any sequence char as + # part of the sequence num_to_remove += 1 else: # not part of the sequence, so exit inner loop @@ -223,7 +321,19 @@ def filter_stop_sequences(action_events): action_events.pop() -def get_screenshots(recording, precompute_diffs=False): +def get_screenshots( + recording: Recording, precompute_diffs: bool = False +) -> list[Screenshot]: + """Get screenshots for a given recording. + + Args: + recording (Recording): The recording object. + precompute_diffs (bool, optional): Whether to precompute screenshot diffs. + Defaults to False. + + Returns: + list[Screenshot]: A list of screenshots for the recording. + """ screenshots = _get(Screenshot, recording.timestamp) for prev, cur in zip(screenshots, screenshots[1:]): @@ -239,5 +349,13 @@ def get_screenshots(recording, precompute_diffs=False): return screenshots -def get_window_events(recording): +def get_window_events(recording: Recording) -> list[WindowEvent]: + """Get window events for a given recording. + + Args: + recording (Recording): The recording object. + + Returns: + list[WindowEvent]: A list of window events for the recording. + """ return _get(WindowEvent, recording.timestamp) diff --git a/openadapt/db.py b/openadapt/db.py index 290b6b2e0..7a535607e 100644 --- a/openadapt/db.py +++ b/openadapt/db.py @@ -1,12 +1,15 @@ -import sqlalchemy as sa +"""Implements functionality for connecting to and interacting with the database. + +Module: db.py +""" + from dictalchemy import DictableModel +from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from sqlalchemy.schema import MetaData -from sqlalchemy.ext.declarative import declarative_base +import sqlalchemy as sa from openadapt.config import DB_ECHO, DB_URL -from openadapt.utils import EMPTY, row2dict - NAMING_CONVENTION = { "ix": "ix_%(column_0_label)s", @@ -18,10 +21,15 @@ class BaseModel(DictableModel): + """The base model for database tables.""" __abstract__ = True - def __repr__(self): + def __repr__(self) -> str: + """Return a string representation of the model object.""" + # avoid circular import + from openadapt.utils import EMPTY, row2dict + params = ", ".join( f"{k}={v!r}" # !r converts value to string using repr (adds quotes) for k, v in row2dict(self, follow=False).items() @@ -30,7 +38,8 @@ def __repr__(self): return f"{self.__class__.__name__}({params})" -def get_engine(): +def get_engine() -> sa.engine: + """Create and return a database engine.""" engine = sa.create_engine( DB_URL, echo=DB_ECHO, @@ -38,7 +47,15 @@ def get_engine(): return engine -def get_base(engine): +def get_base(engine: sa.engine) -> sa.engine: + """Create and return the base model with the provided engine. + + Args: + engine (sa.engine): The database engine to bind to the base model. + + Returns: + sa.engine: The base model object. + """ metadata = MetaData(naming_convention=NAMING_CONVENTION) Base = declarative_base( cls=BaseModel, diff --git a/openadapt/events.py b/openadapt/events.py index df026548e..a341ea3f1 100644 --- a/openadapt/events.py +++ b/openadapt/events.py @@ -1,21 +1,41 @@ +"""This module provides functionality for aggregating events.""" + +# TODO: rename this file to folds.py as per +# https://drive.google.com/file/d/1_fYoFncuI0TKghKWiMvP13hHEnIqAHI_/view?usp=drive_link + +from pprint import pformat +from typing import Any, Callable, Optional import time from loguru import logger -from pprint import pformat from scipy.spatial import distance import numpy as np -from openadapt import common, config, crud, models, utils - +from openadapt import common, crud, models, utils MAX_PROCESS_ITERS = 1 +MOUSE_MOVE_EVENT_MERGE_DISTANCE_THRESHOLD = 1 +MOUSE_MOVE_EVENT_MERGE_MIN_IDX_DELTA = 5 +KEYBOARD_EVENTS_MERGE_GROUP_NAMED_KEYS = True def get_events( - recording, - process=True, - meta=None, -): + recording: models.Recording, + process: bool = True, + meta: dict = None, +) -> list[models.ActionEvent]: + """Retrieve events for a recording. + + Args: + recording (models.Recording): The recording object. + process (bool): Whether to process the events by merging and discarding certain + types of events. Default is True. + meta (dict): Metadata dictionary to populate with information + about the processing. Default is None. + + Returns: + list: A list of action events. + """ start_time = time.time() action_events = crud.get_action_events(recording) window_events = crud.get_window_events(recording) @@ -43,13 +63,19 @@ def get_events( f"{num_window_events=} " f"{num_screenshots=}" ) - action_events, window_events, screenshots = process_events( - action_events, window_events, screenshots, + ( + action_events, + window_events, + screenshots, + ) = process_events( + action_events, + window_events, + screenshots, ) if ( - len(action_events) == num_action_events and - len(window_events) == num_window_events and - len(screenshots) == num_screenshots + len(action_events) == num_action_events + and len(window_events) == num_window_events + and len(screenshots) == num_screenshots ): break num_process_iters += 1 @@ -62,16 +88,19 @@ def get_events( if meta is not None: format_num = ( lambda num, raw_num: f"{num} of {raw_num} ({(num / raw_num):.2%})" - ) + ) # noqa: E731 meta["num_process_iters"] = num_process_iters meta["num_action_events"] = format_num( - num_action_events, num_action_events_raw, + num_action_events, + num_action_events_raw, ) meta["num_window_events"] = format_num( - num_window_events, num_window_events_raw, + num_window_events, + num_window_events_raw, ) meta["num_screenshots"] = format_num( - num_screenshots, num_screenshots_raw, + num_screenshots, + num_screenshots_raw, ) duration = action_events[-1].timestamp - action_events[0].timestamp @@ -86,11 +115,23 @@ def get_events( return action_events # , window_events, screenshots -def make_parent_event(child, extra=None): +def make_parent_event( + child: models.ActionEvent, extra: dict[str, Any] = None +) -> models.ActionEvent: + """Create a parent event from a child event. + + Args: + child (models.ActionEvent): The child event. + extra (dict): Extra attributes to include in the parent event. Default is None. + + Returns: + models.ActionEvent: The parent event. + + """ # TODO: record which process_fn created the parent event event_dict = { # TODO: set parent event to child timestamp? - #"timestamp": child.timestamp, + # "timestamp": child.timestamp, "recording_timestamp": child.recording_timestamp, "window_event_timestamp": child.window_event_timestamp, "screenshot_timestamp": child.screenshot_timestamp, @@ -104,31 +145,38 @@ def make_parent_event(child, extra=None): return models.ActionEvent(**event_dict) -# Set by_diff_distance=True to compute distance from mouse to screenshot diff -# (computationally expensive but keeps more useful events) -def merge_consecutive_mouse_move_events(events, by_diff_distance=False): - """Merge consecutive mouse move events into a single move event""" +def merge_consecutive_mouse_move_events( + events: list[models.ActionEvent], by_diff_distance: bool = False +) -> list[models.ActionEvent]: + """Merge consecutive mouse move events into a single move event. - _all_slowdowns = [] + Args: + events (list): The list of events to process. + by_diff_distance (bool): Whether to compute the distance from the mouse to + the screenshot diff. This is computationally expensive but keeps more useful + events. Default is False. + Returns: + list: The merged list of events. - def is_target_event(event, state): - return event.name == "move" + """ + _all_slowdowns = [] + def is_target_event(event: models.ActionEvent, state: dict[str, Any]) -> bool: + return event.name == "move" def get_merged_events( - to_merge, - state, - distance_threshold=1, - + to_merge: list[models.ActionEvent], + state: dict[str, Any], + distance_threshold: int = MOUSE_MOVE_EVENT_MERGE_DISTANCE_THRESHOLD, # Minimum number of consecutive events (in which the distance between # the cursor and the nearest non-zero diff pixel is greater than # distance_threshold) in order to result in a separate parent event. # Larger values merge more events under a single parent. # TODO: verify logic is correct (test) # TODO: compute, e.g. as a function of diff and/or cursor velocity? - min_idx_delta=5, # 100 - ): + min_idx_delta: int = MOUSE_MOVE_EVENT_MERGE_MIN_IDX_DELTA, # 100 + ) -> list[models.ActionEvent]: N = len(to_merge) # (inclusive, exclusive) group_idx_tups = [(0, N)] @@ -149,7 +197,8 @@ def get_merged_events( if np.any(diff_mask): _ts.append(time.perf_counter()) - # TODO: compare with https://logicatcore.github.io/2020-08-13-sparse-image-coordinates/ + # TODO: compare with + # https://logicatcore.github.io/2020-08-13-sparse-image-coordinates/ # ~247x slowdown diff_positions = np.argwhere(diff_mask) @@ -157,7 +206,8 @@ def get_merged_events( # ~6x slowdown distances = distance.cdist( - [cursor_position], diff_positions, + [cursor_position], + diff_positions, ) _ts.append(time.perf_counter()) @@ -180,9 +230,9 @@ def get_merged_events( if close_idxs: idx_deltas = np.diff(close_idxs) - min_idx_delta_idxs = np.argwhere( - idx_deltas >= min_idx_delta - ).flatten().tolist() + min_idx_delta_idxs = ( + np.argwhere(idx_deltas >= min_idx_delta).flatten().tolist() + ) group_idxs = np.array(close_idxs)[min_idx_delta_idxs].tolist() prefix = [0] if not group_idxs or group_idxs[0] != 0 else [] suffix = [N] if not group_idxs or group_idxs[-1] != N else [] @@ -226,22 +276,34 @@ def get_merged_events( logger.debug(f"{len(merged_events)=}") return merged_events - return merge_consecutive_action_events( - "mouse_move", events, is_target_event, get_merged_events, + "mouse_move", + events, + is_target_event, + get_merged_events, ) -def merge_consecutive_mouse_scroll_events(events): - """Merge consecutive mouse scroll events into a single scroll event""" +def merge_consecutive_mouse_scroll_events( + events: list[models.ActionEvent], +) -> list[models.ActionEvent]: + """Merge consecutive mouse scroll events into a single scroll event. + Args: + events (list): The list of events to process. - def is_target_event(event, state): - return event.name == "scroll" + Returns: + list: The merged list of events. + """ - def get_merged_events(to_merge, state): - state["dt"] += (to_merge[-1].timestamp - to_merge[0].timestamp) + def is_target_event(event: models.ActionEvent, state: dict[str, Any]) -> bool: + return event.name == "scroll" + + def get_merged_events( + to_merge: list[models.ActionEvent], state: dict[str, Any] + ) -> list[models.ActionEvent]: + state["dt"] += to_merge[-1].timestamp - to_merge[0].timestamp mouse_dx = sum(event.mouse_dx for event in to_merge) mouse_dy = sum(event.mouse_dy for event in to_merge) merged_event = to_merge[-1] @@ -250,17 +312,30 @@ def get_merged_events(to_merge, state): merged_event.mouse_dy = mouse_dy return [merged_event] - return merge_consecutive_action_events( - "mouse_scroll", events, is_target_event, get_merged_events, + "mouse_scroll", + events, + is_target_event, + get_merged_events, ) -def merge_consecutive_mouse_click_events(events): - """Merge consecutive mouse click events into a single doubleclick event""" +def merge_consecutive_mouse_click_events( + events: list[models.ActionEvent], +) -> list[models.ActionEvent]: + """Merge consecutive mouse click events into a single doubleclick event. + Args: + events (list): The list of events to process. - def get_recording_attr(event, attr_name, fallback): + Returns: + list: The merged list of events. + + """ + + def get_recording_attr( + event: models.ActionEvent, attr_name: str, fallback: Callable[[], Any] + ) -> Optional[Any]: attr = getattr(event.recording, attr_name) if event.recording else None if attr is None: fallback_value = fallback() @@ -268,13 +343,13 @@ def get_recording_attr(event, attr_name, fallback): attr = fallback_value return attr - - def is_target_event(event, state): + def is_target_event(event: models.ActionEvent, state: dict[str, Any]) -> bool: # TODO: parametrize button name return event.name == "click" and event.mouse_button_name == "left" - - def get_timestamp_mappings(to_merge): + def get_timestamp_mappings( + to_merge: list[models.ActionEvent], + ) -> tuple[dict[float, float], dict[float, float]]: double_click_distance = get_recording_attr( to_merge[0], "double_click_distance_pixels", @@ -297,30 +372,24 @@ def get_timestamp_mappings(to_merge): dx = abs(event.mouse_x - prev_pressed_event.mouse_x) dy = abs(event.mouse_y - prev_pressed_event.mouse_y) if ( - dt <= double_click_interval and - dx <= double_click_distance and - dy <= double_click_distance + dt <= double_click_interval + and dx <= double_click_distance + and dy <= double_click_distance ): - press_to_press_t[prev_pressed_event.timestamp] = ( - event.timestamp - ) + press_to_press_t[prev_pressed_event.timestamp] = event.timestamp prev_pressed_event = event elif prev_pressed_event: if prev_pressed_event.timestamp in press_to_release_t: # should never happen logger.warning("consecutive mouse release events") - press_to_release_t[prev_pressed_event.timestamp] = ( - event.timestamp - ) + press_to_release_t[prev_pressed_event.timestamp] = event.timestamp return press_to_press_t, press_to_release_t - - def get_merged_events(to_merge, state): + def get_merged_events( + to_merge: list[models.ActionEvent], state: dict[str, Any] + ) -> list[models.ActionEvent]: press_to_press_t, press_to_release_t = get_timestamp_mappings(to_merge) - t_to_event = { - event.timestamp: event - for event in to_merge - } + t_to_event = {event.timestamp: event for event in to_merge} merged = [] skip_timestamps = set() for event in to_merge: @@ -335,7 +404,7 @@ def get_merged_events(to_merge, state): skip_timestamps.add(release_t) skip_timestamps.add(next_press_t) skip_timestamps.add(next_release_t) - state["dt"] += (next_release_t - event.timestamp) + state["dt"] += next_release_t - event.timestamp event = make_parent_event( event, { @@ -356,7 +425,7 @@ def get_merged_events(to_merge, state): # convert to singleclick release_t = press_to_release_t[event.timestamp] skip_timestamps.add(release_t) - state["dt"] += (release_t - event.timestamp) + state["dt"] += release_t - event.timestamp event = make_parent_event( event, { @@ -365,33 +434,33 @@ def get_merged_events(to_merge, state): "mouse_x": event.mouse_x, "mouse_y": event.mouse_y, "mouse_button_name": event.mouse_button_name, - "children": [ - event, - t_to_event[release_t], - ], + "children": [event, t_to_event[release_t]], }, ) event.timestamp -= state["dt"] merged.append(event) return merged - return merge_consecutive_action_events( - "mouse_click", events, is_target_event, get_merged_events, + "mouse_click", + events, + is_target_event, + get_merged_events, ) -def merge_consecutive_keyboard_events(events, group_named_keys=True): - """Merge consecutive keyboard char press events into a single press event""" - +def merge_consecutive_keyboard_events( + events: list[models.ActionEvent], + group_named_keys: bool = KEYBOARD_EVENTS_MERGE_GROUP_NAMED_KEYS, +) -> list[models.ActionEvent]: + """Merge consecutive keyboard char press events into a single press event.""" - def is_target_event(event, state): + def is_target_event(event: models.ActionEvent, state: dict[str, Any]) -> bool: is_target_event = bool(event.key) logger.debug(f"{is_target_event=} {event=}") return is_target_event - - def get_group_idx_tups(to_merge): + def get_group_idx_tups(to_merge: list[models.ActionEvent]) -> list[tuple[int, int]]: pressed_keys = set() was_pressed = False start_idx = 0 @@ -401,16 +470,12 @@ def get_group_idx_tups(to_merge): if event.key_name: if event.name == "press": if event.key in pressed_keys: - logger.warning( - f"{event.key=} already in {pressed_keys=}" - ) + logger.warning(f"{event.key=} already in {pressed_keys=}") else: pressed_keys.add(event.key) elif event.name == "release": if event.key not in pressed_keys: - logger.warning( - f"{event.key} not in {pressed_keys=}" - ) + logger.warning(f"{event.key} not in {pressed_keys=}") else: pressed_keys.remove(event.key) is_pressed = bool(pressed_keys) @@ -433,8 +498,9 @@ def get_group_idx_tups(to_merge): logger.info(f"{len(to_merge)=} {group_idx_tups=}") return group_idx_tups - - def get_merged_events(to_merge, state): + def get_merged_events( + to_merge: list[models.ActionEvent], state: dict[str, Any] + ) -> list[models.ActionEvent]: if group_named_keys: group_idx_tups = get_group_idx_tups(to_merge) else: @@ -458,24 +524,27 @@ def get_merged_events(to_merge, state): "children": children, }, ) - state["dt"] += (last_child.timestamp - first_child.timestamp) + state["dt"] += last_child.timestamp - first_child.timestamp merged_events.append(merged_event) return merged_events return merge_consecutive_action_events( - "keyboard", events, is_target_event, get_merged_events, + "keyboard", + events, + is_target_event, + get_merged_events, ) -def remove_redundant_mouse_move_events(events): - """Remove mouse move events that don't change the mouse position""" - +def remove_redundant_mouse_move_events( + events: list[models.ActionEvent], +) -> list[models.ActionEvent]: + """Remove mouse move events that don't change the mouse position.""" - def is_target_event(event, state): + def is_target_event(event: models.ActionEvent, state: dict[str, Any]) -> bool: return event.name in ("move", "click") - - def is_same_pos(e0, e1): + def is_same_pos(e0: models.ActionEvent, e1: models.ActionEvent) -> bool: if not all([e0, e1]): return False for attr in ("mouse_x", "mouse_y"): @@ -485,24 +554,29 @@ def is_same_pos(e0, e1): return False return True - - def should_discard(event, prev_event, next_event): - return ( - event.name == "move" and ( - is_same_pos(prev_event, event) or - is_same_pos(event, next_event) - ) + def should_discard( + event: models.ActionEvent, + prev_event: models.ActionEvent | None, + next_event: models.ActionEvent | None, + ) -> bool: + return event.name == "move" and ( + is_same_pos(prev_event, event) or is_same_pos(event, next_event) ) - - def get_merged_events(to_merge, state): + def get_merged_events( + to_merge: list[models.ActionEvent], state: dict[str, Any] + ) -> list[models.ActionEvent]: to_merge = [None, *to_merge, None] merged_events = [] dts = [] children = [] - for idx, (prev_event, event, next_event) in enumerate(zip( - to_merge, to_merge[1:], to_merge[2:], - )): + for idx, (prev_event, event, next_event) in enumerate( + zip( + to_merge, + to_merge[1:], + to_merge[2:], + ) + ): if should_discard(event, prev_event, next_event): if prev_event: dt = event.timestamp - prev_event.timestamp @@ -518,35 +592,42 @@ def get_merged_events(to_merge, state): merged_events.append(event) # update timestamps (doing this in the previous loop double counts) - assert len(dts) == len(merged_events), (len(dts), len(merged_events)) + assert len(dts) == len(merged_events), ( + len(dts), + len(merged_events), + ) for event, dt in zip(merged_events, dts): event.timestamp -= dt return merged_events - return merge_consecutive_action_events( - "redundant_mouse_move", events, is_target_event, get_merged_events, + "redundant_mouse_move", + events, + is_target_event, + get_merged_events, ) def merge_consecutive_action_events( - name, events, is_target_event, get_merged_events, -): - """Merge consecutive action events into a single event""" - + name: str, + events: list[models.ActionEvent], + is_target_event: Callable[..., bool], + get_merged_events: Callable[..., list[models.ActionEvent]], +) -> list[models.ActionEvent]: + """Merge consecutive action events into one or more parent events.""" num_events_before = len(events) state = {"dt": 0} rval = [] to_merge = [] - - def include_merged_events(to_merge): + def include_merged_events( + to_merge: list[models.ActionEvent], + ) -> None: merged_events = get_merged_events(to_merge, state) rval.extend(merged_events) to_merge.clear() - for event in events: assert event.name in common.ALL_EVENTS, event if is_target_event(event, state): @@ -568,12 +649,27 @@ def include_merged_events(to_merge): def discard_unused_events( - referred_events, action_events, referred_timestamp_key, -): - referred_event_timestamps = set([ - getattr(action_event, referred_timestamp_key) - for action_event in action_events - ]) + referred_events: list[models.ActionEvent], + action_events: list[models.ActionEvent], + referred_timestamp_key: str, +) -> list[models.ActionEvent]: + """Discard unused events based on the referred timestamp key. + + Args: + referred_events (list): The list of referred events. + action_events (list): The list of action events. + referred_timestamp_key (str): The key representing the timestamp + in referred events. + + Returns: + list: The filtered list of referred events. + """ + referred_event_timestamps = set( + [ + getattr(action_event, referred_timestamp_key) + for action_event in action_events + ] + ) num_referred_events_before = len(referred_events) referred_events = [ referred_event @@ -581,18 +677,35 @@ def discard_unused_events( if referred_event.timestamp in referred_event_timestamps ] num_referred_events_after = len(referred_events) - num_referred_events_removed = ( - num_referred_events_before - num_referred_events_after - ) + num_referred_events_removed = num_referred_events_before - num_referred_events_after logger.info(f"{referred_timestamp_key=} {num_referred_events_removed=}") return referred_events -def process_events(action_events, window_events, screenshots): - # for debugging - _action_events = action_events - _window_events = window_events - _screenshots = screenshots +def process_events( + action_events: list[models.ActionEvent], + window_events: list[models.WindowEvent], + screenshots: list[models.Screenshot], +) -> tuple[ + list[models.ActionEvent], + list[models.WindowEvent], + list[models.Screenshot], +]: + """Process action events, window events, and screenshots. + + Args: + action_events (list): The list of action events. + window_events (list): The list of window events. + screenshots (list): The list of screenshots. + + Returns: + tuple: A tuple containing the processed action events, window events, + and screenshots. + """ + # For debugging + # _action_events = action_events + # _window_events = window_events + # _screenshots = screenshots num_action_events = len(action_events) num_window_events = len(window_events) @@ -615,16 +728,24 @@ def process_events(action_events, window_events, screenshots): for prev_event, event in zip(action_events, action_events[1:]): try: assert prev_event.timestamp <= event.timestamp, ( - process_fn, prev_event, event, + process_fn, + prev_event, + event, ) except AssertionError as exc: logger.exception(exc) - import ipdb; ipdb.set_trace() + import ipdb + + ipdb.set_trace() window_events = discard_unused_events( - window_events, action_events, "window_event_timestamp", + window_events, + action_events, + "window_event_timestamp", ) screenshots = discard_unused_events( - screenshots, action_events, "screenshot_timestamp", + screenshots, + action_events, + "screenshot_timestamp", ) num_action_events_ = len(action_events) num_window_events_ = len(window_events) @@ -639,8 +760,6 @@ def process_events(action_events, window_events, screenshots): f"{num_total=}" ) logger.info( - f"{pct_action_events=} {pct_window_events=} {pct_screenshots=} " - f"{pct_total=}" - + f"{pct_action_events=} {pct_window_events=} {pct_screenshots=} " f"{pct_total=}" ) return action_events, window_events, screenshots diff --git a/openadapt/extensions/synchronized_queue.py b/openadapt/extensions/synchronized_queue.py index 0c5e0da66..238db8b42 100644 --- a/openadapt/extensions/synchronized_queue.py +++ b/openadapt/extensions/synchronized_queue.py @@ -1,9 +1,8 @@ -""" - Module for customizing multiprocessing.Queue to avoid NotImplementedError in MacOS -""" +"""Module for customizing multiprocessing.Queue to avoid NotImplementedError.""" from multiprocessing.queues import Queue +from typing import Any import multiprocessing # Credit: https://gist.github.com/FanchenBao/d8577599c46eab1238a81857bb7277c9 @@ -15,7 +14,8 @@ class SharedCounter(object): - """ A synchronized shared counter. + """A synchronized shared counter. + The locking done by multiprocessing.Value ensures that only a single process or thread may read or write the in-memory ctypes object. However, in order to do n += 1, Python performs a read followed by a write, so a @@ -27,22 +27,28 @@ class SharedCounter(object): shared-counter-with-pythons-multiprocessing/ """ - def __init__(self, n=0): - self.count = multiprocessing.Value('i', n) + def __init__(self, n: int = 0) -> None: + """Initialize the shared counter. + + Args: + n (int): The initial value of the counter. Defaults to 0. + """ + self.count = multiprocessing.Value("i", n) - def increment(self, n=1): - """ Increment the counter by n (default = 1) """ + def increment(self, n: int = 1) -> None: + """Increment the counter by n (default = 1).""" with self.count.get_lock(): self.count.value += n @property - def value(self): - """ Return the value of the counter """ + def value(self) -> int: + """Return the value of the counter.""" return self.count.value class SynchronizedQueue(Queue): - """ A portable implementation of multiprocessing.Queue. + """A portable implementation of multiprocessing.Queue. + Because of multithreading / multiprocessing semantics, Queue.qsize() may raise the NotImplementedError exception on Unix platforms like Mac OS X where sem_getvalue() is not implemented. This subclass addresses this @@ -53,47 +59,69 @@ class SynchronizedQueue(Queue): qsize() and empty(). Note the implementation of __getstate__ and __setstate__ which help to serialize SynchronizedQueue when it is passed between processes. If these functions - are not defined, SynchronizedQueue cannot be serialized, which will lead to the error - of "AttributeError: 'SynchronizedQueue' object has no attribute 'size'". + are not defined, SynchronizedQueue cannot be serialized, + which will lead to the error of "AttributeError: 'SynchronizedQueue' object + has no attribute 'size'". See the answer provided here: https://stackoverflow.com/a/65513291/9723036 - - For documentation of using __getstate__ and __setstate__ - to serialize objects, refer to here: + + For documentation of using __getstate__ and __setstate__ + to serialize objects, refer to here: https://docs.python.org/3/library/pickle.html#pickling-class-instances """ - def __init__(self): + def __init__(self) -> None: + """Initialize the synchronized queue.""" super().__init__(ctx=multiprocessing.get_context()) self.size = SharedCounter(0) - def __getstate__(self): + def __getstate__(self) -> dict[str, int]: """Help to make SynchronizedQueue instance serializable. + Note that we record the parent class state, which is the state of the - actual queue, and the size of the queue, which is the state of SynchronizedQueue. - self.size is a SharedCounter instance. It is itself serializable. + actual queue, and the size of the queue, which is the + state of SynchronizedQueue. self.size is a SharedCounter instance. + It is itself serializable. """ return { - 'parent_state': super().__getstate__(), - 'size': self.size, + "parent_state": super().__getstate__(), + "size": self.size, } - def __setstate__(self, state): - super().__setstate__(state['parent_state']) - self.size = state['size'] + def __setstate__(self, state: dict[str, Any]) -> None: + """Set the state of the object. + + Args: + state: The state of the object. - def put(self, *args, **kwargs): + Returns: + None + """ + super().__setstate__(state["parent_state"]) + self.size = state["size"] + + def put(self, *args: tuple[Any, ...], **kwargs: dict[str, Any]) -> None: + """Put an item into the queue and increment the size counter.""" super().put(*args, **kwargs) self.size.increment(1) - def get(self, *args, **kwargs): + def get(self, *args: tuple[Any, ...], **kwargs: dict[str, Any]) -> Any: + """Get an item from the queue and decrement the size counter.""" item = super().get(*args, **kwargs) self.size.increment(-1) return item - def qsize(self): - """ Reliable implementation of multiprocessing.Queue.qsize() """ + def qsize(self) -> int: + """Get the current size of the queue. + + Returns: + int: The current size of the queue. + """ return self.size.value - def empty(self): - """ Reliable implementation of multiprocessing.Queue.empty() """ + def empty(self) -> bool: + """Check if the queue is empty. + + Returns: + bool: True if the queue is empty, False otherwise. + """ return not self.qsize() diff --git a/openadapt/models.py b/openadapt/models.py index f76528200..99cb7af23 100644 --- a/openadapt/models.py +++ b/openadapt/models.py @@ -1,26 +1,34 @@ +"""This module defines the models used in the OpenAdapt system.""" + +from typing import Union import io from loguru import logger -from pynput import keyboard from PIL import Image, ImageChops +from pynput import keyboard import numpy as np import sqlalchemy as sa -from openadapt import config, db, utils, window +from openadapt import config, db, window # https://groups.google.com/g/sqlalchemy/c/wlr7sShU6-k class ForceFloat(sa.TypeDecorator): + """Custom SQLAlchemy type decorator for floating-point numbers.""" + impl = sa.Numeric(10, 2, asdecimal=False) cache_ok = True - def process_result_value(self, value, dialect): + def process_result_value(self, value: Union[int, float, str, None]) -> float | None: + """Convert the result value to float.""" if value is not None: value = float(value) return value class Recording(db.Base): + """Class representing a recording in the database.""" + __tablename__ = "recording" id = sa.Column(sa.Integer, primary_key=True) @@ -51,15 +59,18 @@ class Recording(db.Base): _processed_action_events = None @property - def processed_action_events(self): + def processed_action_events(self) -> list: + """Get the processed action events for the recording.""" from openadapt import events + if not self._processed_action_events: self._processed_action_events = events.get_events(self) return self._processed_action_events - class ActionEvent(db.Base): + """Class representing an action event in the database.""" + __tablename__ = "action_event" id = sa.Column(sa.Integer, primary_key=True) @@ -86,8 +97,12 @@ class ActionEvent(db.Base): children = sa.orm.relationship("ActionEvent") # TODO: replacing the above line with the following two results in an error: # AttributeError: 'list' object has no attribute '_sa_instance_state' - #children = sa.orm.relationship("ActionEvent", remote_side=[id], back_populates="parent") - #parent = sa.orm.relationship("ActionEvent", remote_side=[parent_id], back_populates="children") + # children = sa.orm.relationship( + # "ActionEvent", remote_side=[id], back_populates="parent" + # ) + # parent = sa.orm.relationship( + # "ActionEvent", remote_side=[parent_id], back_populates="children" + # ) # noqa: E501 recording = sa.orm.relationship("Recording", back_populates="action_events") screenshot = sa.orm.relationship("Screenshot", back_populates="action_event") @@ -95,7 +110,10 @@ class ActionEvent(db.Base): # TODO: playback_timestamp / original_timestamp - def _key(self, key_name, key_char, key_vk): + def _key( + self, key_name: str, key_char: str, key_vk: str + ) -> Union[keyboard.Key, keyboard.KeyCode, str, None]: + """Helper method to determine the key attribute based on available data.""" if key_name: key = keyboard.Key[key_name] elif key_char: @@ -108,10 +126,9 @@ def _key(self, key_name, key_char, key_vk): return key @property - def key(self): - logger.trace( - f"{self.name=} {self.key_name=} {self.key_char=} {self.key_vk=}" - ) + def key(self) -> Union[keyboard.Key, keyboard.KeyCode, str, None]: + """Get the key associated with the action event.""" + logger.trace(f"{self.name=} {self.key_name=} {self.key_char=} {self.key_vk=}") return self._key( self.key_name, self.key_char, @@ -119,7 +136,8 @@ def key(self): ) @property - def canonical_key(self): + def canonical_key(self) -> Union[keyboard.Key, keyboard.KeyCode, str, None]: + """Get the canonical key associated with the action event.""" logger.trace( f"{self.name=} " f"{self.canonical_key_name=} " @@ -132,7 +150,8 @@ def canonical_key(self): self.canonical_key_vk, ) - def _text(self, canonical=False): + def _text(self, canonical: bool = False) -> str | None: + """Helper method to generate the text representation of the action event.""" sep = config.ACTION_TEXT_SEP name_prefix = config.ACTION_TEXT_NAME_PREFIX name_suffix = config.ACTION_TEXT_NAME_SUFFIX @@ -157,21 +176,25 @@ def _text(self, canonical=False): else: if key_name_attr: text = f"{name_prefix}{key_attr}{name_suffix}".replace( - "Key.", "", + "Key.", + "", ) else: text = key_attr return text @property - def text(self): + def text(self) -> str: + """Get the text representation of the action event.""" return self._text() @property - def canonical_text(self): + def canonical_text(self) -> str: + """Get the canonical text representation of the action event.""" return self._text(canonical=True) - def __str__(self): + def __str__(self) -> str: + """Return a string representation of the action event.""" attr_names = [ "name", "mouse_x", @@ -183,16 +206,8 @@ def __str__(self): "text", "element_state", ] - attrs = [ - getattr(self, attr_name) - for attr_name in attr_names - ] - attrs = [ - int(attr) - if isinstance(attr, float) - else attr - for attr in attrs - ] + attrs = [getattr(self, attr_name) for attr_name in attr_names] + attrs = [int(attr) if isinstance(attr, float) else attr for attr in attrs] attrs = [ f"{attr_name}=`{attr}`" for attr_name, attr in zip(attr_names, attrs) @@ -202,15 +217,23 @@ def __str__(self): return rval @classmethod - def from_children(cls, children_dicts): - children = [ - ActionEvent(**child_dict) - for child_dict in children_dicts - ] + def from_children(cls: list, children_dicts: list) -> "ActionEvent": + """Create an ActionEvent instance from a list of child event dictionaries. + + Args: + children_dicts (list): List of dictionaries representing child events. + + Returns: + ActionEvent: An instance of ActionEvent with the specified child events. + + """ + children = [ActionEvent(**child_dict) for child_dict in children_dicts] return ActionEvent(children=children) class Screenshot(db.Base): + """Class representing a screenshot in the database.""" + __tablename__ = "screenshot" id = sa.Column(sa.Integer, primary_key=True) @@ -231,7 +254,8 @@ class Screenshot(db.Base): _diff_mask = None @property - def image(self): + def image(self) -> Image: + """Get the image associated with the screenshot.""" if not self._image: if self.sct_img: self._image = Image.frombytes( @@ -247,30 +271,41 @@ def image(self): return self._image @property - def diff(self): + def diff(self) -> Image: + """Get the difference between the current and previous screenshot.""" if not self._diff: assert self.prev, "Attempted to compute diff before setting prev" self._diff = ImageChops.difference(self.image, self.prev.image) return self._diff @property - def diff_mask(self): + def diff_mask(self) -> Image: + """Get the difference mask of the screenshot.""" if not self._diff_mask: if self.diff: self._diff_mask = self.diff.convert("1") return self._diff_mask @property - def array(self): + def array(self) -> np.ndarray: + """Get the NumPy array representation of the image.""" return np.array(self.image) @classmethod - def take_screenshot(cls): + def take_screenshot(cls: "Screenshot") -> "Screenshot": + """Capture a screenshot.""" + # avoid circular import + from openadapt import utils + sct_img = utils.take_screenshot() screenshot = Screenshot(sct_img=sct_img) return screenshot - def crop_active_window(self, action_event): + def crop_active_window(self, action_event: ActionEvent) -> None: + """Crop the screenshot to the active window defined by the action event.""" + # avoid circular import + from openadapt import utils + window_event = action_event.window_event width_ratio, height_ratio = utils.get_scale_ratios(action_event) @@ -284,6 +319,8 @@ def crop_active_window(self, action_event): class WindowEvent(db.Base): + """Class representing a window event in the database.""" + __tablename__ = "window_event" id = sa.Column(sa.Integer, primary_key=True) @@ -301,11 +338,14 @@ class WindowEvent(db.Base): action_events = sa.orm.relationship("ActionEvent", back_populates="window_event") @classmethod - def get_active_window_event(cls): + def get_active_window_event(cls: "WindowEvent") -> "WindowEvent": + """Get the active window event.""" return WindowEvent(**window.get_active_window_data()) class PerformanceStat(db.Base): + """Class representing a performance statistic in the database.""" + __tablename__ = "performance_stat" id = sa.Column(sa.Integer, primary_key=True) @@ -317,6 +357,8 @@ class PerformanceStat(db.Base): class MemoryStat(db.Base): + """Class representing a memory usage statistic in the database.""" + __tablename__ = "memory_stat" id = sa.Column(sa.Integer, primary_key=True) diff --git a/openadapt/playback.py b/openadapt/playback.py index c3b8fcb1d..7b7c4b5c1 100644 --- a/openadapt/playback.py +++ b/openadapt/playback.py @@ -1,12 +1,22 @@ -"""Utilities for playing back ActionEvents""" +"""Utilities for playing back ActionEvents.""" from loguru import logger -from pynput import mouse +from pynput import mouse, keyboard from openadapt.common import KEY_EVENTS, MOUSE_EVENTS +from openadapt.models import ActionEvent -def play_mouse_event(event, mouse_controller): +def play_mouse_event(event: ActionEvent, mouse_controller: mouse.Controller) -> None: + """Play a mouse event. + + Args: + event (ActionEvent): The mouse event to be played. + mouse_controller (mouse.Controller): The mouse controller. + + Raises: + Exception: If the event name is not handled. + """ name = event.name assert name in MOUSE_EVENTS, event x = event.mouse_x @@ -35,16 +45,25 @@ def play_mouse_event(event, mouse_controller): elif event.name == "scroll": mouse_controller.scroll(dx, dy) else: - raise Exception(f"unhandled {event.name=}") + raise Exception(f"Unhandled event name: {event.name}") + +def play_key_event( + event: ActionEvent, keyboard_controller: keyboard.Controller, canonical: bool = True +) -> None: + """Play a key event. -def play_key_event(event, keyboard_controller, canonical=True): + Args: + event (ActionEvent): The key event to be played. + keyboard_controller (keyboard.Controller): The keyboard controller. + canonical (bool): Whether to use the canonical key or the key. + + Raises: + Exception: If the event name is not handled. + """ assert event.name in KEY_EVENTS, event - key = ( - event.canonical_key if canonical and event.canonical_key else - event.key - ) + key = event.canonical_key if canonical and event.canonical_key else event.key if event.name == "press": keyboard_controller.press(key) @@ -53,10 +72,24 @@ def play_key_event(event, keyboard_controller, canonical=True): elif event.name == "type": keyboard_controller.type(key) else: - raise Exception(f"unhandled {event.name=}") + raise Exception(f"Unhandled event name: {event.name}") + + +def play_action_event( + event: ActionEvent, + mouse_controller: mouse.Controller, + keyboard_controller: keyboard.Controller, +) -> None: + """Play an action event. + Args: + event (ActionEvent): The action event to be played. + mouse_controller (mouse.Controller): The mouse controller. + keyboard_controller (keyboard.Controller): The keyboard controller. -def play_action_event(event, mouse_controller, keyboard_controller): + Raises: + Exception: If the event name is not handled. + """ # currently we use children to replay type events if event.children and event.name in KEY_EVENTS: for child in event.children: @@ -68,4 +101,4 @@ def play_action_event(event, mouse_controller, keyboard_controller): elif event.name in KEY_EVENTS: play_key_event(event, keyboard_controller) else: - raise Exception(f"unhandled {event.name=}") + raise Exception(f"Unhandled event name: {event.name}") diff --git a/openadapt/record.py b/openadapt/record.py index 59800ec38..ab3d1de89 100644 --- a/openadapt/record.py +++ b/openadapt/record.py @@ -8,7 +8,7 @@ from collections import namedtuple from functools import partial, wraps -from typing import Any, Callable, Dict +from typing import Any, Callable, Union import multiprocessing import os import queue @@ -28,6 +28,7 @@ from openadapt import config, crud, utils, window from openadapt.extensions import synchronized_queue as sq +from openadapt.models import ActionEvent Event = namedtuple("Event", ("timestamp", "type", "data")) @@ -49,11 +50,13 @@ utils.configure_logging(logger, LOG_LEVEL) -def collect_stats(): +def collect_stats() -> None: + """Collects and appends performance snapshots using tracemalloc.""" performance_snapshots.append(tracemalloc.take_snapshot()) -def log_memory_usage(): +def log_memory_usage() -> None: + """Logs memory usage stats and allocation trace based on snapshots.""" assert len(performance_snapshots) == 2, performance_snapshots first_snapshot, last_snapshot = performance_snapshots stats = last_snapshot.compare_to(first_snapshot, "lineno") @@ -71,26 +74,50 @@ def log_memory_usage(): logger.info(f"trace_str=\n{trace_str}") -def args_to_str(*args): +def args_to_str(*args: tuple) -> str: + """Convert positional arguments to a string representation. + + Args: + *args: Positional arguments. + + Returns: + str: Comma-separated string representation of positional arguments. + """ return ", ".join(map(str, args)) -def kwargs_to_str(**kwargs): +def kwargs_to_str(**kwargs: dict[str, Any]) -> str: + """Convert keyword arguments to a string representation. + + Args: + **kwargs: Keyword arguments. + + Returns: + str: Comma-separated string representation of keyword arguments + in form "key=value". + """ return ",".join([f"{k}={v}" for k, v in kwargs.items()]) -def trace(logger): - def decorator(func): +def trace(logger: logger) -> Any: + """Decorator that logs the function entry and exit using the provided logger. + + Args: + logger: The logger object to use for logging. + + Returns: + A decorator that can be used to wrap functions and log their entry and exit. + """ + + def decorator(func: Callable) -> Callable: @wraps(func) - def wrapper_logging(*args, **kwargs): + def wrapper_logging(*args: tuple[tuple, ...], **kwargs: dict[str, Any]) -> Any: func_name = func.__qualname__ func_args = args_to_str(*args) func_kwargs = kwargs_to_str(**kwargs) if func_kwargs != "": - logger.info( - f" -> Enter: {func_name}({func_args}, {func_kwargs})" - ) + logger.info(f" -> Enter: {func_name}({func_args}, {func_kwargs})") else: logger.info(f" -> Enter: {func_name}({func_args})") @@ -104,7 +131,25 @@ def wrapper_logging(*args, **kwargs): return decorator -def process_event(event, write_q, write_fn, recording_timestamp, perf_q): +def process_event( + event: ActionEvent, + write_q: sq.SynchronizedQueue, + write_fn: Callable, + recording_timestamp: int, + perf_q: sq.SynchronizedQueue, +) -> None: + """Process an event and take appropriate action based on its type. + + Args: + event: The event to process. + write_q: The queue for writing the event. + write_fn: The function for writing the event. + recording_timestamp: The timestamp of the recording. + perf_q: The queue for collecting performance statistics. + + Returns: + None + """ if PROC_WRITE_BY_EVENT_TYPE[event.type]: write_q.put(event) else: @@ -118,11 +163,10 @@ def process_events( action_write_q: sq.SynchronizedQueue, window_write_q: sq.SynchronizedQueue, perf_q: sq.SynchronizedQueue, - recording_timestamp: float, + recording_timestamp: int, terminate_event: multiprocessing.Event, -): - """ - Process events from event queue and write them to respective write queues. +) -> None: + """Process events from the event queue and write them to write queues. Args: event_q: A queue with events to be processed. @@ -133,9 +177,8 @@ def process_events( recording_timestamp: The timestamp of the recording. terminate_event: An event to signal the termination of the process. """ - utils.set_start_time(recording_timestamp) - logger.info(f"starting") + logger.info("Starting") prev_event = None prev_screen_event = None @@ -147,17 +190,20 @@ def process_events( logger.trace(f"{event=}") assert event.type in EVENT_TYPES, event if prev_event is not None: - assert event.timestamp > prev_event.timestamp, (event, prev_event) + assert event.timestamp > prev_event.timestamp, ( + event, + prev_event, + ) if event.type == "screen": prev_screen_event = event elif event.type == "window": prev_window_event = event elif event.type == "action": if prev_screen_event is None: - logger.warning("discarding action that came before screen") + logger.warning("Discarding action that came before screen") continue if prev_window_event is None: - logger.warning("discarding input that came before window") + logger.warning("Discarding input that came before window") continue event.data["screenshot_timestamp"] = prev_screen_event.timestamp event.data["window_event_timestamp"] = prev_window_event.timestamp @@ -190,23 +236,21 @@ def process_events( raise Exception(f"unhandled {event.type=}") del prev_event prev_event = event - logger.info("done") + logger.info("Done") def write_action_event( recording_timestamp: float, event: Event, perf_q: sq.SynchronizedQueue, -): - """ - Write an action event to the database and update the performance queue. +) -> None: + """Write an action event to the database and update the performance queue. Args: recording_timestamp: The timestamp of the recording. event: An action event to be written. perf_q: A queue for collecting performance data. """ - assert event.type == "action", event crud.insert_action_event(recording_timestamp, event.timestamp, event.data) perf_q.put((event.type, event.timestamp, utils.get_timestamp())) @@ -216,16 +260,14 @@ def write_screen_event( recording_timestamp: float, event: Event, perf_q: sq.SynchronizedQueue, -): - """ - Write a screen event to the database and update the performance queue. +) -> None: + """Write a screen event to the database and update the performance queue. Args: recording_timestamp: The timestamp of the recording. event: A screen event to be written. perf_q: A queue for collecting performance data. """ - assert event.type == "screen", event screenshot = event.data png_data = mss.tools.to_png(screenshot.rgb, screenshot.size) @@ -238,16 +280,14 @@ def write_window_event( recording_timestamp: float, event: Event, perf_q: sq.SynchronizedQueue, -): - """ - Write a window event to the database and update the performance queue. +) -> None: + """Write a window event to the database and update the performance queue. Args: recording_timestamp: The timestamp of the recording. event: A window event to be written. perf_q: A queue for collecting performance data. """ - assert event.type == "window", event crud.insert_window_event(recording_timestamp, event.timestamp, event.data) perf_q.put((event.type, event.timestamp, utils.get_timestamp())) @@ -262,9 +302,8 @@ def write_events( recording_timestamp: float, terminate_event: multiprocessing.Event, term_pipe: multiprocessing.Pipe, -): - """ - Write events of a specific type to the db using the provided write function. +) -> None: + """Write events of a specific type to the db using the provided write function. Args: event_type: The type of events to be written. @@ -276,17 +315,13 @@ def write_events( term_pipe: A pipe for communicating \ the number of events left to be written. """ - utils.set_start_time(recording_timestamp) logger.info(f"{event_type=} starting") signal.signal(signal.SIGINT, signal.SIG_IGN) num_left = 0 progress = None - while ( - not terminate_event.is_set() or - not write_q.empty() - ): + while not terminate_event.is_set() or not write_q.empty(): if term_pipe.poll(): num_left = term_pipe.recv() if num_left != 0 and progress is None: @@ -297,11 +332,7 @@ def write_events( colour="green", dynamic_ncols=True, ) - if ( - terminate_event.is_set() and - num_left != 0 and - progress is not None - ): + if terminate_event.is_set() and num_left != 0 and progress is not None: progress.update() try: event = write_q.get_nowait() @@ -318,9 +349,17 @@ def write_events( def trigger_action_event( - event_q: queue.Queue, - action_event_args: Dict[str, Any], + event_q: queue.Queue, action_event_args: dict[str, Any] ) -> None: + """Triggers an action event and adds it to the event queue. + + Args: + event_q: The event queue to add the action event to. + action_event_args: A dictionary containing the arguments for the action event. + + Returns: + None + """ x = action_event_args.get("mouse_x") y = action_event_args.get("mouse_y") if x is not None and y is not None: @@ -332,21 +371,23 @@ def trigger_action_event( event_q.put(Event(utils.get_timestamp(), "action", action_event_args)) -def on_move( - event_q: queue.Queue, - x: int, - y: int, - injected: bool, -) -> None: +def on_move(event_q: queue.Queue, x: int, y: int, injected: bool) -> None: + """Handles the 'move' event. + + Args: + event_q: The event queue to add the 'move' event to. + x: The x-coordinate of the mouse. + y: The y-coordinate of the mouse. + injected: Whether the event was injected or not. + + Returns: + None + """ logger.debug(f"{x=} {y=} {injected=}") if not injected: trigger_action_event( event_q, - { - "name": "move", - "mouse_x": x, - "mouse_y": y, - }, + {"name": "move", "mouse_x": x, "mouse_y": y}, ) @@ -358,6 +399,19 @@ def on_click( pressed: bool, injected: bool, ) -> None: + """Handles the 'click' event. + + Args: + event_q: The event queue to add the 'click' event to. + x: The x-coordinate of the mouse. + y: The y-coordinate of the mouse. + button: The mouse button. + pressed: Whether the button is pressed or released. + injected: Whether the event was injected or not. + + Returns: + None + """ logger.debug(f"{x=} {y=} {button=} {pressed=} {injected=}") if not injected: trigger_action_event( @@ -380,6 +434,19 @@ def on_scroll( dy: int, injected: bool, ) -> None: + """Handles the 'scroll' event. + + Args: + event_q: The event queue to add the 'scroll' event to. + x: The x-coordinate of the mouse. + y: The y-coordinate of the mouse. + dx: The horizontal scroll amount. + dy: The vertical scroll amount. + injected: Whether the event was injected or not. + + Returns: + None + """ logger.debug(f"{x=} {y=} {dx=} {dy=} {injected=}") if not injected: trigger_action_event( @@ -400,14 +467,24 @@ def handle_key( key: keyboard.KeyCode, canonical_key: keyboard.KeyCode, ) -> None: + """Handles a key event. + + Args: + event_q: The event queue to add the key event to. + event_name: The name of the key event. + key: The key code of the key event. + canonical_key: The canonical key code of the key event. + + Returns: + None + """ attr_names = [ "name", "char", "vk", ] attrs = { - f"key_{attr_name}": getattr(key, attr_name, None) - for attr_name in attr_names + f"key_{attr_name}": getattr(key, attr_name, None) for attr_name in attr_names } logger.debug(f"{attrs=}") canonical_attrs = { @@ -415,9 +492,7 @@ def handle_key( for attr_name in attr_names } logger.debug(f"{canonical_attrs=}") - trigger_action_event( - event_q, {"name": event_name, **attrs, **canonical_attrs} - ) + trigger_action_event(event_q, {"name": event_name, **attrs, **canonical_attrs}) def read_screen_events( @@ -425,24 +500,22 @@ def read_screen_events( terminate_event: multiprocessing.Event, recording_timestamp: float, ) -> None: - """ - Read screen events and add them to the event queue. + """Read screen events and add them to the event queue. Args: event_q: A queue for adding screen events. terminate_event: An event to signal the termination of the process. recording_timestamp: The timestamp of the recording. """ - utils.set_start_time(recording_timestamp) - logger.info(f"starting") + logger.info("Starting") while not terminate_event.is_set(): screenshot = utils.take_screenshot() if screenshot is None: - logger.warning("screenshot was None") + logger.warning("Screenshot was None") continue event_q.put(Event(utils.get_timestamp(), "screen", screenshot)) - logger.info("done") + logger.info("Done") @trace(logger) @@ -451,17 +524,15 @@ def read_window_events( terminate_event: multiprocessing.Event, recording_timestamp: float, ) -> None: - """ - Read window events and add them to the event queue. + """Read window events and add them to the event queue. Args: event_q: A queue for adding window events. terminate_event: An event to signal the termination of the process. recording_timestamp: The timestamp of the recording. """ - utils.set_start_time(recording_timestamp) - logger.info(f"starting") + logger.info("Starting") prev_window_data = {} while not terminate_event.is_set(): window_data = window.get_active_window_data() @@ -482,7 +553,7 @@ def read_window_events( _window_data.pop("state") logger.info(f"{_window_data=}") if window_data != prev_window_data: - logger.debug("queuing window event for writing") + logger.debug("Queuing window event for writing") event_q.put( Event( utils.get_timestamp(), @@ -498,19 +569,18 @@ def performance_stats_writer( perf_q: sq.SynchronizedQueue, recording_timestamp: float, terminate_event: multiprocessing.Event, -): - """ - Write performance stats to the db. - Each entry includes the event type, start time and end time +) -> None: + """Write performance stats to the database. + + Each entry includes the event type, start time, and end time. Args: perf_q: A queue for collecting performance data. recording_timestamp: The timestamp of the recording. terminate_event: An event to signal the termination of the process. """ - utils.set_start_time(recording_timestamp) - logger.info("performance stats writer starting") + logger.info("Performance stats writer starting") signal.signal(signal.SIGINT, signal.SIG_IGN) while not terminate_event.is_set() or not perf_q.empty(): try: @@ -524,12 +594,25 @@ def performance_stats_writer( start_time, end_time, ) - logger.info("performance stats writer done") + logger.info("Performance stats writer done") def memory_writer( - recording_timestamp: float, terminate_event: multiprocessing.Event, record_pid: int -): + recording_timestamp: float, + terminate_event: multiprocessing.Event, + record_pid: int, +) -> None: + """Writes memory usage statistics to the database. + + Args: + recording_timestamp (float): The timestamp of the recording. + terminate_event (multiprocessing.Event): The event used to terminate + the process. + record_pid (int): The process ID to monitor memory usage for. + + Returns: + None + """ utils.set_start_time(recording_timestamp) logger.info("Memory writer starting") signal.signal(signal.SIGINT, signal.SIG_IGN) @@ -564,18 +647,15 @@ def memory_writer( @trace(logger) def create_recording( task_description: str, -) -> Dict[str, Any]: - """ - Create a new recording entry in the database. +) -> dict[str, Any]: + """Create a new recording entry in the database. Args: - task_description: a text description of the task being implemented - in the recording + task_description: A text description of the task being recorded. Returns: - The newly created Recording object + The newly created Recording object. """ - timestamp = utils.set_start_time() monitor_width, monitor_height = utils.get_monitor_dims() double_click_distance_pixels = utils.get_double_click_distance_pixels() @@ -600,11 +680,37 @@ def read_keyboard_events( terminate_event: multiprocessing.Event, recording_timestamp: float, ) -> None: + """Reads keyboard events and adds them to the event queue. + + Args: + event_q (queue.Queue): The event queue to add the keyboard events to. + terminate_event (multiprocessing.Event): The event to signal termination + of event reading. + recording_timestamp (float): The timestamp of the recording. + + Returns: + None + """ # create list of indices for sequence detection # one index for each stop sequence in STOP_SEQUENCES stop_sequence_indices = [0 for _ in STOP_SEQUENCES] - def on_press(event_q, key, injected): + def on_press( + event_q: queue.Queue, + key: Union[keyboard.Key, keyboard.KeyCode], + injected: bool, + ) -> None: + """Event handler for key press events. + + Args: + event_q (queue.Queue): The event queue for processing key events. + key (keyboard.KeyboardEvent): The key event object representing + the pressed key. + injected (bool): A flag indicating whether the key event was injected. + + Returns: + None + """ canonical_key = keyboard_listener.canonical(key) logger.debug(f"{key=} {injected=} {canonical_key=}") if not injected: @@ -640,7 +746,22 @@ def on_press(event_q, key, injected): logger.info("Stop sequence entered! Stopping recording now.") stop_sequence_detected = True - def on_release(event_q, key, injected): + def on_release( + event_q: queue.Queue, + key: Union[keyboard.Key, keyboard.KeyCode], + injected: bool, + ) -> None: + """Event handler for key release events. + + Args: + event_q (queue.Queue): The event queue for processing key events. + key (keyboard.KeyboardEvent): The key event object representing + the released key. + injected (bool): A flag indicating whether the key event was injected. + + Returns: + None + """ canonical_key = keyboard_listener.canonical(key) logger.debug(f"{key=} {injected=} {canonical_key=}") if not injected: @@ -661,6 +782,16 @@ def read_mouse_events( terminate_event: multiprocessing.Event, recording_timestamp: float, ) -> None: + """Reads mouse events and adds them to the event queue. + + Args: + event_q: The event queue to add the mouse events to. + terminate_event: The event to signal termination of event reading. + recording_timestamp: The timestamp of the recording. + + Returns: + None + """ utils.set_start_time(recording_timestamp) mouse_listener = mouse.Listener( on_move=partial(on_move, event_q), @@ -671,18 +802,17 @@ def read_mouse_events( terminate_event.wait() mouse_listener.stop() + @logger.catch @trace(logger) def record( task_description: str, -): - """ - Record Screenshots/ActionEvents/WindowEvents. +) -> None: + """Record Screenshots/ActionEvents/WindowEvents. Args: - task_description: a text description of the task that will be recorded + task_description: A text description of the task to be recorded. """ - logger.info(f"{task_description=}") recording = create_recording(task_description) @@ -695,11 +825,20 @@ def record( # TODO: save write times to DB; display performance plot in visualize.py perf_q = sq.SynchronizedQueue() terminate_event = multiprocessing.Event() - - term_pipe_parent_window, term_pipe_child_window = multiprocessing.Pipe() - term_pipe_parent_screen, term_pipe_child_screen = multiprocessing.Pipe() - term_pipe_parent_action, term_pipe_child_action = multiprocessing.Pipe() - + + ( + term_pipe_parent_window, + term_pipe_child_window, + ) = multiprocessing.Pipe() + ( + term_pipe_parent_screen, + term_pipe_child_screen, + ) = multiprocessing.Pipe() + ( + term_pipe_parent_action, + term_pipe_child_action, + ) = multiprocessing.Pipe() + window_event_reader = threading.Thread( target=read_window_events, args=(event_q, terminate_event, recording_timestamp), @@ -795,7 +934,11 @@ def record( record_pid = os.getpid() mem_plotter = multiprocessing.Process( target=memory_writer, - args=(recording_timestamp, terminate_perf_event, record_pid), + args=( + recording_timestamp, + terminate_perf_event, + record_pid, + ), ) mem_plotter.start() @@ -812,7 +955,6 @@ def record( except KeyboardInterrupt: terminate_event.set() - collect_stats() log_memory_usage() @@ -820,7 +962,7 @@ def record( term_pipe_parent_action.send(action_write_q.qsize()) term_pipe_parent_screen.send(screen_write_q.qsize()) - logger.info(f"joining...") + logger.info("joining...") keyboard_event_reader.join() mouse_event_reader.join() screen_event_reader.join() @@ -835,11 +977,12 @@ def record( mem_plotter.join() utils.plot_performance(recording_timestamp) - logger.info(f"saved {recording_timestamp=}") + logger.info(f"Saved {recording_timestamp=}") -# entry point -def start(): +# Entry point +def start() -> None: + """Starts the recording process.""" fire.Fire(record) diff --git a/openadapt/replay.py b/openadapt/replay.py index a594054ec..a57a7d3c3 100644 --- a/openadapt/replay.py +++ b/openadapt/replay.py @@ -1,5 +1,16 @@ -from datetime import datetime -from dateutil import parser +"""Replay recorded events. + +Usage: +python openadapt/replay.py [--timestamp=] + +Arguments: +strategy_name Name of the replay strategy to use. + +Options: +--timestamp= Timestamp of the recording to replay. + +""" + from typing import Union from loguru import logger @@ -7,14 +18,17 @@ from openadapt import crud, utils - LOG_LEVEL = "INFO" + @logger.catch -def replay( - strategy_name: str, - timestamp: Union[str, None] = None, -): +def replay(strategy_name: str, timestamp: Union[str, None] = None) -> None: + """Replay recorded events using the specified strategy. + + Args: + strategy_name: Name of the replay strategy to use. + timestamp: Timestamp of the recording to replay. + """ utils.configure_logging(logger, LOG_LEVEL) if timestamp: @@ -45,8 +59,9 @@ def replay( strategy.run() -# entry point -def start(): +# Entry point +def start() -> None: + """Starts the replay.""" fire.Fire(replay) diff --git a/openadapt/scripts/__init__.py b/openadapt/scripts/__init__.py index e69de29bb..83e5d974b 100644 --- a/openadapt/scripts/__init__.py +++ b/openadapt/scripts/__init__.py @@ -0,0 +1,6 @@ +"""Scripts package. + +This package contains scripts for various purposes. + +Module: __init__.py +""" diff --git a/openadapt/scripts/reset_db.py b/openadapt/scripts/reset_db.py index 430ba073a..4b0d85aa1 100644 --- a/openadapt/scripts/reset_db.py +++ b/openadapt/scripts/reset_db.py @@ -1,20 +1,26 @@ +"""Reset Database Script. + +This script clears the database by removing the database file and +running a database migration using Alembic. + +Module: reset_db.py +""" + +from subprocess import PIPE, run import os -from subprocess import run, PIPE -from openadapt import config +from openadapt import config -def reset_db(): - """ - The function clears the database by removing the database file and running a - database migration using Alembic. - """ +def reset_db() -> None: + """Clears the database by removing the db file and running a db migration.""" if os.path.exists(config.DB_FPATH): os.remove(config.DB_FPATH) - # Prevents duplicate logging of config values by piping stderr and filtering the output. + # Prevents duplicate logging of config values by piping stderr + # and filtering the output. result = run(["alembic", "upgrade", "head"], stderr=PIPE, text=True) - print(result.stderr[result.stderr.find("INFO [alembic") :]) + print(result.stderr[result.stderr.find("INFO [alembic") :]) # noqa: E203 if result.returncode != 0: raise RuntimeError("Database migration failed.") else: diff --git a/openadapt/scripts/scrub.py b/openadapt/scripts/scrub.py index b4058f28c..24f6b8107 100644 --- a/openadapt/scripts/scrub.py +++ b/openadapt/scripts/scrub.py @@ -23,9 +23,8 @@ import math from loguru import logger +from moviepy.editor import VideoClip, VideoFileClip from PIL import Image -from moviepy.editor import VideoFileClip, VideoClip -from moviepy.video.fx import speedx from tqdm import tqdm import fire import numpy as np @@ -33,9 +32,13 @@ from openadapt import config, scrub, utils -def _make_frame(time, final, progress_bar, progress_threshold): - """ - Private function to scrub a frame. +def _make_frame( + time: int, + final: VideoFileClip, + progress_bar: tqdm.std.tqdm, + progress_threshold: int, +) -> np.ndarray: + """Private function to scrub a frame. Args: time: Time (in seconds) @@ -48,7 +51,6 @@ def _make_frame(time, final, progress_bar, progress_threshold): Returns: A Redacted frame """ - frame = final.get_frame(time) image = Image.fromarray(frame) @@ -73,8 +75,7 @@ def scrub_mp4( crop_start_time: int = 0, crop_end_time: Optional[int] = None, ) -> str: - """ - Scrub a mp4 file. + """Scrub a mp4 file. Args: mp4_file_path: Path to the mp4 file. @@ -86,7 +87,6 @@ def scrub_mp4( Returns: Path to the scrubbed (redacted) mp4 file. """ - logger.info(f"{mp4_file=}") logger.info(f"{scrub_all_entities=}") logger.info(f"{playback_speed_multiplier=}") @@ -119,12 +119,7 @@ def scrub_mp4( progress_threshold = math.floor(frame_count * progress_interval) redacted_clip = VideoClip( - make_frame=lambda t: _make_frame( - t, - final, - progress_bar, - progress_threshold, - ), + make_frame=lambda t: _make_frame(t, final, progress_bar, progress_threshold), duration=final.duration, ) # Redact the clip diff --git a/openadapt/scrub.py b/openadapt/scrub.py index dedb691de..5ea28dc6d 100644 --- a/openadapt/scrub.py +++ b/openadapt/scrub.py @@ -7,22 +7,20 @@ $ python openadapt/scrub.py scrub_dict dict_arg $ python openadapt/scrub.py scrub_list_dicts list_of_dicts_arg +Module: scrub.py """ -from typing import List, Dict, Union, Any +from typing import Union + from PIL import Image from presidio_analyzer import AnalyzerEngine from presidio_analyzer.nlp_engine import NlpEngineProvider from presidio_anonymizer import AnonymizerEngine -from presidio_image_redactor import ( - ImageRedactorEngine, - ImageAnalyzerEngine, -) +from presidio_image_redactor import ImageAnalyzerEngine, ImageRedactorEngine import fire from openadapt import config, utils - SCRUB_PROVIDER_TRF = NlpEngineProvider(nlp_configuration=config.SCRUB_CONFIG_TRF) NLP_ENGINE_TRF = SCRUB_PROVIDER_TRF.create_engine() ANALYZER_TRF = AnalyzerEngine(nlp_engine=NLP_ENGINE_TRF, supported_languages=["en"]) @@ -36,16 +34,15 @@ def scrub_text(text: str, is_separated: bool = False) -> str: - """ - Scrub the text of all PII/PHI using Presidio ANALYZER.TRF and Anonymizer + """Scrub the text of all PII/PHI using Presidio ANALYZER.TRF and Anonymizer. Args: text (str): Text to be scrubbed + is_separated (bool): Whether the text is separated with special characters Returns: str: Scrubbed text """ - if text is None: return None @@ -76,8 +73,7 @@ def scrub_text(text: str, is_separated: bool = False) -> str: def scrub_text_all(text: str) -> str: - """ - Scrub the text by replacing all characters with config.SCRUB_CHAR + """Scrub the text by replacing all characters with config.SCRUB_CHAR. Args: text (str): Text to be scrubbed @@ -85,23 +81,19 @@ def scrub_text_all(text: str) -> str: Returns: str: Scrubbed text """ - return config.SCRUB_CHAR * len(text) -def scrub_image( - image: Image, fill_color=config.SCRUB_FILL_COLOR -) -> Image: - """ - Scrub the image of all PII/PHI using Presidio Image Redactor +def scrub_image(image: Image, fill_color: int = config.SCRUB_FILL_COLOR) -> Image: + """Scrub the image of all PII/PHI using Presidio Image Redactor. Args: - image (PIL.Image): A PIL.Image object to be scrubbed + image (Image): A PIL.Image object to be scrubbed + fill_color (int): The color used to fill the redacted regions(BGR). Returns: - PIL.Image: The scrubbed image with PII and PHI removed. + Image: The scrubbed image with PII and PHI removed. """ - redacted_image = IMAGE_REDACTOR.redact( image, fill=fill_color, entities=SCRUBBING_ENTITIES ) @@ -110,22 +102,23 @@ def scrub_image( def _should_scrub_text( - key: Any, value: Any, list_keys: List[str], scrub_all: bool = False + key: str, + value: str, + list_keys: list[str], + scrub_all: bool = False, ) -> bool: - """ - Check if the key and value should be scrubbed and are of correct instance. + """Check if the key and value should be scrubbed and are of correct instance. Args: - key (Any): The key of the dict item - value (Any): The value of the dict item - list_keys (list): A list of keys that are needed to be scrubbed - scrub_all (bool): Whether to scrub - all sub-field/keys/values of that particular key + key (str): The key of the item. + value (str): The value of the item. + list_keys (list[str]): A list of keys that need to be scrubbed. + scrub_all (bool): Whether to scrub all sub-fields/keys/values + of that particular key. Returns: - bool: True if the key and value should be scrubbed, False otherwise + bool: True if the key and value should be scrubbed, False otherwise. """ - return ( isinstance(value, str) and isinstance(key, str) @@ -134,8 +127,7 @@ def _should_scrub_text( def _is_scrubbed(old_text: str, new_text: str) -> bool: - """ - Check if the text has been scrubbed + """Check if the text has been scrubbed. Args: old_text (str): The original text @@ -144,24 +136,19 @@ def _is_scrubbed(old_text: str, new_text: str) -> bool: Returns: bool: True if the text has been scrubbed, False otherwise """ - return old_text != new_text -def _scrub_text_item( - value: str, key: Any, force_scrub_children: bool = False -) -> str: - """ - Scrubs the value of a dict item. +def _scrub_text_item(value: str, key: str, force_scrub_children: bool = False) -> str: + """Scrubs the value of a text item. Args: - value (str): The value of the dict item - key (Any): The key of the dict item + value (str): The value of the item + key (str): The key of the item Returns: str: The scrubbed value """ - if key in ("text", "canonical_text"): return scrub_text(value, is_separated=True) if force_scrub_children: @@ -169,34 +156,27 @@ def _scrub_text_item( return scrub_text(value) -def _should_scrub_list_item(item: Any, key: Any, list_keys: List[str]) -> bool: - """ - Check if the key and item should be scrubbed and are of correct instance. +def _should_scrub_list_item(item: str, key: str, list_keys: list[str]) -> bool: + """Check if the key and item should be scrubbed and are of correct instance. Args: - item (str/dict/other): The value of the dict item - key (str): The key of the dict item + item (str): The value of the item + key (str): The key of the item list_keys (list): A list of keys that are needed to be scrubbed Returns: bool: True if the key and value should be scrubbed, False otherwise """ - - return ( - isinstance(item, (str, dict)) - and isinstance(key, str) - and key in list_keys - ) + return isinstance(item, (str)) and isinstance(key, str) and key in list_keys def _scrub_list_item( - item: Union[str, Dict], + item: Union[str, dict], key: str, - list_keys: List[str], + list_keys: list[str], force_scrub_children: bool = False, -) -> Union[str, Dict]: - """ - Scrubs the value of a dict item. +) -> Union[str, dict]: + """Scrubs the value of a dict item. Args: item (str/dict): The value of the dict item @@ -206,11 +186,8 @@ def _scrub_list_item( Returns: dict/str: The scrubbed dict/value respectively """ - if isinstance(item, dict): - return scrub_dict( - item, list_keys, force_scrub_children=force_scrub_children - ) + return scrub_dict(item, list_keys, force_scrub_children=force_scrub_children) return _scrub_text_item(item, key) @@ -220,28 +197,27 @@ def scrub_dict( scrub_all: bool = False, force_scrub_children: bool = False, ) -> dict: - """ - Scrub the dict of all PII/PHI using Presidio ANALYZER.TRF and Anonymizer. + """Scrub the dict of all PII/PHI using Presidio ANALYZER.TRF and Anonymizer. Args: input_dict (dict): A dict to be scrubbed + list_keys (list): List of keys to be scrubbed + scrub_all (bool): Whether to scrub all sub-fields/keys/values + of that particular key + force_scrub_children (bool): Whether to force scrub children + even if key is not present Returns: dict: The scrubbed dict with PII and PHI removed. """ - if list_keys is None: list_keys = config.SCRUB_KEYS_HTML scrubbed_dict = {} for key, value in input_dict.items(): if _should_scrub_text(key, value, list_keys, scrub_all): - scrubbed_text = _scrub_text_item( - value, key, force_scrub_children - ) - if key in ("text", "canonical_text") and _is_scrubbed( - value, scrubbed_text - ): + scrubbed_text = _scrub_text_item(value, key, force_scrub_children) + if key in ("text", "canonical_text") and _is_scrubbed(value, scrubbed_text): force_scrub_children = True scrubbed_dict[key] = scrubbed_text elif isinstance(value, list): @@ -264,18 +240,16 @@ def scrub_dict( return scrubbed_dict -def scrub_list_dicts(input_list: List[Dict], list_keys: List = None) -> List[Dict]: - """ - Scrub the list of dicts of all PII/PHI - using Presidio ANALYZER.TRF and Anonymizer. +def scrub_list_dicts(input_list: list[dict], list_keys: list = None) -> list[dict]: + """Scrub list of dicts to remove PII/PHI using Presidio ANALYZER.TRF and Anonymizer. Args: input_list (list[dict]): A list of dicts to be scrubbed + list_keys (list): List of keys to be scrubbed Returns: list[dict]: The scrubbed list of dicts with PII and PHI removed. """ - scrubbed_list_dicts = [] for input_dict in input_list: scrubbed_list_dicts.append(scrub_dict(input_dict, list_keys)) diff --git a/openadapt/start.py b/openadapt/start.py index f8fb6b71d..9e4db6b3d 100644 --- a/openadapt/start.py +++ b/openadapt/start.py @@ -1,19 +1,17 @@ -""" -Implements the code needed to update the OpenAdapt app if needed. +"""Implements the code necessary to update the OpenAdapt app if required. Usage: python3 -m openadapt.start """ -from loguru import logger import subprocess +from loguru import logger + from openadapt.app.main import run_app def main() -> None: - """ - The main function which runs the OpenAdapt app when it is updated. - """ + """The main function which runs the OpenAdapt app when it is updated.""" result = subprocess.run(["git", "status"], capture_output=True, text=True) if "unmerged" in result.stdout: diff --git a/openadapt/strategies/__init__.py b/openadapt/strategies/__init__.py index 7d49003ca..273dab072 100644 --- a/openadapt/strategies/__init__.py +++ b/openadapt/strategies/__init__.py @@ -1,4 +1,10 @@ -from openadapt.strategies.base import BaseReplayStrategy -from openadapt.strategies.naive import NaiveReplayStrategy -from openadapt.strategies.demo import DemoReplayStrategy +"""Package containing different replay strategies. + +Module: __init__.py +""" + +# from openadapt.strategies.base import BaseReplayStrategy +# from openadapt.strategies.naive import NaiveReplayStrategy +# from openadapt.strategies.demo import DemoReplayStrategy + # add more strategies here diff --git a/openadapt/strategies/base.py b/openadapt/strategies/base.py index c998d010b..cd2d56c3a 100644 --- a/openadapt/strategies/base.py +++ b/openadapt/strategies/base.py @@ -1,5 +1,6 @@ -""" -Implements the base class for implementing replay strategies. +"""Implements the base class for implementing replay strategies. + +Module: base.py """ from abc import ABC, abstractmethod @@ -8,22 +9,27 @@ from loguru import logger from pynput import keyboard, mouse -import mss.base import numpy as np -from openadapt import models, playback, utils, window - +from openadapt import models, playback, utils MAX_FRAME_TIMES = 1000 class BaseReplayStrategy(ABC): + """Base class for implementing replay strategies.""" def __init__( self, recording: models.Recording, max_frame_times: int = MAX_FRAME_TIMES, - ): + ) -> None: + """Initialize the BaseReplayStrategy. + + Args: + recording (models.Recording): The recording to replay. + max_frame_times (int): The maximum number of frame times to track. + """ self.recording = recording self.max_frame_times = max_frame_times self.action_events = [] @@ -36,9 +42,18 @@ def get_next_action_event( self, screenshot: models.Screenshot, ) -> models.ActionEvent: + """Get the next action event based on the current screenshot. + + Args: + screenshot (models.Screenshot): The current screenshot. + + Returns: + models.ActionEvent: The next action event. + """ pass - def run(self): + def run(self) -> None: + """Run the replay strategy.""" keyboard_controller = keyboard.Controller() mouse_controller = mouse.Controller() while True: @@ -48,14 +63,16 @@ def run(self): self.window_events.append(window_event) try: action_event = self.get_next_action_event( - screenshot, window_event, + screenshot, + window_event, ) except StopIteration: break if self.action_events: prev_action_event = self.action_events[-1] assert prev_action_event.timestamp <= action_event.timestamp, ( - prev_action_event, action_event + prev_action_event, + action_event, ) self.log_fps() if action_event: @@ -64,10 +81,7 @@ def run(self): [action_event], drop_constant=False, )[0] - logger.info( - f"action_event=\n" - f"{pformat(action_event_dict)}" - ) + logger.info(f"action_event=\n" f"{pformat(action_event_dict)}") try: playback.play_action_event( action_event, @@ -76,9 +90,12 @@ def run(self): ) except Exception as exc: logger.exception(exc) - import ipdb; ipdb.set_trace() + import ipdb + + ipdb.set_trace() - def log_fps(self): + def log_fps(self) -> None: + """Log the frames per second (FPS) rate.""" t = time.time() self.frame_times.append(t) dts = np.diff(self.frame_times) diff --git a/openadapt/strategies/demo.py b/openadapt/strategies/demo.py index d86eb39ad..8d0d31ffc 100644 --- a/openadapt/strategies/demo.py +++ b/openadapt/strategies/demo.py @@ -1,5 +1,4 @@ -""" -Demonstration of HuggingFace, OCR, and ASCII ReplayStrategyMixins. +"""Demonstration of HuggingFace, OCR, and ASCII ReplayStrategyMixins. Usage: @@ -7,19 +6,16 @@ """ from loguru import logger -import numpy as np -from openadapt.crud import get_screenshots -from openadapt.events import get_events +from openadapt.crud import get_screenshots from openadapt.models import Recording, Screenshot, WindowEvent from openadapt.strategies.base import BaseReplayStrategy +from openadapt.strategies.mixins.ascii import ASCIIReplayStrategyMixin from openadapt.strategies.mixins.huggingface import ( - HuggingFaceReplayStrategyMixin, MAX_INPUT_SIZE, + HuggingFaceReplayStrategyMixin, ) - from openadapt.strategies.mixins.ocr import OCRReplayStrategyMixin -from openadapt.strategies.mixins.ascii import ASCIIReplayStrategyMixin from openadapt.strategies.mixins.sam import SAMReplayStrategyMixin from openadapt.strategies.mixins.summary import SummaryReplayStrategyMixin @@ -32,10 +28,17 @@ class DemoReplayStrategy( SummaryReplayStrategyMixin, BaseReplayStrategy, ): + """Demo replay strategy that combines HuggingFace, OCR, and ASCII mixins.""" + def __init__( self, recording: Recording, - ): + ) -> None: + """Initialize the DemoReplayStrategy. + + Args: + recording (Recording): The recording to replay. + """ super().__init__(recording) self.result_history = [] self.screenshots = get_screenshots(recording) @@ -45,37 +48,45 @@ def get_next_action_event( self, screenshot: Screenshot, window_event: WindowEvent, - ): - ascii_text = self.get_ascii_text(screenshot) + ) -> None: + """Get the next action event based on the current screenshot and window event. + + Args: + screenshot (Screenshot): The current screenshot. + window_event (WindowEvent): The current window event. + + Returns: + None: No action event is returned in this demo strategy. + """ + # ascii_text = self.get_ascii_text(screenshot) # logger.info(f"ascii_text=\n{ascii_text}") - ocr_text = self.get_ocr_text(screenshot) + # ocr_text = self.get_ocr_text(screenshot) # logger.info(f"ocr_text=\n{ocr_text}") screenshot_bbox = self.get_screenshot_bbox(screenshot) logger.info(f"screenshot_bbox=\n{screenshot_bbox}") - screenshot_click_event_bbox = self.get_click_event_bbox(self.screenshots[self.screenshot_idx]) - logger.info(f"self.screenshots[self.screenshot_idx].action_event=\n{screenshot_click_event_bbox}") - event_strs = [ - f"<{event}>" - for event in self.recording.action_events - ] - history_strs = [ - f"<{completion}>" - for completion in self.result_history - ] + screenshot_click_event_bbox = self.get_click_event_bbox( + self.screenshots[self.screenshot_idx] + ) + logger.info( + "self.screenshots[self.screenshot_idx].action_event=\n" + f"{screenshot_click_event_bbox}" + ) + event_strs = [f"<{event}>" for event in self.recording.action_events] + history_strs = [f"<{completion}>" for completion in self.result_history] prompt = " ".join(event_strs + history_strs) N = max(0, len(prompt) - MAX_INPUT_SIZE) prompt = prompt[N:] - #logger.info(f"{prompt=}") + # logger.info(f"{prompt=}") max_tokens = 10 completion = self.get_completion(prompt, max_tokens) - #logger.info(f"{completion=}") + # logger.info(f"{completion=}") # only take the first <...> result = completion.split(">")[0].strip(" <>") - #logger.info(f"{result=}") + # logger.info(f"{result=}") self.result_history.append(result) # TODO: parse result into ActionEvent(s) diff --git a/openadapt/strategies/mixins/ascii.py b/openadapt/strategies/mixins/ascii.py index 1ae331ab8..8a86b4b8d 100644 --- a/openadapt/strategies/mixins/ascii.py +++ b/openadapt/strategies/mixins/ascii.py @@ -1,5 +1,4 @@ -""" -Implements a ReplayStrategy mixin for converting images to ASCII. +"""Implements a ReplayStrategy mixin for converting images to ASCII. Usage: @@ -13,18 +12,23 @@ class MyReplayStrategy(ASCIIReplayStrategyMixin): from openadapt.models import Recording, Screenshot from openadapt.strategies.base import BaseReplayStrategy - COLUMNS = 120 WIDTH_RATIO = 2.2 MONOCHROME = True class ASCIIReplayStrategyMixin(BaseReplayStrategy): + """ReplayStrategy mixin for converting images to ASCII.""" def __init__( self, recording: Recording, - ): + ) -> None: + """Initialize the ASCIIReplayStrategyMixin. + + Args: + recording (Recording): The recording to replay. + """ super().__init__(recording) def get_ascii_text( @@ -33,8 +37,18 @@ def get_ascii_text( monochrome: bool = MONOCHROME, columns: int = COLUMNS, width_ratio: float = WIDTH_RATIO, - - ): + ) -> str: + """Convert the screenshot image to ASCII text. + + Args: + screenshot (Screenshot): The screenshot to convert. + monochrome (bool): Flag to indicate monochrome conversion (default: True). + columns (int): Number of columns for the ASCII text (default: 120). + width_ratio (float): Width ratio for the ASCII text (default: 2.2). + + Returns: + str: The ASCII representation of the screenshot image. + """ ascii_art = AsciiArt.from_pillow_image(screenshot.image) ascii_text = ascii_art.to_ascii( monochrome=monochrome, diff --git a/openadapt/strategies/mixins/huggingface.py b/openadapt/strategies/mixins/huggingface.py index 0d091cde8..d5eba21ba 100644 --- a/openadapt/strategies/mixins/huggingface.py +++ b/openadapt/strategies/mixins/huggingface.py @@ -1,32 +1,39 @@ -""" -Implements a ReplayStrategy mixin for generating completions with HuggingFace. +"""Implements a ReplayStrategy mixin for generating completions with HuggingFace. Usage: - class MyReplayStrategy(LLMReplayStrategyMixin): + class MyReplayStrategy(HuggingFaceReplayStrategyMixin): ... """ - from loguru import logger import transformers as tf # RIP TensorFlow from openadapt.models import Recording from openadapt.strategies.base import BaseReplayStrategy - MODEL_NAME = "gpt2" # gpt2-xl is bigger and slower MAX_INPUT_SIZE = 1024 class HuggingFaceReplayStrategyMixin(BaseReplayStrategy): + """ReplayStrategy mixin for generating completions with HuggingFace.""" def __init__( self, recording: Recording, model_name: str = MODEL_NAME, max_input_size: int = MAX_INPUT_SIZE, - ): + ) -> None: + """Initialize the HuggingFaceReplayStrategyMixin. + + Args: + recording (Recording): The recording to replay. + model_name (str): The name of the HuggingFace model to use + (default: "gpt2"). + max_input_size (int): The maximum input size for the model + (default: 1024). + """ super().__init__(recording) logger.info(f"{model_name=}") @@ -38,16 +45,21 @@ def get_completion( self, prompt: str, max_tokens: int, - ): + ) -> str: + """Generate completion for a given prompt using the HuggingFace model. + + Args: + prompt (str): The prompt for generating completion. + max_tokens (int): The maximum number of tokens to generate. + + Returns: + str: The generated completion. + """ max_input_size = self.max_input_size if max_input_size and len(prompt) > max_input_size: - logger.warning( - f"Truncating from {len(prompt) =} to {max_input_size=}" - ) + logger.warning(f"Truncating from {len(prompt) =} to {max_input_size=}") prompt = prompt[-max_input_size:] - logger.warning( - f"Truncated {len(prompt)=}" - ) + logger.warning(f"Truncated {len(prompt)=}") logger.debug(f"{prompt=} {max_tokens=}") input_tokens = self.tokenizer(prompt, return_tensors="pt") @@ -58,7 +70,7 @@ def get_completion( attention_mask=attention_mask, max_length=input_tokens["input_ids"].shape[-1] + max_tokens, pad_token_id=pad_token_id, - num_return_sequences=1 + num_return_sequences=1, ) N = input_tokens["input_ids"].shape[-1] completion = self.tokenizer.decode( diff --git a/openadapt/strategies/mixins/ocr.py b/openadapt/strategies/mixins/ocr.py index 38313ebc0..10371eec8 100644 --- a/openadapt/strategies/mixins/ocr.py +++ b/openadapt/strategies/mixins/ocr.py @@ -1,5 +1,4 @@ -""" -Implements a ReplayStrategy mixin for getting text from images via OCR. +"""Implements a ReplayStrategy mixin for getting text from images via OCR. Uses RapidOCR: github.com/RapidAI/RapidOCR/blob/main/python/README.md @@ -9,41 +8,49 @@ class MyReplayStrategy(OCRReplayStrategyMixin): ... """ -from typing import List, Union -import itertools +from typing import Union from loguru import logger -from PIL import Image from rapidocr_onnxruntime import RapidOCR from sklearn.cluster import DBSCAN -import numpy as np import pandas as pd from openadapt.models import Recording, Screenshot from openadapt.strategies.base import BaseReplayStrategy - # TODO: group into sections via layout analysis; see: # github.com/RapidAI/RapidOCR/blob/main/python/rapid_structure/docs/README_Layout.md class OCRReplayStrategyMixin(BaseReplayStrategy): + """ReplayStrategy mixin for getting text from images via OCR.""" + def __init__( self, recording: Recording, - ): + ) -> None: + """Initialize the OCRReplayStrategyMixin. + + Args: + recording (Recording): The recording to replay. + """ super().__init__(recording) self.ocr = RapidOCR() - def get_ocr_text( - self, - screenshot: Screenshot - ): + def get_ocr_text(self, screenshot: Screenshot) -> str: + """Get the OCR text from a screenshot. + + Args: + screenshot (Screenshot): The screenshot. + + Returns: + str: The OCR text. + """ # TOOD: improve performance result, elapse = self.ocr(screenshot.array) - #det_elapse, cls_elapse, rec_elapse = elapse - #all_elapse = det_elapse + cls_elapse + rec_elapse + # det_elapse, cls_elapse, rec_elapse = elapse + # all_elapse = det_elapse + cls_elapse + rec_elapse logger.debug(f"{result=}") logger.debug(f"{elapse=}") df_text = get_text_df(result) @@ -52,50 +59,43 @@ def get_ocr_text( return text -def get_text_df( - result: List[List[Union[List[float], str, float]]], -): - """ - Convert RapidOCR result to DataFrame. - - Args: - result: list of [coordinates, text, confidence] - coordinates: - [tl_x, tl_y], - [tr_x, tr_y], - [br_x, br_y], - [bl_x, bl_y] +def get_text_df(result: list[list[Union[list[float], str, float]]]) -> pd.DataFrame: + """Convert RapidOCR result to DataFrame. - Returns: - pd.DataFrame - """ + Args: + result: list of [coordinates, text, confidence] + coordinates: + [tl_x, tl_y], + [tr_x, tr_y], + [br_x, br_y], + [bl_x, bl_y] - coords = [coords for coords, text, confidence in result] - columns = ["tl", "tr", "bl", "br"] - df = pd.DataFrame(coords, columns=columns) - df = unnest(df, df.columns, 0, suffixes=["_x", "_y"]) + Returns: + pd.DataFrame + """ + coords = [coords for coords, text, confidence in result] + columns = ["tl", "tr", "bl", "br"] + df = pd.DataFrame(coords, columns=columns) + df = unnest(df, df.columns, 0, suffixes=["_x", "_y"]) - texts = [text for coords, text, confidence in result] - df["text"] = texts + texts = [text for coords, text, confidence in result] + df["text"] = texts - confidences = [confidence for coords, text, confidence in result] - df["confidence"] = confidences - logger.debug(f"df=\n{df}") - return df + confidences = [confidence for coords, text, confidence in result] + df["confidence"] = confidences + logger.debug(f"df=\n{df}") + return df -def get_text_from_df( - df: pd.DataFrame, -): - """Converts a DataFrame produced by get_text_df into a string. +def get_text_from_df(df: pd.DataFrame) -> str: + """Convert a DataFrame produced by get_text_df into a string. - Params: - df: DataFrame produced by get_text_df + Args: + df (pd.DataFrame): The DataFrame produced by get_text_df. Returns: - str + str: The converted text. """ - df["text"] = df["text"].apply(preprocess_text) sorted_df = sort_rows(df) df["height"] = df.apply(get_height, axis=1) @@ -106,7 +106,21 @@ def get_text_from_df( return result -def unnest(df, explode, axis, suffixes=None): +def unnest( + df: pd.DataFrame, explode: list, axis: int, suffixes: list = None +) -> pd.DataFrame: + """Unnest specified columns in a DataFrame by exploding values. + + Args: + df (pd.DataFrame): DataFrame to unnest. + explode (list): Columns to unnest. + axis (int): Axis to unnest along (1 for columns, 0 for rows). + suffixes (list, optional): Suffixes for unnested column names. + + Returns: + pd.DataFrame: Unnested DataFrame. + + """ # https://stackoverflow.com/a/53218939 if axis == 1: df1 = pd.concat([df[x].explode() for x in explode], axis=1) @@ -129,21 +143,54 @@ def unnest(df, explode, axis, suffixes=None): ) -def preprocess_text(text): +def preprocess_text(text: str) -> str: + """Preprocess the OCR text. + + Args: + text (str): The OCR text. + + Returns: + str: The preprocessed text. + """ return text.strip() -def get_centroid(row): +def get_centroid(row: pd.Series) -> tuple: + """Compute the centroid coordinates from the corners of a rectangle. + + Args: + row (pd.Series): Row containing corner coordinates. + + Returns: + tuple: Tuple of centroid coordinates (x, y). + + """ x = (row["tl_x"] + row["tr_x"] + row["bl_x"] + row["br_x"]) / 4 y = (row["tl_y"] + row["tr_y"] + row["bl_y"] + row["br_y"]) / 4 return x, y -def get_height(row): +def get_height(row: pd.Series) -> float: + """Get the height of a row. + + Args: + row (pd.Series): The row of the DataFrame. + + Returns: + float: The height. + """ return abs(row["tl_y"] - row["bl_y"]) -def sort_rows(df): +def sort_rows(df: pd.DataFrame) -> pd.DataFrame: + """Sort the rows of the DataFrame. + + Args: + df (pd.DataFrame): The DataFrame to sort. + + Returns: + pd.DataFrame: The sorted DataFrame. + """ df["centroid"] = df.apply(get_centroid, axis=1) df["x"] = df["centroid"].apply(lambda coord: coord[0]) df["y"] = df["centroid"].apply(lambda coord: coord[1]) @@ -151,14 +198,31 @@ def sort_rows(df): return df -def cluster_lines(df, eps): +def cluster_lines(df: pd.DataFrame, eps: float) -> pd.DataFrame: + """Cluster lines in the DataFrame. + + Args: + df (pd.DataFrame): The DataFrame to cluster. + eps (float): The epsilon value for DBSCAN. + + Returns: + pd.DataFrame: The DataFrame with line clustering. + """ coords = df[["x", "y"]].to_numpy() cluster_model = DBSCAN(eps=eps, min_samples=1) df["line_cluster"] = cluster_model.fit_predict(coords) return df -def cluster_words(df): +def cluster_words(df: pd.DataFrame) -> pd.DataFrame: + """Cluster words in the DataFrame. + + Args: + df (pd.DataFrame): The DataFrame to cluster. + + Returns: + pd.DataFrame: The DataFrame with word clustering. + """ line_dfs = [] for line_cluster in df["line_cluster"].unique(): line_df = df[df["line_cluster"] == line_cluster].copy() @@ -175,7 +239,15 @@ def cluster_words(df): return pd.concat(line_dfs) -def concat_text(df): +def concat_text(df: pd.DataFrame) -> str: + """Concatenate text from the DataFrame. + + Args: + df (pd.DataFrame): The DataFrame with clustered words. + + Returns: + str: The concatenated text. + """ df.sort_values(by=["line_cluster", "word_cluster"], inplace=True) lines = df.groupby("line_cluster")["text"].apply(lambda x: " ".join(x)) return "\n".join(lines) diff --git a/openadapt/strategies/mixins/openai.py b/openadapt/strategies/mixins/openai.py index 2698cf61b..35386b87a 100644 --- a/openadapt/strategies/mixins/openai.py +++ b/openadapt/strategies/mixins/openai.py @@ -1,5 +1,4 @@ -""" -Implements a ReplayStrategy mixin for generating LLM completions. +"""Implements a ReplayStrategy mixin for generating LLM completions. Usage: @@ -8,14 +7,14 @@ class MyReplayStrategy(OpenAIReplayStrategyMixin): """ from pprint import pformat +import time from loguru import logger import openai import tiktoken -from openadapt.strategies.base import BaseReplayStrategy from openadapt import cache, config, models - +from openadapt.strategies.base import BaseReplayStrategy # https://github.com/nalgeon/pokitoki/blob/0b6b921d367f693738e7b9bab44e6926171b48d6/bot/ai/chatgpt.py#L78 # OpenAI counts length in tokens, not characters. @@ -34,34 +33,45 @@ class MyReplayStrategy(OpenAIReplayStrategyMixin): class OpenAIReplayStrategyMixin(BaseReplayStrategy): + """Mixin class implementing replay strategy using OpenAI models.""" def __init__( self, recording: models.Recording, model_name: str = config.OPENAI_MODEL_NAME, - #system_message: str = config.OPENAI_SYSTEM_MESSAGE, - ): + # system_message: str = config.OPENAI_SYSTEM_MESSAGE, + ) -> None: + """Initialize the OpenAIReplayStrategyMixin. + + Args: + recording (models.Recording): The recording object. + model_name (str): The name of the OpenAI model to use. + + """ super().__init__(recording) logger.info(f"{model_name=}") self.model_name = model_name - #self.system_message = system_message + # self.system_message = system_message def get_completion( self, prompt: str, system_message: str, - #max_tokens: int, - ): + # max_tokens: int, + ) -> str: + """Generates an LLM completion. + + Args: + prompt (str): The prompt for the completion. + system_message (str): The system message to set the context. + + Returns: + str: The generated completion. + """ messages = [ - { - "role": "system", - "content": system_message, - }, - { - "role": "user", - "content": prompt, - } + {"role": "system", "content": system_message}, + {"role": "user", "content": prompt}, ] logger.debug(f"messages=\n{pformat(messages)}") completion = create_openai_completion(self.model_name, messages) @@ -75,8 +85,8 @@ def get_completion( @cache.cache() def create_openai_completion( - model, - messages, + model: str, + messages: list, # temperatere=1, # top_p=1, # n=1, @@ -87,7 +97,16 @@ def create_openai_completion( # frequency_penalty=0, # logit_bias=None, # user=None, -): +) -> dict: + """Creates an LLM completion using the OpenAI API. + + Args: + model (str): The model name. + messages (list): The list of messages. + + Returns: + dict: The completion response. + """ return openai.ChatCompletion.create( model=model, messages=messages, @@ -106,28 +125,36 @@ def create_openai_completion( @cache.cache() def get_completion( - messages, - prompt, - model="gpt-4", -): - + messages: list, + prompt: str, + model: str = "gpt-4", +) -> list[str]: + """Gets the LLM completion. + + Args: + messages (list): The list of messages. + prompt (str): The prompt for the completion. + model (str): The model name. + + Returns: + list: The list of messages with the generated completion. + """ logger.info(f"{prompt=}") - messages.append( - { - "role": "user", - "content": prompt, - } - ) - length = MAX_LENGTHS[model] - shorten_messages(messages, length) + messages.append({"role": "user", "content": prompt}) + # length = MAX_LENGTHS[model] + # shorten_messages(messages, length) logger.debug(f"messages=\n{pformat(messages)}") - def _get_completion( - prompt: str, - ) -> str: - """TODO""" + def _get_completion(prompt: str) -> str: + """Helper function to get the LLM completion. + Args: + prompt (str): The prompt for the completion. + + Returns: + str: The generated completion. + """ try: completion = create_openai_completion(model, messages) logger.info(f"{completion=}") @@ -164,7 +191,7 @@ def _get_completion( # XXX TODO not currently in use # https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb -def num_tokens_from_messages(messages, model="gpt-3.5-turbo-0301"): +def num_tokens_from_messages(messages: list, model: str = "gpt-3.5-turbo-0301") -> int: """Returns the number of tokens used by a list of messages.""" try: encoding = tiktoken.encoding_for_model(model) @@ -196,7 +223,8 @@ def num_tokens_from_messages(messages, model="gpt-3.5-turbo-0301"): f"""num_tokens_from_messages() is not implemented for model " "{model}. See " "https://github.com/openai/openai-python/blob/main/chatml.md for " - information on how messages are converted to tokens.""") + information on how messages are converted to tokens.""" + ) num_tokens = 0 for message in messages: num_tokens += tokens_per_message diff --git a/openadapt/strategies/mixins/sam.py b/openadapt/strategies/mixins/sam.py index 6787916b6..5f1a193ac 100644 --- a/openadapt/strategies/mixins/sam.py +++ b/openadapt/strategies/mixins/sam.py @@ -1,28 +1,29 @@ -""" -Implements a ReplayStrategy mixin for getting segmenting images via SAM model. +"""Implements a ReplayStrategy mixin for getting segmenting images via SAM model. -Uses SAM model:https://github.com/facebookresearch/segment-anything +Uses SAM model:https://github.com/facebookresearch/segment-anything Usage: class MyReplayStrategy(SAMReplayStrategyMixin): ... """ -from pprint import pformat -from mss import mss -import numpy as np -from openadapt import models -from segment_anything import SamPredictor, sam_model_registry, SamAutomaticMaskGenerator -from PIL import Image -from loguru import logger -from openadapt.events import get_events -from openadapt.utils import display_event, rows2dicts -from openadapt.models import Recording, Screenshot, WindowEvent + from pathlib import Path import urllib -import numpy as np + +from loguru import logger +from PIL import Image +from segment_anything import ( + modeling, + SamAutomaticMaskGenerator, + SamPredictor, + sam_model_registry, +) +import matplotlib.axes as axes import matplotlib.pyplot as plt +import numpy as np +from openadapt.models import Recording, Screenshot from openadapt.strategies.base import BaseReplayStrategy CHECKPOINT_URL_BASE = "https://dl.fbaipublicfiles.com/segment_anything/" @@ -38,18 +39,38 @@ class MyReplayStrategy(SAMReplayStrategyMixin): class SAMReplayStrategyMixin(BaseReplayStrategy): + """ReplayStrategy mixin for segmenting images via the SAM model.""" + def __init__( self, recording: Recording, - model_name=MODEL_NAME, - checkpoint_dir_path=CHECKPOINT_DIR_PATH, - ): + model_name: str = MODEL_NAME, + checkpoint_dir_path: str = CHECKPOINT_DIR_PATH, + ) -> None: + """Initialize the SAMReplayStrategyMixin. + + Args: + recording (Recording): The recording object. + model_name (str): The name of the SAM model to use. Defaults to MODEL_NAME. + checkpoint_dir_path (str): The directory path to store SAM model checkpoint. + """ super().__init__(recording) self.sam_model = self._initialize_model(model_name, checkpoint_dir_path) self.sam_predictor = SamPredictor(self.sam_model) self.sam_mask_generator = SamAutomaticMaskGenerator(self.sam_model) - def _initialize_model(self, model_name, checkpoint_dir_path): + def _initialize_model( + self, model_name: str, checkpoint_dir_path: str + ) -> modeling.Sam: + """Initialize the SAM model. + + Args: + model_name (str): The name of the SAM model. + checkpoint_dir_path (str): The directory path to store SAM model checkpoint. + + Returns: + modeling.Sam: The initialized SAM model. + """ checkpoint_url = CHECKPOINT_URL_BY_NAME[model_name] checkpoint_file_name = checkpoint_url.split("/")[-1] checkpoint_file_path = Path(checkpoint_dir_path, checkpoint_file_name) @@ -59,17 +80,17 @@ def _initialize_model(self, model_name, checkpoint_dir_path): urllib.request.urlretrieve(checkpoint_url, checkpoint_file_path) return sam_model_registry[model_name](checkpoint=checkpoint_file_path) - def get_screenshot_bbox(self, screenshot: Screenshot, show_plots=SHOW_PLOTS) -> str: - """ - Get the bounding boxes of objects in a screenshot image with RESIZE_RATIO in XYWH format. + def get_screenshot_bbox( + self, screenshot: Screenshot, show_plots: bool = SHOW_PLOTS + ) -> str: + """Retrieve object bounding boxes of screenshot image(XYWH) with RESIZE_RATIO. Args: screenshot (Screenshot): The screenshot object containing the image. - show_plots (bool): Flag indicating whether to display the plots or not. Defaults to SHOW_PLOTS. + show_plots (bool): Flag indicating whether to display the plots or not. Returns: - str: A string representation of a list containing the bounding boxes of objects. - + str: String representation of list containing the bounding boxes of objects. """ image_resized = resize_image(screenshot.image) array_resized = np.array(image_resized) @@ -86,22 +107,23 @@ def get_screenshot_bbox(self, screenshot: Screenshot, show_plots=SHOW_PLOTS) -> return str(bbox_list) def get_click_event_bbox( - self, screenshot: Screenshot, show_plots=SHOW_PLOTS + self, screenshot: Screenshot, show_plots: bool = SHOW_PLOTS ) -> str: - """ - Get the bounding box of the clicked object in a resized image with RESIZE_RATIO in XYWH format. + """Get bounding box of a clicked object in resized image w/ RESIZE_RATIO(XYWH). Args: - screenshot (Screenshot): The screenshot object containing the image. - show_plots (bool): Flag indicating whether to display the plots or not. Defaults to SHOW_PLOTS. + screenshot: The screenshot object containing the image. + show_plots: Flag indicating whether to display the plots or not. Returns: - str: A string representation of a list containing the bounding box of the clicked object. - None: If the screenshot does not represent a click event with the mouse pressed. + str: A string representation of a list containing the bounding box + of clicked object. + None: If the screenshot does not represent a click event with + the mouse pressed. """ for action_event in screenshot.action_event: - if action_event.name in "click" and action_event.mouse_pressed == True: + if action_event.name in "click" and action_event.mouse_pressed is True: logger.info(f"click_action_event=\n{action_event}") image_resized = resize_image(screenshot.image) array_resized = np.array(image_resized) @@ -111,15 +133,30 @@ def get_click_event_bbox( resized_mouse_y = int(action_event.mouse_y * RESIZE_RATIO) # Add additional points around the clicked point additional_points = [ - [resized_mouse_x - 1, resized_mouse_y - 1], # Top-left + [ + resized_mouse_x - 1, + resized_mouse_y - 1, + ], # Top-left [resized_mouse_x - 1, resized_mouse_y], # Left - [resized_mouse_x - 1, resized_mouse_y + 1], # Bottom-left + [ + resized_mouse_x - 1, + resized_mouse_y + 1, + ], # Bottom-left [resized_mouse_x, resized_mouse_y - 1], # Top - [resized_mouse_x, resized_mouse_y], # Center (clicked point) + [ + resized_mouse_x, + resized_mouse_y, + ], # Center (clicked point) [resized_mouse_x, resized_mouse_y + 1], # Bottom - [resized_mouse_x + 1, resized_mouse_y - 1], # Top-right + [ + resized_mouse_x + 1, + resized_mouse_y - 1, + ], # Top-right [resized_mouse_x + 1, resized_mouse_y], # Right - [resized_mouse_x + 1, resized_mouse_y + 1], # Bottom-right + [ + resized_mouse_x + 1, + resized_mouse_y + 1, + ], # Bottom-right ] input_point = np.array(additional_points) self.sam_predictor.set_image(array_resized) @@ -157,8 +194,7 @@ def get_click_event_bbox( def resize_image(image: Image) -> Image: - """ - Resize the given image. + """Resize the given image. Args: image (PIL.Image.Image): The image to be resized. @@ -172,7 +208,15 @@ def resize_image(image: Image) -> Image: return image_resized -def show_mask(mask, ax, random_color=False): +def show_mask(mask: np.ndarray, ax: axes.Axes, random_color: bool = False) -> None: + """Display the mask on the plot. + + Args: + mask (np.ndarray): The mask array. + ax: The plot axes. + random_color (bool): Flag indicating whether to use a random color. + Defaults to False. + """ if random_color: color = np.concatenate([np.random.random(3), np.array([0.6])], axis=0) else: @@ -182,7 +226,20 @@ def show_mask(mask, ax, random_color=False): ax.imshow(mask_image) -def show_points(coords, labels, ax, marker_size=120): +def show_points( + coords: np.ndarray, + labels: np.ndarray, + ax: axes.Axes, + marker_size: int = 120, +) -> None: + """Display the points on the plot. + + Args: + coords (np.ndarray): The coordinates of the points. + labels (np.ndarray): The labels of the points. + ax: The plot axes. + marker_size (int): The marker size. Defaults to 120. + """ pos_points = coords[labels == 1] neg_points = coords[labels == 0] ax.scatter( @@ -205,15 +262,33 @@ def show_points(coords, labels, ax, marker_size=120): ) -def show_box(box, ax): +def show_box(box: list[int], ax: axes.Axes) -> None: + """Display the bounding box on the plot. + + Args: + box (list[int]): The bounding box coordinates in XYWH format. + ax: The plot axes. + """ x0, y0 = box[0], box[1] w, h = box[2], box[3] ax.add_patch( - plt.Rectangle((x0, y0), w, h, edgecolor="green", facecolor=(0, 0, 0, 0), lw=2) + plt.Rectangle( + (x0, y0), + w, + h, + edgecolor="green", + facecolor=(0, 0, 0, 0), + lw=2, + ) ) -def show_anns(anns): +def show_anns(anns: axes.Axes) -> None: + """Display the annotations on the plot. + + Args: + anns: The annotations. + """ if len(anns) == 0: return sorted_anns = sorted(anns, key=(lambda x: x["area"]), reverse=True) diff --git a/openadapt/strategies/mixins/summary.py b/openadapt/strategies/mixins/summary.py index a351731b0..24ab4f4fd 100644 --- a/openadapt/strategies/mixins/summary.py +++ b/openadapt/strategies/mixins/summary.py @@ -1,12 +1,11 @@ -""" -Implements a ReplayStrategy mixin which summarizes the content of texts. - +"""Implements a ReplayStrategy mixin which summarizes the content of texts. Usage: class MyReplayStrategy(SummaryReplayStrategyMixin): ... """ + from loguru import logger from sumy.nlp.stemmers import Stemmer from sumy.nlp.tokenizers import Tokenizer @@ -20,20 +19,20 @@ class MyReplayStrategy(SummaryReplayStrategyMixin): class SummaryReplayStrategyMixin(BaseReplayStrategy): - """ - The summary of text ReplayStrategyMixin. - """ + """ReplayStrategy mixin for summarizing text content.""" def __init__( self, recording: Recording, - ): - """ - See base class. + ) -> None: + """Initialize the SummaryReplayStrategyMixin. + + Args: + recording (Recording): The recording object. Additional Attributes: - - stemmer: - - summarizer: + - stemmer: The stemmer for text processing. + - summarizer: The summarizer for generating text summaries. """ super().__init__(recording) self.stemmer = Stemmer("english") @@ -46,6 +45,15 @@ def get_summary( text: str, num_sentences: int, ) -> str: + """Generate a summary of the given text. + + Args: + text (str): The text to summarize. + num_sentences (int): The number of sentences to include in the summary. + + Returns: + str: The summarized text. + """ while True: try: Tokenizer("english") diff --git a/openadapt/strategies/naive.py b/openadapt/strategies/naive.py index c99837e45..d58f5b43c 100644 --- a/openadapt/strategies/naive.py +++ b/openadapt/strategies/naive.py @@ -1,16 +1,10 @@ -""" -Implements a naive playback strategy wherein the ActionEvents are replayed -directly, without considering any screenshots. -""" +"""Implements a naive playback strategy, replaying ActionEvents without screenshots.""" -from pprint import pformat import time from loguru import logger -import mss.base - -from openadapt import config, events, utils, models, strategies +from openadapt import config, models, strategies, utils DISPLAY_EVENTS = False PROCESS_EVENTS = True @@ -19,15 +13,29 @@ class NaiveReplayStrategy(strategies.base.BaseReplayStrategy): + """Naive playback strategy that replays ActionEvents directly.""" def __init__( self, recording: models.Recording, - display_events=DISPLAY_EVENTS, - replay_events=REPLAY_EVENTS, - process_events=PROCESS_EVENTS, - sleep=SLEEP, - ): + display_events: bool = DISPLAY_EVENTS, + replay_events: bool = REPLAY_EVENTS, + process_events: bool = PROCESS_EVENTS, + sleep: bool = SLEEP, + ) -> None: + """Initialize the NaiveReplayStrategy. + + Args: + recording (models.Recording): The recording object. + display_events (bool): Flag indicating whether to display the events. + Defaults to False. + replay_events (bool): Flag indicating whether to replay the events. + Defaults to True. + process_events (bool): Flag indicating whether to process the events. + Defaults to True. + sleep (bool): Flag indicating whether to add sleep time between events. + Defaults to False. + """ super().__init__(recording) self.display_events = display_events self.replay_events = replay_events @@ -35,14 +43,24 @@ def __init__( self.sleep = sleep self.prev_timestamp = None self.action_event_idx = -1 - #event_dicts = utils.rows2dicts(self.processed_action_events) - #logger.info(f"event_dicts=\n{pformat(event_dicts)}") + # event_dicts = utils.rows2dicts(self.processed_action_events) + # logger.info(f"event_dicts=\n{pformat(event_dicts)}") def get_next_action_event( self, screenshot: models.Screenshot, window_event: models.WindowEvent, - ): + ) -> models.ActionEvent | None: + """Get the next ActionEvent for replay. + + Args: + screenshot (models.Screenshot): The screenshot object. + window_event (models.WindowEvent): The window event object. + + Returns: + models.ActionEvent or None: The next ActionEvent for replay or None + if there are no more events. + """ if self.process_events: action_events = self.recording.processed_action_events else: diff --git a/openadapt/strategies/stateful.py b/openadapt/strategies/stateful.py index 1f34e0c20..c9e3de7cf 100644 --- a/openadapt/strategies/stateful.py +++ b/openadapt/strategies/stateful.py @@ -1,5 +1,4 @@ -""" -LLM with window states. +"""LLM with window states. Usage: @@ -8,15 +7,15 @@ from copy import deepcopy from pprint import pformat -#import datetime from loguru import logger import deepdiff -import numpy as np -from openadapt import config, events, models, strategies, utils +from openadapt import models, strategies, utils from openadapt.strategies.mixins.openai import OpenAIReplayStrategyMixin +# import datetime + IGNORE_BOUNDARY_WINDOWS = True @@ -25,11 +24,17 @@ class StatefulReplayStrategy( OpenAIReplayStrategyMixin, strategies.base.BaseReplayStrategy, ): + """LLM with window states.""" def __init__( self, recording: models.Recording, - ): + ) -> None: + """Initialize the StatefulReplayStrategy. + + Args: + recording (models.Recording): The recording object. + """ super().__init__(recording) self.recording_window_state_diffs = get_window_state_diffs( recording.processed_action_events @@ -40,7 +45,7 @@ def __init__( ][:-1] self.recording_action_diff_tups = zip( self.recording_window_state_diffs, - self.recording_action_strs + self.recording_action_strs, ) self.recording_action_idx = 0 @@ -48,54 +53,73 @@ def get_next_action_event( self, active_screenshot: models.Screenshot, active_window: models.WindowEvent, - ): + ) -> models.ActionEvent: + """Get the next ActionEvent for replay. + + Args: + active_screenshot (models.Screenshot): The active screenshot object. + active_window (models.WindowEvent): The active window event object. + + Returns: + models.ActionEvent: The next ActionEvent for replay. + """ logger.debug(f"{self.recording_action_idx=}") if self.recording_action_idx == len(self.recording.processed_action_events): raise StopIteration() - reference_action = ( - self.recording.processed_action_events[self.recording_action_idx] - ) + reference_action = self.recording.processed_action_events[ + self.recording_action_idx + ] reference_window = reference_action.window_event - reference_window_dict = deepcopy({ - key: val - for key, val in utils.row2dict(reference_window, follow=False).items() - if val is not None - and not key.endswith("timestamp") - and not key.endswith("id") - #and not isinstance(getattr(models.WindowEvent, key), property) - }) + reference_window_dict = deepcopy( + { + key: val + for key, val in utils.row2dict(reference_window, follow=False).items() + if val is not None + and not key.endswith("timestamp") + and not key.endswith("id") + # and not isinstance(getattr(models.WindowEvent, key), property) + } + ) if reference_action.children: reference_action_dicts = [ - deepcopy({ - key: val - for key, val in utils.row2dict(child, follow=False).items() - if val is not None - and not key.endswith("timestamp") - and not key.endswith("id") - and not isinstance(getattr(models.ActionEvent, key), property) - }) + deepcopy( + { + key: val + for key, val in utils.row2dict(child, follow=False).items() + if val is not None + and not key.endswith("timestamp") + and not key.endswith("id") + and not isinstance(getattr(models.ActionEvent, key), property) + } + ) for child in reference_action.children ] else: reference_action_dicts = [ - deepcopy({ - key: val - for key, val in utils.row2dict(reference_action, follow=False).items() - if val is not None - and not key.endswith("timestamp") - and not key.endswith("id") - #and not isinstance(getattr(models.ActionEvent, key), property) - }) + deepcopy( + { + key: val + for key, val in utils.row2dict( + reference_action, follow=False + ).items() + if val is not None + and not key.endswith("timestamp") + and not key.endswith("id") + # and not isinstance(getattr(models.ActionEvent, key), property) + } + ) ] - active_window_dict = deepcopy({ - key: val - for key, val in utils.row2dict(active_window, follow=False).items() - if val is not None - and not key.endswith("timestamp") - and not key.endswith("id") - #and not isinstance(getattr(models.WindowEvent, key), property) - }) + active_window_dict = deepcopy( + { + key: val + for key, val in utils.row2dict(active_window, follow=False).items() + if val is not None + and not key.endswith("timestamp") + and not key.endswith("id") + # and not isinstance(getattr(models.WindowEvent, key), property) + } + ) reference_window_dict["state"].pop("data") active_window_dict["state"].pop("data") @@ -103,7 +127,9 @@ def get_next_action_event( f"{reference_window_dict=}\n" f"{reference_action_dicts=}\n" f"{active_window_dict=}\n" - "Provide valid Python3 code containing the action dicts by completing the following, and nothing else:\n" + "Provide valid Python3 code containing the action dicts" + " by completing the following," + " and nothing else:\n" "active_action_dicts=" ) system_message = ( @@ -127,7 +153,15 @@ def get_next_action_event( return active_action -def get_action_dict_from_completion(completion): +def get_action_dict_from_completion(completion: str) -> dict[models.ActionEvent]: + """Convert the completion to a dictionary containing action information. + + Args: + completion (str): The completion provided by the user. + + Returns: + dict: The action dictionary. + """ try: action = eval(completion) except Exception as exc: @@ -137,9 +171,19 @@ def get_action_dict_from_completion(completion): def get_window_state_diffs( - action_events, - ignore_boundary_windows=IGNORE_BOUNDARY_WINDOWS, -): + action_events: list[models.ActionEvent], + ignore_boundary_windows: bool = IGNORE_BOUNDARY_WINDOWS, +) -> list[deepdiff.DeepDiff]: + """Get the differences in window state between consecutive action events. + + Args: + action_events (list[models.ActionEvent]): The list of action events. + ignore_boundary_windows (bool): Flag to ignore boundary windows. + Defaults to True. + + Returns: + list[deepdiff.DeepDiff]: list of deep diffs for window state differences. + """ ignore_window_ids = set() if ignore_boundary_windows: first_window_event = action_events[0].window_event @@ -165,4 +209,4 @@ def get_window_state_diffs( window_event_states, window_event_states[1:] ) ] - return diffs \ No newline at end of file + return diffs diff --git a/openadapt/utils.py b/openadapt/utils.py index 3a1d16290..6ee7c9d98 100644 --- a/openadapt/utils.py +++ b/openadapt/utils.py @@ -1,8 +1,13 @@ -from datetime import datetime, timedelta +"""Utility functions for OpenAdapt. + +This module provides various utility functions used throughout OpenAdapt. +""" + +from collections import defaultdict from io import BytesIO -from collections import Counter, defaultdict +from logging import StreamHandler +from typing import Union import base64 -import fire import inspect import os import sys @@ -11,21 +16,30 @@ from loguru import logger from PIL import Image, ImageDraw, ImageFont +import fire import matplotlib.pyplot as plt import mss import mss.base import numpy as np -from logging import StreamHandler from openadapt import common, config +from openadapt.db import BaseModel +from openadapt.models import ActionEvent EMPTY = (None, [], {}, "") _logger_lock = threading.Lock() -def configure_logging(logger, log_level): - # TODO: redact log messages (https://github.com/Delgan/loguru/issues/17#issuecomment-717526130) +def configure_logging(logger: logger, log_level: str) -> None: + """Configure the logging settings for OpenAdapt. + + Args: + logger (logger): The logger object. + log_level (str): The desired log level. + """ + # TODO: redact log messages + # (https://github.com/Delgan/loguru/issues/17#issuecomment-717526130) log_level_override = os.getenv("LOG_LEVEL") log_level = log_level_override or log_level @@ -48,16 +62,19 @@ def configure_logging(logger, log_level): logger.debug(f"{log_level=}") -def row2dict(row, follow=True): +def row2dict(row: Union[dict, BaseModel], follow: bool = True) -> dict: + """Convert a row object to a dictionary. + + Args: + row: The row object. + follow (bool): Flag indicating whether to follow children. Defaults to True. + + Returns: + dict: The row object converted to a dictionary. + """ if isinstance(row, dict): return row - try_follow = ( - [ - "children", - ] - if follow - else [] - ) + try_follow = ["children"] if follow else [] to_follow = [key for key in try_follow if hasattr(row, key)] # follow children recursively @@ -76,7 +93,13 @@ def row2dict(row, follow=True): return row_dict -def round_timestamps(events, num_digits): +def round_timestamps(events: list[ActionEvent], num_digits: int) -> None: + """Round timestamps in a list of events. + + Args: + events (list): The list of events. + num_digits (int): The number of digits to round to. + """ for event in events: if isinstance(event, dict): continue @@ -88,11 +111,23 @@ def round_timestamps(events, num_digits): def rows2dicts( - rows, - drop_empty=True, - drop_constant=True, - num_digits=None, -): + rows: list[ActionEvent], + drop_empty: bool = True, + drop_constant: bool = True, + num_digits: int = None, +) -> list[dict]: + """Convert a list of rows to a list of dictionaries. + + Args: + rows (list): The list of rows. + drop_empty (bool): Flag indicating whether to drop empty rows. Defaults to True. + drop_constant (bool): Flag indicating whether to drop rows with constant values. + Defaults to True. + num_digits (int): The number of digits to round timestamps to. Defaults to None. + + Returns: + list: The list of dictionaries representing the rows. + """ if num_digits: round_timestamps(rows, num_digits) row_dicts = [row2dict(row) for row in rows] @@ -129,11 +164,23 @@ def rows2dicts( return row_dicts -def override_double_click_interval_seconds(override_value): +def override_double_click_interval_seconds( + override_value: float, +) -> None: + """Override the double click interval in seconds. + + Args: + override_value (float): The new value for the double click interval. + """ get_double_click_interval_seconds.override_value = override_value -def get_double_click_interval_seconds(): +def get_double_click_interval_seconds() -> float: + """Get the double click interval in seconds. + + Returns: + float: The double click interval in seconds. + """ if hasattr(get_double_click_interval_seconds, "override_value"): return get_double_click_interval_seconds.override_value if sys.platform == "darwin": @@ -149,9 +196,14 @@ def get_double_click_interval_seconds(): raise Exception(f"Unsupported {sys.platform=}") -def get_double_click_distance_pixels(): +def get_double_click_distance_pixels() -> int: + """Get the double click distance in pixels. + + Returns: + int: The double click distance in pixels. + """ if sys.platform == "darwin": - # From https://developer.apple.com/documentation/appkit/nspressgesturerecognizer/1527495-allowablemovement: + # From https://developer.apple.com/documentation/appkit/nspressgesturerecognizer/1527495-allowablemovement: # noqa: E501 # The default value of this property is the same as the # double-click distance. # TODO: do this more robustly; see: @@ -172,7 +224,12 @@ def get_double_click_distance_pixels(): raise Exception(f"Unsupported {sys.platform=}") -def get_monitor_dims(): +def get_monitor_dims() -> tuple: + """Get the dimensions of the monitor. + + Returns: + tuple: The width and height of the monitor. + """ sct = mss.mss() monitor = sct.monitors[0] monitor_width = monitor["width"] @@ -182,15 +239,34 @@ def get_monitor_dims(): # TODO: move parameters to config def draw_ellipse( - x, - y, - image, - width_pct=0.03, - height_pct=0.03, - fill_transparency=0.25, - outline_transparency=0.5, - outline_width=2, -): + x: float, + y: float, + image: Image.Image, + width_pct: float = 0.03, + height_pct: float = 0.03, + fill_transparency: float = 0.25, + outline_transparency: float = 0.5, + outline_width: int = 2, +) -> tuple[Image.Image, float, float]: + """Draw an ellipse on the image. + + Args: + x (float): The x-coordinate of the center of the ellipse. + y (float): The y-coordinate of the center of the ellipse. + image (Image.Image): The image to draw on. + width_pct (float): The percentage of the image width + for the width of the ellipse. + height_pct (float): The percentage of the image height + for the height of the ellipse. + fill_transparency (float): The transparency of the ellipse fill. + outline_transparency (float): The transparency of the ellipse outline. + outline_width (int): The width of the ellipse outline. + + Returns: + Image.Image: The image with the ellipse drawn on it. + float: The width of the ellipse. + float: The height of the ellipse. + """ overlay = Image.new("RGBA", image.size) draw = ImageDraw.Draw(overlay) max_dim = max(image.size) @@ -210,7 +286,16 @@ def draw_ellipse( return image, width, height -def get_font(original_font_name, font_size): +def get_font(original_font_name: str, font_size: int) -> ImageFont.FreeTypeFont: + """Get a font object. + + Args: + original_font_name (str): The original font name. + font_size (int): The font size. + + Returns: + PIL.ImageFont.FreeTypeFont: The font object. + """ font_names = [ original_font_name, original_font_name.lower(), @@ -225,18 +310,39 @@ def get_font(original_font_name, font_size): def draw_text( - x, - y, - text, - image, - font_size_pct=0.01, - font_name="Arial.ttf", - fill=(255, 0, 0), - stroke_fill=(255, 255, 255), - stroke_width=3, - outline=False, - outline_padding=10, -): + x: float, + y: float, + text: str, + image: Image.Image, + font_size_pct: float = 0.01, + font_name: str = "Arial.ttf", + fill: tuple = (255, 0, 0), + stroke_fill: tuple = (255, 255, 255), + stroke_width: int = 3, + outline: bool = False, + outline_padding: int = 10, +) -> Image.Image: + """Draw text on the image. + + Args: + x (float): The x-coordinate of the text anchor point. + y (float): The y-coordinate of the text anchor point. + text (str): The text to draw. + image (PIL.Image.Image): The image to draw on. + font_size_pct (float): The percentage of the image size + for the font size. Defaults to 0.01. + font_name (str): The name of the font. Defaults to "Arial.ttf". + fill (tuple): The color of the text. Defaults to (255, 0, 0) (red). + stroke_fill (tuple): The color of the text stroke. + Defaults to (255, 255, 255) (white). + stroke_width (int): The width of the text stroke. Defaults to 3. + outline (bool): Flag indicating whether to draw an outline + around the text. Defaults to False. + outline_padding (int): The padding size for the outline. Defaults to 10. + + Returns: + PIL.Image.Image: The image with the text drawn on it. + """ overlay = Image.new("RGBA", image.size) draw = ImageDraw.Draw(overlay) max_dim = max(image.size) @@ -271,23 +377,54 @@ def draw_text( def draw_rectangle( - x0, - y0, - x1, - y1, - image, - bg_color=(0, 0, 0), - fg_color=(255, 255, 255), - outline_color=(255, 0, 0), - bg_transparency=0.25, - fg_transparency=0, - outline_transparency=0.5, - outline_width=2, - invert=False, -): + x0: float, + y0: float, + x1: float, + y1: float, + image: Image.Image, + bg_color: tuple = (0, 0, 0), + fg_color: tuple = (255, 255, 255), + outline_color: tuple = (255, 0, 0), + bg_transparency: float = 0.25, + fg_transparency: float = 0, + outline_transparency: float = 0.5, + outline_width: int = 2, + invert: bool = False, +) -> Image.Image: + """Draw a rectangle on the image. + + Args: + x0 (float): The x-coordinate of the top-left corner of the rectangle. + y0 (float): The y-coordinate of the top-left corner of the rectangle. + x1 (float): The x-coordinate of the bottom-right corner of the rectangle. + y1 (float): The y-coordinate of the bottom-right corner of the rectangle. + image (PIL.Image.Image): The image to draw on. + bg_color (tuple): The background color of the rectangle. + Defaults to (0, 0, 0) (black). + fg_color (tuple): The foreground color of the rectangle. + Defaults to (255, 255, 255) (white). + outline_color (tuple): The color of the rectangle outline. + Defaults to (255, 0, 0) (red). + bg_transparency (float): The transparency of the rectangle + background. Defaults to 0.25. + fg_transparency (float): The transparency of the rectangle + foreground. Defaults to 0. + outline_transparency (float): The transparency of the rectangle + outline. Defaults to 0.5. + outline_width (int): The width of the rectangle outline. + Defaults to 2. + invert (bool): Flag indicating whether to invert the colors. + Defaults to False. + + Returns: + PIL.Image.Image: The image with the rectangle drawn on it. + """ if invert: bg_color, fg_color = fg_color, bg_color - bg_transparency, fg_transparency = fg_transparency, bg_transparency + bg_transparency, fg_transparency = ( + fg_transparency, + bg_transparency, + ) bg_opacity = int(255 * bg_transparency) overlay = Image.new("RGBA", image.size, bg_color + (bg_opacity,)) draw = ImageDraw.Draw(overlay) @@ -301,7 +438,16 @@ def draw_rectangle( return image -def get_scale_ratios(action_event): +def get_scale_ratios(action_event: ActionEvent) -> tuple[float, float]: + """Get the scale ratios for the action event. + + Args: + action_event (ActionEvent): The action event. + + Returns: + float: The width ratio. + float: The height ratio. + """ recording = action_event.recording image = action_event.screenshot.image width_ratio = image.width / recording.monitor_width @@ -310,13 +456,31 @@ def get_scale_ratios(action_event): def display_event( - action_event, - marker_width_pct=0.03, - marker_height_pct=0.03, - marker_fill_transparency=0.25, - marker_outline_transparency=0.5, - diff=False, -): + action_event: ActionEvent, + marker_width_pct: float = 0.03, + marker_height_pct: float = 0.03, + marker_fill_transparency: float = 0.25, + marker_outline_transparency: float = 0.5, + diff: bool = False, +) -> Image.Image: + """Display an action event on the image. + + Args: + action_event (ActionEvent): The action event to display. + marker_width_pct (float): The percentage of the image width + for the marker width. Defaults to 0.03. + marker_height_pct (float): The percentage of the image height + for the marker height. Defaults to 0.03. + marker_fill_transparency (float): The transparency of the + marker fill. Defaults to 0.25. + marker_outline_transparency (float): The transparency of the + marker outline. Defaults to 0.5. + diff (bool): Flag indicating whether to display the diff image. + Defaults to False. + + Returns: + PIL.Image.Image: The image with the action event displayed on it. + """ recording = action_event.recording window_event = action_event.window_event screenshot = action_event.screenshot @@ -376,7 +540,15 @@ def display_event( return image -def image2utf8(image): +def image2utf8(image: Image.Image) -> str: + """Convert an image to UTF-8 format. + + Args: + image (PIL.Image.Image): The image to convert. + + Returns: + str: The UTF-8 encoded image. + """ image = image.convert("RGB") buffered = BytesIO() image.save(buffered, format="JPEG") @@ -391,20 +563,46 @@ def image2utf8(image): _start_perf_counter = None -def set_start_time(value=None): +def set_start_time(value: float = None) -> float: + """Set the start time for performance measurements. + + Args: + value (float): The start time value. Defaults to the current time. + + Returns: + float: The start time. + """ global _start_time _start_time = value or time.time() logger.debug(f"{_start_time=}") return _start_time -def get_timestamp(is_global=False): +def get_timestamp(is_global: bool = False) -> float: + """Get the current timestamp. + + Args: + is_global (bool): Flag indicating whether to use the global + start time. Defaults to False. + + Returns: + float: The current timestamp. + """ global _start_time return _start_time + time.perf_counter() # https://stackoverflow.com/a/50685454 -def evenly_spaced(arr, N): +def evenly_spaced(arr: list, N: list) -> list: + """Get evenly spaced elements from the array. + + Args: + arr (list): The input array. + N (int): The number of elements to get. + + Returns: + list: The evenly spaced elements. + """ if N >= len(arr): return arr idxs = set(np.round(np.linspace(0, len(arr) - 1, N)).astype(int)) @@ -412,6 +610,11 @@ def evenly_spaced(arr, N): def take_screenshot() -> mss.base.ScreenShot: + """Take a screenshot. + + Returns: + mss.base.ScreenShot: The screenshot. + """ with mss.mss() as sct: # monitor 0 is all in one monitor = sct.monitors[0] @@ -419,7 +622,12 @@ def take_screenshot() -> mss.base.ScreenShot: return sct_img -def get_strategy_class_by_name(): +def get_strategy_class_by_name() -> dict: + """Get a dictionary of strategy classes by their names. + + Returns: + dict: A dictionary of strategy classes. + """ from openadapt.strategies import BaseReplayStrategy strategy_classes = BaseReplayStrategy.__subclasses__() @@ -429,14 +637,12 @@ def get_strategy_class_by_name(): def plot_performance(recording_timestamp: float = None) -> None: - """ - Plot the performance of the event processing and writing. + """Plot the performance of the event processing and writing. Args: - recording_timestamp: The timestamp of the recording (defaults to latest) - perf_q: A queue with performance data. + recording_timestamp (float): The timestamp of the recording + (defaults to latest). """ - type_to_proc_times = defaultdict(list) type_to_timestamps = defaultdict(list) event_types = set() @@ -451,9 +657,7 @@ def plot_performance(recording_timestamp: float = None) -> None: event_type = perf_stat.event_type start_time = perf_stat.start_time end_time = perf_stat.end_time - type_to_proc_times[event_type].append( - end_time - start_time - ) + type_to_proc_times[event_type].append(end_time - start_time) event_types.add(event_type) type_to_timestamps[event_type].append(start_time) @@ -474,7 +678,10 @@ def plot_performance(recording_timestamp: float = None) -> None: memory_ax = ax.twinx() memory_ax.plot( - timestamps, mem_usages, label="memory usage", color="red", + timestamps, + mem_usages, + label="memory usage", + color="red", ) memory_ax.set_ylabel("Memory Usage (bytes)") @@ -501,16 +708,23 @@ def plot_performance(recording_timestamp: float = None) -> None: os.system(f"open {fpath}") -def strip_element_state(action_event): +def strip_element_state(action_event: ActionEvent) -> ActionEvent: + """Strip the element state from the action event and its children. + + Args: + action_event (ActionEvent): The action event to strip. + + Returns: + ActionEvent: The action event without element state. + """ action_event.element_state = None for child in action_event.children: strip_element_state(child) return action_event -def get_functions(name) -> dict: - """ - Get a dictionary of function names to functions for all non-private functions +def get_functions(name: str) -> dict: + """Get a dictionary of function names to functions for all non-private functions. Usage: @@ -518,12 +732,11 @@ def get_functions(name) -> dict: fire.Fire(utils.get_functions(__name__)) Args: - name: The name of the module to get functions from. + name (str): The name of the module to get functions from. Returns: dict: A dictionary of function names to functions. """ - functions = {} for name, obj in inspect.getmembers(sys.modules[name]): if inspect.isfunction(obj) and not name.startswith("_"): diff --git a/openadapt/visualize.py b/openadapt/visualize.py index f91747643..0501c8101 100644 --- a/openadapt/visualize.py +++ b/openadapt/visualize.py @@ -1,33 +1,30 @@ +"""Implements visualization utilities for OpenAdapt.""" + from pprint import pformat from threading import Timer import html import os import string -from bokeh.io import output_file, show -from bokeh.layouts import layout, row +from bokeh.io import output_file +from bokeh.layouts import row from bokeh.models.widgets import Div from loguru import logger from tqdm import tqdm -from openadapt.crud import ( - get_latest_recording, -) -from openadapt.events import ( - get_events, -) +from openadapt import config +from openadapt.crud import get_latest_recording +from openadapt.events import get_events from openadapt.utils import ( + EMPTY, configure_logging, display_event, evenly_spaced, image2utf8, - EMPTY, row2dict, rows2dicts, ) -from openadapt import config - SCRUB = config.SCRUB_ENABLED if SCRUB: from openadapt import scrub @@ -79,7 +76,16 @@ ) -def recursive_len(lst, key): +def recursive_len(lst: list, key: str) -> int: + """Calculate the recursive length of a list based on a key. + + Args: + lst (list): The list to calculate the length of. + key: The key to access the sublists. + + Returns: + int: The recursive length of the list. + """ try: _len = len(lst) except TypeError: @@ -92,14 +98,33 @@ def recursive_len(lst, key): return _len -def format_key(key, value): +def format_key(key: str, value: list) -> str: + """Format a key and value for display. + + Args: + key: The key to format. + value: The value associated with the key. + + Returns: + str: The formatted key and value. + """ if isinstance(value, list): return f"{key} ({len(value)}; {recursive_len(value, key)})" else: return key -def indicate_missing(some, every, indicator): +def indicate_missing(some: list, every: list, indicator: str) -> list: + """Indicate missing elements in a list. + + Args: + some (list): The list with potentially missing elements. + every (list): The reference list with all elements. + indicator (str): The indicator to use for missing elements. + + Returns: + list: The list with indicators for missing elements. + """ rval = [] some_idx = 0 every_idx = 0 @@ -116,7 +141,21 @@ def indicate_missing(some, every, indicator): return rval -def dict2html(obj, max_children=MAX_TABLE_CHILDREN, max_len=MAX_TABLE_STR_LEN): +def dict2html( + obj: dict, + max_children: int = MAX_TABLE_CHILDREN, + max_len: int = MAX_TABLE_STR_LEN, +) -> str: + """Convert a dictionary to an HTML representation. + + Args: + obj (dict): The dictionary to convert. + max_children (int): The maximum number of child elements to display in a table. + max_len (int): The maximum length of a string value in the HTML representation. + + Returns: + str: The HTML representation of the dictionary. + """ if isinstance(obj, list): children = [dict2html(value, max_children) for value in obj] if max_children is not None and len(children) > max_children: @@ -149,8 +188,10 @@ def dict2html(obj, max_children=MAX_TABLE_CHILDREN, max_len=MAX_TABLE_STR_LEN): html_str = head + middle + tail return html_str + @logger.catch -def main(): +def main() -> None: + """Main function to generate an HTML report for a recording.""" configure_logging(logger, LOG_LEVEL) recording = get_latest_recording() @@ -196,12 +237,12 @@ def main(): else len(action_events) ) with tqdm( - total=num_events, - desc="Preparing HTML", - unit="event", - colour="green", - dynamic_ncols=True, - ) as progress: + total=num_events, + desc="Preparing HTML", + unit="event", + colour="green", + dynamic_ncols=True, + ) as progress: for idx, action_event in enumerate(action_events): if idx == MAX_EVENTS: break @@ -276,13 +317,9 @@ def main(): logger.info(f"{fname_out=}") output_file(fname_out, title=title) - result = show( - layout( - rows, - ) - ) + # result = show(layout(rows,)) - def cleanup(): + def cleanup() -> None: os.remove(fname_out) removed = not os.path.exists(fname_out) logger.info(f"{removed=}") diff --git a/openadapt/window/__init__.py b/openadapt/window/__init__.py index f722d02f7..575630ee3 100644 --- a/openadapt/window/__init__.py +++ b/openadapt/window/__init__.py @@ -1,18 +1,29 @@ +"""Package for interacting with active window and elements across platforms. + +Module: __init__.py +""" + +from typing import Any import sys from loguru import logger - if sys.platform == "darwin": from . import _macos as impl elif sys.platform in ("win32", "linux"): - # TOOD: implement linux + # TODO: implement Linux from . import _windows as impl else: - raise Exception(f"Unsupposed {sys.platform=}") + raise Exception(f"Unsupported platform: {sys.platform}") + +def get_active_window_data() -> dict[str, Any] | None: + """Get data of the active window. -def get_active_window_data(): + Returns: + dict or None: A dictionary containing information about the active window, + or None if the state is not available. + """ state = get_active_window_state() if not state: return None @@ -34,9 +45,14 @@ def get_active_window_data(): return window_data -def get_active_window_state(): +def get_active_window_state() -> dict | None: + """Get the state of the active window. + + Returns: + dict or None: A dictionary containing the state of the active window, + or None if the state is not available. + """ # TODO: save window identifier (a window's title can change, or - # multiple windows can have the same title) try: return impl.get_active_window_state() except Exception as exc: @@ -44,7 +60,17 @@ def get_active_window_state(): return None -def get_active_element_state(x, y): +def get_active_element_state(x: int, y: int) -> dict | None: + """Get the state of the active element at the specified coordinates. + + Args: + x (int): The x-coordinate of the element. + y (int): The y-coordinate of the element. + + Returns: + dict or None: A dictionary containing the state of the active element, + or None if the state is not available. + """ try: return impl.get_active_element_state(x, y) except Exception as exc: diff --git a/openadapt/window/_macos.py b/openadapt/window/_macos.py index 57048ba0b..ecd7087e4 100644 --- a/openadapt/window/_macos.py +++ b/openadapt/window/_macos.py @@ -1,18 +1,25 @@ from pprint import pprint +from typing import Any, Literal, Union import pickle +import plistlib +import re from loguru import logger -import atomacos import AppKit import ApplicationServices -import Quartz +import atomacos import Foundation -import re -import plistlib +import Quartz + +def get_active_window_state() -> dict | None: + """Get the state of the active window. -def get_active_window_state(): - # pywinctl performance on mac is unusable, see: + Returns: + dict or None: A dictionary containing the state of the active window, + or None if the state is not available. + """ + # pywinctl performance on macOS is unusable, see: # https://github.com/Kalmat/PyWinCtl/issues/29 meta = get_active_window_meta() data = get_window_data(meta) @@ -47,7 +54,12 @@ def get_active_window_state(): return rval -def get_active_window_meta(): +def get_active_window_meta() -> dict: + """Get the metadata of the active window. + + Returns: + dict: A dictionary containing the metadata of the active window. + """ windows = Quartz.CGWindowListCopyWindowInfo( ( Quartz.kCGWindowListExcludeDesktopElements @@ -55,12 +67,25 @@ def get_active_window_meta(): ), Quartz.kCGNullWindowID, ) - active_windows_info = [win for win in windows if win["kCGWindowLayer"] == 0 and win["kCGWindowOwnerName"] != "Window Server"] + active_windows_info = [ + win + for win in windows + if win["kCGWindowLayer"] == 0 and win["kCGWindowOwnerName"] != "Window Server" + ] active_window_info = active_windows_info[0] return active_window_info -def get_active_window(window_meta): +def get_active_window(window_meta: dict) -> ApplicationServices.AXUIElement | None: + """Get the active window from the given metadata. + + Args: + window_meta (dict): The metadata of the window. + + Returns: + AXUIElement or None: The active window as an AXUIElement object, + or None if the active window cannot be retrieved. + """ pid = window_meta["kCGWindowOwnerPID"] app_ref = ApplicationServices.AXUIElementCreateApplication(pid) error_code, window = ApplicationServices.AXUIElementCopyAttributeValue( @@ -72,13 +97,33 @@ def get_active_window(window_meta): return window -def get_window_data(window_meta): +def get_window_data(window_meta: dict) -> dict: + """Get the data of the window. + + Args: + window_meta (dict): The metadata of the window. + + Returns: + dict: A dictionary containing the data of the window. + """ window = get_active_window(window_meta) state = dump_state(window) return state -def dump_state(element, elements=None): +def dump_state( + element: Union[AppKit.NSArray, list, AppKit.NSDictionary, dict, Any], + elements: set = None, +) -> Union[dict, list]: + """Dump the state of the given element and its descendants. + + Args: + element: The element to dump the state for. + elements (set): Set to track elements to prevent circular traversal. + + Returns: + dict or list: State of element and descendants as dict or list + """ elements = elements or set() if element in elements: return @@ -111,7 +156,7 @@ def dump_state(element, elements=None): # for WindowEvents: if "parent" in attr_name.lower(): continue - # for ActionEvents: + # For ActionEvents: if attr_name in ("AXTopLevelUIElement", "AXWindow"): continue @@ -139,8 +184,15 @@ def dump_state(element, elements=None): # https://github.com/autopkg/autopkg/commit/1aff762d8ea658b3fca8ac693f3bf13e8baf8778 -def deepconvert_objc(object): - """Convert all contents of an ObjC object to Python primitives.""" +def deepconvert_objc(object: Any) -> Any | list | dict | Literal[0]: + """Convert all contents of an ObjC object to Python primitives. + + Args: + object: The object to convert. + + Returns: + object: The converted object with Python primitives. + """ value = object strings = ( str, @@ -162,7 +214,8 @@ def deepconvert_objc(object): # handle core-foundation class AXValueRef elif isinstance(object, ApplicationServices.AXValueRef): # convert to dict - note: this object is not iterable - # TODO: access directly, e.g. via ApplicationServices.AXUIElementCopyAttributeValue + # TODO: access directly, e.g. via + # ApplicationServices.AXUIElementCopyAttributeValue rep = repr(object) x_value = re.search(r"x:([\d.]+)", rep) y_value = re.search(r"y:([\d.]+)", rep) @@ -192,7 +245,9 @@ def deepconvert_objc(object): logger.warning( f"Unknown type: {type(object)} - " f"Please report this on GitHub: " - f"https://github.com/MLDSAI/OpenAdapt/issues/new?assignees=&labels=bug&projects=&template=bug_form.yml&title=%5BBug%5D%3A+" + f"github.com/MLDSAI/OpenAdapt/issues/new?" + f"assignees=&labels=bug&projects=&template=bug_form.yml&" + f"title=%5BBug%5D%3A+" ) logger.warning(f"{object=}") if value: @@ -200,7 +255,16 @@ def deepconvert_objc(object): return value -def get_active_element_state(x, y): +def get_active_element_state(x: int, y: int) -> dict: + """Get the state of the active element at the specified coordinates. + + Args: + x (int): The x-coordinate of the element. + y (int): The y-coordinate of the element. + + Returns: + dict: A dictionary containing the state of the active element. + """ window_meta = get_active_window_meta() pid = window_meta["kCGWindowOwnerPID"] app = atomacos._a11y.AXUIElement.from_pid(pid) @@ -215,14 +279,26 @@ def get_active_element_state(x, y): return state -def main(): +def main() -> None: + """Main function for testing the functionality. + + This function sleeps for 1 second, gets the state of the active window, + pretty-prints the state, and pickles the state. It also sets up the ipdb + debugger for further debugging. + + Returns: + None + """ import time + time.sleep(1) state = get_active_window_state() pprint(state) pickle.dumps(state, protocol=pickle.HIGHEST_PROTOCOL) - import ipdb; ipdb.set_trace() + import ipdb + + ipdb.set_trace() # noqa: E702 if __name__ == "__main__": diff --git a/openadapt/window/_windows.py b/openadapt/window/_windows.py index 40b74250b..4a07dc4f2 100644 --- a/openadapt/window/_windows.py +++ b/openadapt/window/_windows.py @@ -1,12 +1,13 @@ -from loguru import logger -import pywinauto from pprint import pprint import pickle +import time + +from loguru import logger +import pywinauto def get_active_window_state() -> dict: - """ - Get the state of the active window. + """Get the state of the active window. Returns: dict: A dictionary containing the state of the active window. @@ -47,9 +48,10 @@ def get_active_window_state() -> dict: return state -def get_active_window_meta(active_window) -> dict: - """ - Get the meta information of the active window. +def get_active_window_meta( + active_window: pywinauto.application.WindowSpecification, +) -> dict: + """Get the meta information of the active window. Args: active_window: The active window object. @@ -65,9 +67,8 @@ def get_active_window_meta(active_window) -> dict: return result -def get_active_element_state(x: int, y: int): - """ - Get the state of the active element at the given coordinates. +def get_active_element_state(x: int, y: int) -> dict: + """Get the state of the active element at the given coordinates. Args: x (int): The x-coordinate. @@ -83,28 +84,27 @@ def get_active_element_state(x: int, y: int): return properties -def get_active_window(): - """ - Get the active window object. +def get_active_window() -> pywinauto.application.WindowSpecification: + """Get the active window object. Returns: - Desktop: The active window object. + pywinauto.application.WindowSpecification: The active window object. """ app = pywinauto.application.Application(backend="uia").connect(active_only=True) window = app.top_window() return window.wrapper_object() -def get_element_properties(element): - """ - Recursively retrieves the properties of each element and its children. +def get_element_properties(element: pywinauto.application.WindowSpecification) -> dict: + """Recursively retrieves the properties of each element and its children. Args: element: An instance of a custom element class that has the `.get_properties()` and `.children()` methods. Returns: - A nested dictionary containing the properties of each element and its children. + dict: A nested dictionary containing the properties of each element + and its children. The dictionary includes a "children" key for each element, which holds the properties of its children. @@ -128,7 +128,15 @@ def get_element_properties(element): return properties -def dictify_rect(rect): +def dictify_rect(rect: pywinauto.win32structures.RECT) -> dict: + """Convert a rectangle object to a dictionary. + + Args: + rect: The rectangle object. + + Returns: + dict: A dictionary representation of the rectangle. + """ rect_dict = { "left": rect.left, "top": rect.top, @@ -138,9 +146,8 @@ def dictify_rect(rect): return rect_dict -def get_properties(element): - """ - Retrieves specific writable properties of an element. +def get_properties(element: pywinauto.application.WindowSpecification) -> dict: + """Retrieves specific writable properties of an element. This function retrieves a dictionary of writable properties for a given element. It achieves this by temporarily modifying the class of the element object using @@ -169,14 +176,11 @@ class TempElement(element.__class__): return properties -def main(): - """ - Test function for retrieving and inspecting the state of the active window. +def main() -> None: + """Test function for retrieving and inspecting the state of the active window. This function is primarily used for testing and debugging purposes. """ - import time - time.sleep(1) state = get_active_window_state() @@ -184,7 +188,7 @@ def main(): pickle.dumps(state) import ipdb - ipdb.set_trace() + ipdb.set_trace() # noqa: E702 if __name__ == "__main__": diff --git a/permissions_in_macOS.md b/permissions_in_macOS.md index ebbfad367..feca90d3e 100644 --- a/permissions_in_macOS.md +++ b/permissions_in_macOS.md @@ -20,4 +20,4 @@ Screen recoding must be enabled in Settings in order to allow capture of screens Mouse and keyboard control must be enabled in Settings in order to allow replay of actions by the framework. You can do so by enabling the Terminal application under Settings → Privay and security → Accessibility → Allow Terminal -![Enabling replay of actions](./assets/macOS_accessibility.png) \ No newline at end of file +![Enabling replay of actions](./assets/macOS_accessibility.png) diff --git a/poetry.lock b/poetry.lock index 4a0ccd962..010f797ad 100644 --- a/poetry.lock +++ b/poetry.lock @@ -164,13 +164,13 @@ files = [ [[package]] name = "anyio" -version = "3.7.0" +version = "3.7.1" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.7" files = [ - {file = "anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0"}, - {file = "anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce"}, + {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, + {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, ] [package.dependencies] @@ -179,7 +179,7 @@ idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["Sphinx (>=6.1.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme", "sphinxcontrib-jquery"] +doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (<0.22)"] @@ -892,13 +892,13 @@ files = [ [[package]] name = "deepdiff" -version = "6.3.0" +version = "6.3.1" description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." optional = false python-versions = ">=3.7" files = [ - {file = "deepdiff-6.3.0-py3-none-any.whl", hash = "sha256:15838bd1cbd046ce15ed0c41e837cd04aff6b3e169c5e06fca69d7aa11615ceb"}, - {file = "deepdiff-6.3.0.tar.gz", hash = "sha256:6a3bf1e7228ac5c71ca2ec43505ca0a743ff54ec77aa08d7db22de6bc7b2b644"}, + {file = "deepdiff-6.3.1-py3-none-any.whl", hash = "sha256:eae2825b2e1ea83df5fc32683d9aec5a56e38b756eb2b280e00863ce4def9d33"}, + {file = "deepdiff-6.3.1.tar.gz", hash = "sha256:e8c1bb409a2caf1d757799add53b3a490f707dd792ada0eca7cac1328055097a"}, ] [package.dependencies] @@ -998,13 +998,13 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.1" +version = "1.1.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, - {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, + {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, + {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, ] [package.extras] @@ -1089,6 +1089,52 @@ files = [ six = "*" termcolor = "*" +[[package]] +name = "flake8" +version = "6.0.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, + {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.10.0,<2.11.0" +pyflakes = ">=3.0.0,<3.1.0" + +[[package]] +name = "flake8-annotations" +version = "3.0.1" +description = "Flake8 Type Annotation Checks" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8_annotations-3.0.1-py3-none-any.whl", hash = "sha256:af78e3216ad800d7e144745ece6df706c81b3255290cbf870e54879d495e8ade"}, + {file = "flake8_annotations-3.0.1.tar.gz", hash = "sha256:ff37375e71e3b83f2a5a04d443c41e2c407de557a884f3300a7fa32f3c41cb0a"}, +] + +[package.dependencies] +attrs = ">=21.4" +flake8 = ">=5.0" + +[[package]] +name = "flake8-docstrings" +version = "1.7.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +optional = false +python-versions = ">=3.7" +files = [ + {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, + {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, +] + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + [[package]] name = "flatbuffers" version = "23.5.26" @@ -1438,13 +1484,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "huggingface-hub" -version = "0.15.1" +version = "0.16.2" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.7.0" files = [ - {file = "huggingface_hub-0.15.1-py3-none-any.whl", hash = "sha256:05b0fb0abbf1f625dfee864648ac3049fe225ac4371c7bafaca0c2d3a2f83445"}, - {file = "huggingface_hub-0.15.1.tar.gz", hash = "sha256:a61b7d1a7769fe10119e730277c72ab99d95c48d86a3d6da3e9f3d0f632a4081"}, + {file = "huggingface_hub-0.16.2-py3-none-any.whl", hash = "sha256:92facff575c11a8cf4b35d184ae67867a577a1b30865edcd8a9c5a48d2202133"}, + {file = "huggingface_hub-0.16.2.tar.gz", hash = "sha256:205abbf02a3408129a309f09e6d1a88d0c82de296b498682a813d9baa91c272f"}, ] [package.dependencies] @@ -1457,15 +1503,16 @@ tqdm = ">=4.42.1" typing-extensions = ">=3.7.4.3" [package.extras] -all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "black (>=23.1,<24.0)", "gradio", "jedi", "mypy (==0.982)", "numpy", "pytest", "pytest-cov", "pytest-env", "pytest-vcr", "pytest-xdist", "ruff (>=0.0.241)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "urllib3 (<2.0)"] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "black (>=23.1,<24.0)", "gradio", "jedi", "mypy (==0.982)", "numpy", "pydantic", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-vcr", "pytest-xdist", "ruff (>=0.0.241)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "urllib3 (<2.0)"] cli = ["InquirerPy (==0.3.4)"] -dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "black (>=23.1,<24.0)", "gradio", "jedi", "mypy (==0.982)", "numpy", "pytest", "pytest-cov", "pytest-env", "pytest-vcr", "pytest-xdist", "ruff (>=0.0.241)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "urllib3 (<2.0)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "black (>=23.1,<24.0)", "gradio", "jedi", "mypy (==0.982)", "numpy", "pydantic", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-vcr", "pytest-xdist", "ruff (>=0.0.241)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "urllib3 (<2.0)"] fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +inference = ["aiohttp", "pydantic"] quality = ["black (>=23.1,<24.0)", "mypy (==0.982)", "ruff (>=0.0.241)"] tensorflow = ["graphviz", "pydot", "tensorflow"] -testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "gradio", "jedi", "numpy", "pytest", "pytest-cov", "pytest-env", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "gradio", "jedi", "numpy", "pydantic", "pytest", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] torch = ["torch"] -typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3"] +typing = ["pydantic", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3"] [[package]] name = "humanfriendly" @@ -1981,95 +2028,99 @@ dev = ["Sphinx (>=4.1.1)", "black (>=19.10b0)", "colorama (>=0.3.4)", "docutils [[package]] name = "lxml" -version = "4.9.2" +version = "4.9.3" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" files = [ - {file = "lxml-4.9.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2"}, - {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892"}, - {file = "lxml-4.9.2-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a"}, - {file = "lxml-4.9.2-cp27-cp27m-win32.whl", hash = "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de"}, - {file = "lxml-4.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3"}, - {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50"}, - {file = "lxml-4.9.2-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975"}, - {file = "lxml-4.9.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c"}, - {file = "lxml-4.9.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a"}, - {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4"}, - {file = "lxml-4.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4"}, - {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7"}, - {file = "lxml-4.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184"}, - {file = "lxml-4.9.2-cp310-cp310-win32.whl", hash = "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda"}, - {file = "lxml-4.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab"}, - {file = "lxml-4.9.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9"}, - {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf"}, - {file = "lxml-4.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380"}, - {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92"}, - {file = "lxml-4.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1"}, - {file = "lxml-4.9.2-cp311-cp311-win32.whl", hash = "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33"}, - {file = "lxml-4.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd"}, - {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0"}, - {file = "lxml-4.9.2-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e"}, - {file = "lxml-4.9.2-cp35-cp35m-win32.whl", hash = "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df"}, - {file = "lxml-4.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5"}, - {file = "lxml-4.9.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1"}, - {file = "lxml-4.9.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e"}, - {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74"}, - {file = "lxml-4.9.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38"}, - {file = "lxml-4.9.2-cp36-cp36m-win32.whl", hash = "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5"}, - {file = "lxml-4.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3"}, - {file = "lxml-4.9.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894"}, - {file = "lxml-4.9.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45"}, - {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e"}, - {file = "lxml-4.9.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b"}, - {file = "lxml-4.9.2-cp37-cp37m-win32.whl", hash = "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe"}, - {file = "lxml-4.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9"}, - {file = "lxml-4.9.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03"}, - {file = "lxml-4.9.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c"}, - {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f"}, - {file = "lxml-4.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457"}, - {file = "lxml-4.9.2-cp38-cp38-win32.whl", hash = "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b"}, - {file = "lxml-4.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7"}, - {file = "lxml-4.9.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947"}, - {file = "lxml-4.9.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5"}, - {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5"}, - {file = "lxml-4.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2"}, - {file = "lxml-4.9.2-cp39-cp39-win32.whl", hash = "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1"}, - {file = "lxml-4.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f"}, - {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c"}, - {file = "lxml-4.9.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f"}, - {file = "lxml-4.9.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7"}, - {file = "lxml-4.9.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409"}, - {file = "lxml-4.9.2.tar.gz", hash = "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67"}, + {file = "lxml-4.9.3-cp27-cp27m-macosx_11_0_x86_64.whl", hash = "sha256:b0a545b46b526d418eb91754565ba5b63b1c0b12f9bd2f808c852d9b4b2f9b5c"}, + {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:075b731ddd9e7f68ad24c635374211376aa05a281673ede86cbe1d1b3455279d"}, + {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1e224d5755dba2f4a9498e150c43792392ac9b5380aa1b845f98a1618c94eeef"}, + {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0781a98ff5e6586926293e59480b64ddd46282953203c76ae15dbbbf302e8bb"}, + {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cef2502e7e8a96fe5ad686d60b49e1ab03e438bd9123987994528febd569868e"}, + {file = "lxml-4.9.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b86164d2cff4d3aaa1f04a14685cbc072efd0b4f99ca5708b2ad1b9b5988a991"}, + {file = "lxml-4.9.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:42871176e7896d5d45138f6d28751053c711ed4d48d8e30b498da155af39aebd"}, + {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:411007c0d88188d9f621b11d252cce90c4a2d1a49db6c068e3c16422f306eab8"}, + {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cd47b4a0d41d2afa3e58e5bf1f62069255aa2fd6ff5ee41604418ca925911d76"}, + {file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e2cb47860da1f7e9a5256254b74ae331687b9672dfa780eed355c4c9c3dbd23"}, + {file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1247694b26342a7bf47c02e513d32225ededd18045264d40758abeb3c838a51f"}, + {file = "lxml-4.9.3-cp310-cp310-win32.whl", hash = "sha256:cdb650fc86227eba20de1a29d4b2c1bfe139dc75a0669270033cb2ea3d391b85"}, + {file = "lxml-4.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:97047f0d25cd4bcae81f9ec9dc290ca3e15927c192df17331b53bebe0e3ff96d"}, + {file = "lxml-4.9.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1f447ea5429b54f9582d4b955f5f1985f278ce5cf169f72eea8afd9502973dd5"}, + {file = "lxml-4.9.3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:57d6ba0ca2b0c462f339640d22882acc711de224d769edf29962b09f77129cbf"}, + {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:71c52db65e4b56b8ddc5bb89fb2e66c558ed9d1a74a45ceb7dcb20c191c3df2f"}, + {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d73d8ecf8ecf10a3bd007f2192725a34bd62898e8da27eb9d32a58084f93962b"}, + {file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0a3d3487f07c1d7f150894c238299934a2a074ef590b583103a45002035be120"}, + {file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e28c51fa0ce5674be9f560c6761c1b441631901993f76700b1b30ca6c8378d6"}, + {file = "lxml-4.9.3-cp311-cp311-win32.whl", hash = "sha256:0bfd0767c5c1de2551a120673b72e5d4b628737cb05414f03c3277bf9bed3305"}, + {file = "lxml-4.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:25f32acefac14ef7bd53e4218fe93b804ef6f6b92ffdb4322bb6d49d94cad2bc"}, + {file = "lxml-4.9.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d3ff32724f98fbbbfa9f49d82852b159e9784d6094983d9a8b7f2ddaebb063d4"}, + {file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48d6ed886b343d11493129e019da91d4039826794a3e3027321c56d9e71505be"}, + {file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9a92d3faef50658dd2c5470af249985782bf754c4e18e15afb67d3ab06233f13"}, + {file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b4e4bc18382088514ebde9328da057775055940a1f2e18f6ad2d78aa0f3ec5b9"}, + {file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fc9b106a1bf918db68619fdcd6d5ad4f972fdd19c01d19bdb6bf63f3589a9ec5"}, + {file = "lxml-4.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:d37017287a7adb6ab77e1c5bee9bcf9660f90ff445042b790402a654d2ad81d8"}, + {file = "lxml-4.9.3-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56dc1f1ebccc656d1b3ed288f11e27172a01503fc016bcabdcbc0978b19352b7"}, + {file = "lxml-4.9.3-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:578695735c5a3f51569810dfebd05dd6f888147a34f0f98d4bb27e92b76e05c2"}, + {file = "lxml-4.9.3-cp35-cp35m-win32.whl", hash = "sha256:704f61ba8c1283c71b16135caf697557f5ecf3e74d9e453233e4771d68a1f42d"}, + {file = "lxml-4.9.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c41bfca0bd3532d53d16fd34d20806d5c2b1ace22a2f2e4c0008570bf2c58833"}, + {file = "lxml-4.9.3-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:64f479d719dc9f4c813ad9bb6b28f8390360660b73b2e4beb4cb0ae7104f1c12"}, + {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:dd708cf4ee4408cf46a48b108fb9427bfa00b9b85812a9262b5c668af2533ea5"}, + {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c31c7462abdf8f2ac0577d9f05279727e698f97ecbb02f17939ea99ae8daa98"}, + {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:4930be26af26ac545c3dffb662521d4e6268352866956672231887d18f0eaab2"}, + {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4aec80cde9197340bc353d2768e2a75f5f60bacda2bab72ab1dc499589b3878c"}, + {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14e019fd83b831b2e61baed40cab76222139926b1fb5ed0e79225bc0cae14584"}, + {file = "lxml-4.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0c0850c8b02c298d3c7006b23e98249515ac57430e16a166873fc47a5d549287"}, + {file = "lxml-4.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aca086dc5f9ef98c512bac8efea4483eb84abbf926eaeedf7b91479feb092458"}, + {file = "lxml-4.9.3-cp36-cp36m-win32.whl", hash = "sha256:50baa9c1c47efcaef189f31e3d00d697c6d4afda5c3cde0302d063492ff9b477"}, + {file = "lxml-4.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bef4e656f7d98aaa3486d2627e7d2df1157d7e88e7efd43a65aa5dd4714916cf"}, + {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:46f409a2d60f634fe550f7133ed30ad5321ae2e6630f13657fb9479506b00601"}, + {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4c28a9144688aef80d6ea666c809b4b0e50010a2aca784c97f5e6bf143d9f129"}, + {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:53ace1c1fd5a74ef662f844a0413446c0629d151055340e9893da958a374f70d"}, + {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17a753023436a18e27dd7769e798ce302963c236bc4114ceee5b25c18c52c693"}, + {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7d298a1bd60c067ea75d9f684f5f3992c9d6766fadbc0bcedd39750bf344c2f4"}, + {file = "lxml-4.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:081d32421db5df44c41b7f08a334a090a545c54ba977e47fd7cc2deece78809a"}, + {file = "lxml-4.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:23eed6d7b1a3336ad92d8e39d4bfe09073c31bfe502f20ca5116b2a334f8ec02"}, + {file = "lxml-4.9.3-cp37-cp37m-win32.whl", hash = "sha256:1509dd12b773c02acd154582088820893109f6ca27ef7291b003d0e81666109f"}, + {file = "lxml-4.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:120fa9349a24c7043854c53cae8cec227e1f79195a7493e09e0c12e29f918e52"}, + {file = "lxml-4.9.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4d2d1edbca80b510443f51afd8496be95529db04a509bc8faee49c7b0fb6d2cc"}, + {file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d7e43bd40f65f7d97ad8ef5c9b1778943d02f04febef12def25f7583d19baac"}, + {file = "lxml-4.9.3-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:6fc3c450eaa0b56f815c7b62f2b7fba7266c4779adcf1cece9e6deb1de7305ce"}, + {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65299ea57d82fb91c7f019300d24050c4ddeb7c5a190e076b5f48a2b43d19c42"}, + {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eadfbbbfb41b44034a4c757fd5d70baccd43296fb894dba0295606a7cf3124aa"}, + {file = "lxml-4.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3e9bdd30efde2b9ccfa9cb5768ba04fe71b018a25ea093379c857c9dad262c40"}, + {file = "lxml-4.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fcdd00edfd0a3001e0181eab3e63bd5c74ad3e67152c84f93f13769a40e073a7"}, + {file = "lxml-4.9.3-cp38-cp38-win32.whl", hash = "sha256:57aba1bbdf450b726d58b2aea5fe47c7875f5afb2c4a23784ed78f19a0462574"}, + {file = "lxml-4.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:92af161ecbdb2883c4593d5ed4815ea71b31fafd7fd05789b23100d081ecac96"}, + {file = "lxml-4.9.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:9bb6ad405121241e99a86efff22d3ef469024ce22875a7ae045896ad23ba2340"}, + {file = "lxml-4.9.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8ed74706b26ad100433da4b9d807eae371efaa266ffc3e9191ea436087a9d6a7"}, + {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:303bf1edce6ced16bf67a18a1cf8339d0db79577eec5d9a6d4a80f0fb10aa2da"}, + {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:5515edd2a6d1a5a70bfcdee23b42ec33425e405c5b351478ab7dc9347228f96e"}, + {file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:690dafd0b187ed38583a648076865d8c229661ed20e48f2335d68e2cf7dc829d"}, + {file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6420a005548ad52154c8ceab4a1290ff78d757f9e5cbc68f8c77089acd3c432"}, + {file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bb3bb49c7a6ad9d981d734ef7c7193bc349ac338776a0360cc671eaee89bcf69"}, + {file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d27be7405547d1f958b60837dc4c1007da90b8b23f54ba1f8b728c78fdb19d50"}, + {file = "lxml-4.9.3-cp39-cp39-win32.whl", hash = "sha256:8df133a2ea5e74eef5e8fc6f19b9e085f758768a16e9877a60aec455ed2609b2"}, + {file = "lxml-4.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:4dd9a263e845a72eacb60d12401e37c616438ea2e5442885f65082c276dfb2b2"}, + {file = "lxml-4.9.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6689a3d7fd13dc687e9102a27e98ef33730ac4fe37795d5036d18b4d527abd35"}, + {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f6bdac493b949141b733c5345b6ba8f87a226029cbabc7e9e121a413e49441e0"}, + {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2006f5c8d28dee289f7020f721354362fa304acbaaf9745751ac4006650254b"}, + {file = "lxml-4.9.3-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:5c245b783db29c4e4fbbbfc9c5a78be496c9fea25517f90606aa1f6b2b3d5f7b"}, + {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4fb960a632a49f2f089d522f70496640fdf1218f1243889da3822e0a9f5f3ba7"}, + {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9719fe17307a9e814580af1f5c6e05ca593b12fb7e44fe62450a5384dbf61b4b"}, + {file = "lxml-4.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3331bece23c9ee066e0fb3f96c61322b9e0f54d775fccefff4c38ca488de283a"}, + {file = "lxml-4.9.3-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:ed667f49b11360951e201453fc3967344d0d0263aa415e1619e85ae7fd17b4e0"}, + {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8b77946fd508cbf0fccd8e400a7f71d4ac0e1595812e66025bac475a8e811694"}, + {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fe4bda6bd4340caa6e5cf95e73f8fea5c4bfc55763dd42f1b50a94c1b4a2fbd4"}, + {file = "lxml-4.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f3df3db1d336b9356dd3112eae5f5c2b8b377f3bc826848567f10bfddfee77e9"}, + {file = "lxml-4.9.3.tar.gz", hash = "sha256:48628bd53a426c9eb9bc066a923acaa0878d1e86129fd5359aee99285f4eed9c"}, ] [package.extras] cssselect = ["cssselect (>=0.7)"] html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=0.29.7)"] +source = ["Cython (>=0.29.35)"] [[package]] name = "macholib" @@ -2896,77 +2947,65 @@ files = [ [[package]] name = "pillow" -version = "9.5.0" +version = "10.0.0" description = "Python Imaging Library (Fork)" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "Pillow-9.5.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16"}, - {file = "Pillow-9.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3d403753c9d5adc04d4694d35cf0391f0f3d57c8e0030aac09d7678fa8030aa"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ba1b81ee69573fe7124881762bb4cd2e4b6ed9dd28c9c60a632902fe8db8b38"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe7e1c262d3392afcf5071df9afa574544f28eac825284596ac6db56e6d11062"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f36397bf3f7d7c6a3abdea815ecf6fd14e7fcd4418ab24bae01008d8d8ca15e"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:252a03f1bdddce077eff2354c3861bf437c892fb1832f75ce813ee94347aa9b5"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:85ec677246533e27770b0de5cf0f9d6e4ec0c212a1f89dfc941b64b21226009d"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b416f03d37d27290cb93597335a2f85ed446731200705b22bb927405320de903"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1781a624c229cb35a2ac31cc4a77e28cafc8900733a864870c49bfeedacd106a"}, - {file = "Pillow-9.5.0-cp310-cp310-win32.whl", hash = "sha256:8507eda3cd0608a1f94f58c64817e83ec12fa93a9436938b191b80d9e4c0fc44"}, - {file = "Pillow-9.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:d3c6b54e304c60c4181da1c9dadf83e4a54fd266a99c70ba646a9baa626819eb"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:7ec6f6ce99dab90b52da21cf0dc519e21095e332ff3b399a357c187b1a5eee32"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:560737e70cb9c6255d6dcba3de6578a9e2ec4b573659943a5e7e4af13f298f5c"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96e88745a55b88a7c64fa49bceff363a1a27d9a64e04019c2281049444a571e3"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9c206c29b46cfd343ea7cdfe1232443072bbb270d6a46f59c259460db76779a"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcc2c53c06f2ccb8976fb5c71d448bdd0a07d26d8e07e321c103416444c7ad1"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a0f9bb6c80e6efcde93ffc51256d5cfb2155ff8f78292f074f60f9e70b942d99"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8d935f924bbab8f0a9a28404422da8af4904e36d5c33fc6f677e4c4485515625"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fed1e1cf6a42577953abbe8e6cf2fe2f566daebde7c34724ec8803c4c0cda579"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c1170d6b195555644f0616fd6ed929dfcf6333b8675fcca044ae5ab110ded296"}, - {file = "Pillow-9.5.0-cp311-cp311-win32.whl", hash = "sha256:54f7102ad31a3de5666827526e248c3530b3a33539dbda27c6843d19d72644ec"}, - {file = "Pillow-9.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfa4561277f677ecf651e2b22dc43e8f5368b74a25a8f7d1d4a3a243e573f2d4"}, - {file = "Pillow-9.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:965e4a05ef364e7b973dd17fc765f42233415974d773e82144c9bbaaaea5d089"}, - {file = "Pillow-9.5.0-cp312-cp312-win32.whl", hash = "sha256:22baf0c3cf0c7f26e82d6e1adf118027afb325e703922c8dfc1d5d0156bb2eeb"}, - {file = "Pillow-9.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:432b975c009cf649420615388561c0ce7cc31ce9b2e374db659ee4f7d57a1f8b"}, - {file = "Pillow-9.5.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:5d4ebf8e1db4441a55c509c4baa7a0587a0210f7cd25fcfe74dbbce7a4bd1906"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375f6e5ee9620a271acb6820b3d1e94ffa8e741c0601db4c0c4d3cb0a9c224bf"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99eb6cafb6ba90e436684e08dad8be1637efb71c4f2180ee6b8f940739406e78"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfaaf10b6172697b9bceb9a3bd7b951819d1ca339a5ef294d1f1ac6d7f63270"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:763782b2e03e45e2c77d7779875f4432e25121ef002a41829d8868700d119392"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:35f6e77122a0c0762268216315bf239cf52b88865bba522999dc38f1c52b9b47"}, - {file = "Pillow-9.5.0-cp37-cp37m-win32.whl", hash = "sha256:aca1c196f407ec7cf04dcbb15d19a43c507a81f7ffc45b690899d6a76ac9fda7"}, - {file = "Pillow-9.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322724c0032af6692456cd6ed554bb85f8149214d97398bb80613b04e33769f6"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:a0aa9417994d91301056f3d0038af1199eb7adc86e646a36b9e050b06f526597"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8286396b351785801a976b1e85ea88e937712ee2c3ac653710a4a57a8da5d9c"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c830a02caeb789633863b466b9de10c015bded434deb3ec87c768e53752ad22a"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbd359831c1657d69bb81f0db962905ee05e5e9451913b18b831febfe0519082"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8fc330c3370a81bbf3f88557097d1ea26cd8b019d6433aa59f71195f5ddebbf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:7002d0797a3e4193c7cdee3198d7c14f92c0836d6b4a3f3046a64bd1ce8df2bf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:229e2c79c00e85989a34b5981a2b67aa079fd08c903f0aaead522a1d68d79e51"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9adf58f5d64e474bed00d69bcd86ec4bcaa4123bfa70a65ce72e424bfb88ed96"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:662da1f3f89a302cc22faa9f14a262c2e3951f9dbc9617609a47521c69dd9f8f"}, - {file = "Pillow-9.5.0-cp38-cp38-win32.whl", hash = "sha256:6608ff3bf781eee0cd14d0901a2b9cc3d3834516532e3bd673a0a204dc8615fc"}, - {file = "Pillow-9.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:e49eb4e95ff6fd7c0c402508894b1ef0e01b99a44320ba7d8ecbabefddcc5569"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:482877592e927fd263028c105b36272398e3e1be3269efda09f6ba21fd83ec66"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3ded42b9ad70e5f1754fb7c2e2d6465a9c842e41d178f262e08b8c85ed8a1d8e"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c446d2245ba29820d405315083d55299a796695d747efceb5717a8b450324115"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aca1152d93dcc27dc55395604dcfc55bed5f25ef4c98716a928bacba90d33a3"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:608488bdcbdb4ba7837461442b90ea6f3079397ddc968c31265c1e056964f1ef"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:60037a8db8750e474af7ffc9faa9b5859e6c6d0a50e55c45576bf28be7419705"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a127ae76092974abfbfa38ca2d12cbeddcdeac0fb71f9627cc1135bedaf9d51a"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:489f8389261e5ed43ac8ff7b453162af39c3e8abd730af8363587ba64bb2e865"}, - {file = "Pillow-9.5.0-cp39-cp39-win32.whl", hash = "sha256:9b1af95c3a967bf1da94f253e56b6286b50af23392a886720f563c547e48e964"}, - {file = "Pillow-9.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:77165c4a5e7d5a284f10a6efaa39a0ae8ba839da344f20b111d62cc932fa4e5d"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:833b86a98e0ede388fa29363159c9b1a294b0905b5128baf01db683672f230f5"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaf305d6d40bd9632198c766fb64f0c1a83ca5b667f16c1e79e1661ab5060140"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0852ddb76d85f127c135b6dd1f0bb88dbb9ee990d2cd9aa9e28526c93e794fba"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:91ec6fe47b5eb5a9968c79ad9ed78c342b1f97a091677ba0e012701add857829"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cb841572862f629b99725ebaec3287fc6d275be9b14443ea746c1dd325053cbd"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:c380b27d041209b849ed246b111b7c166ba36d7933ec6e41175fd15ab9eb1572"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c9af5a3b406a50e313467e3565fc99929717f780164fe6fbb7704edba0cebbe"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5671583eab84af046a397d6d0ba25343c00cd50bce03787948e0fff01d4fd9b1"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:84a6f19ce086c1bf894644b43cd129702f781ba5751ca8572f08aa40ef0ab7b7"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1e7723bd90ef94eda669a3c2c19d549874dd5badaeefabefd26053304abe5799"}, - {file = "Pillow-9.5.0.tar.gz", hash = "sha256:bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1"}, + {file = "Pillow-10.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f62406a884ae75fb2f818694469519fb685cc7eaff05d3451a9ebe55c646891"}, + {file = "Pillow-10.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d5db32e2a6ccbb3d34d87c87b432959e0db29755727afb37290e10f6e8e62614"}, + {file = "Pillow-10.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edf4392b77bdc81f36e92d3a07a5cd072f90253197f4a52a55a8cec48a12483b"}, + {file = "Pillow-10.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:520f2a520dc040512699f20fa1c363eed506e94248d71f85412b625026f6142c"}, + {file = "Pillow-10.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:8c11160913e3dd06c8ffdb5f233a4f254cb449f4dfc0f8f4549eda9e542c93d1"}, + {file = "Pillow-10.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a74ba0c356aaa3bb8e3eb79606a87669e7ec6444be352870623025d75a14a2bf"}, + {file = "Pillow-10.0.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5d0dae4cfd56969d23d94dc8e89fb6a217be461c69090768227beb8ed28c0a3"}, + {file = "Pillow-10.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:22c10cc517668d44b211717fd9775799ccec4124b9a7f7b3635fc5386e584992"}, + {file = "Pillow-10.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:dffe31a7f47b603318c609f378ebcd57f1554a3a6a8effbc59c3c69f804296de"}, + {file = "Pillow-10.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:9fb218c8a12e51d7ead2a7c9e101a04982237d4855716af2e9499306728fb485"}, + {file = "Pillow-10.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d35e3c8d9b1268cbf5d3670285feb3528f6680420eafe35cccc686b73c1e330f"}, + {file = "Pillow-10.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ed64f9ca2f0a95411e88a4efbd7a29e5ce2cea36072c53dd9d26d9c76f753b3"}, + {file = "Pillow-10.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b6eb5502f45a60a3f411c63187db83a3d3107887ad0d036c13ce836f8a36f1d"}, + {file = "Pillow-10.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c1fbe7621c167ecaa38ad29643d77a9ce7311583761abf7836e1510c580bf3dd"}, + {file = "Pillow-10.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cd25d2a9d2b36fcb318882481367956d2cf91329f6892fe5d385c346c0649629"}, + {file = "Pillow-10.0.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3b08d4cc24f471b2c8ca24ec060abf4bebc6b144cb89cba638c720546b1cf538"}, + {file = "Pillow-10.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d737a602fbd82afd892ca746392401b634e278cb65d55c4b7a8f48e9ef8d008d"}, + {file = "Pillow-10.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:3a82c40d706d9aa9734289740ce26460a11aeec2d9c79b7af87bb35f0073c12f"}, + {file = "Pillow-10.0.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:d80cf684b541685fccdd84c485b31ce73fc5c9b5d7523bf1394ce134a60c6883"}, + {file = "Pillow-10.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76de421f9c326da8f43d690110f0e79fe3ad1e54be811545d7d91898b4c8493e"}, + {file = "Pillow-10.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81ff539a12457809666fef6624684c008e00ff6bf455b4b89fd00a140eecd640"}, + {file = "Pillow-10.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce543ed15570eedbb85df19b0a1a7314a9c8141a36ce089c0a894adbfccb4568"}, + {file = "Pillow-10.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:685ac03cc4ed5ebc15ad5c23bc555d68a87777586d970c2c3e216619a5476223"}, + {file = "Pillow-10.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d72e2ecc68a942e8cf9739619b7f408cc7b272b279b56b2c83c6123fcfa5cdff"}, + {file = "Pillow-10.0.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d50b6aec14bc737742ca96e85d6d0a5f9bfbded018264b3b70ff9d8c33485551"}, + {file = "Pillow-10.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:00e65f5e822decd501e374b0650146063fbb30a7264b4d2744bdd7b913e0cab5"}, + {file = "Pillow-10.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:f31f9fdbfecb042d046f9d91270a0ba28368a723302786c0009ee9b9f1f60199"}, + {file = "Pillow-10.0.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:349930d6e9c685c089284b013478d6f76e3a534e36ddfa912cde493f235372f3"}, + {file = "Pillow-10.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3a684105f7c32488f7153905a4e3015a3b6c7182e106fe3c37fbb5ef3e6994c3"}, + {file = "Pillow-10.0.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4f69b3700201b80bb82c3a97d5e9254084f6dd5fb5b16fc1a7b974260f89f43"}, + {file = "Pillow-10.0.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f07ea8d2f827d7d2a49ecf1639ec02d75ffd1b88dcc5b3a61bbb37a8759ad8d"}, + {file = "Pillow-10.0.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:040586f7d37b34547153fa383f7f9aed68b738992380ac911447bb78f2abe530"}, + {file = "Pillow-10.0.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:f88a0b92277de8e3ca715a0d79d68dc82807457dae3ab8699c758f07c20b3c51"}, + {file = "Pillow-10.0.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c7cf14a27b0d6adfaebb3ae4153f1e516df54e47e42dcc073d7b3d76111a8d86"}, + {file = "Pillow-10.0.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3400aae60685b06bb96f99a21e1ada7bc7a413d5f49bce739828ecd9391bb8f7"}, + {file = "Pillow-10.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:dbc02381779d412145331789b40cc7b11fdf449e5d94f6bc0b080db0a56ea3f0"}, + {file = "Pillow-10.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:9211e7ad69d7c9401cfc0e23d49b69ca65ddd898976d660a2fa5904e3d7a9baa"}, + {file = "Pillow-10.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:faaf07ea35355b01a35cb442dd950d8f1bb5b040a7787791a535de13db15ed90"}, + {file = "Pillow-10.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9f72a021fbb792ce98306ffb0c348b3c9cb967dce0f12a49aa4c3d3fdefa967"}, + {file = "Pillow-10.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f7c16705f44e0504a3a2a14197c1f0b32a95731d251777dcb060aa83022cb2d"}, + {file = "Pillow-10.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:76edb0a1fa2b4745fb0c99fb9fb98f8b180a1bbceb8be49b087e0b21867e77d3"}, + {file = "Pillow-10.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:368ab3dfb5f49e312231b6f27b8820c823652b7cd29cfbd34090565a015e99ba"}, + {file = "Pillow-10.0.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:608bfdee0d57cf297d32bcbb3c728dc1da0907519d1784962c5f0c68bb93e5a3"}, + {file = "Pillow-10.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5c6e3df6bdd396749bafd45314871b3d0af81ff935b2d188385e970052091017"}, + {file = "Pillow-10.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:7be600823e4c8631b74e4a0d38384c73f680e6105a7d3c6824fcf226c178c7e6"}, + {file = "Pillow-10.0.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:92be919bbc9f7d09f7ae343c38f5bb21c973d2576c1d45600fce4b74bafa7ac0"}, + {file = "Pillow-10.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8182b523b2289f7c415f589118228d30ac8c355baa2f3194ced084dac2dbba"}, + {file = "Pillow-10.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:38250a349b6b390ee6047a62c086d3817ac69022c127f8a5dc058c31ccef17f3"}, + {file = "Pillow-10.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:88af2003543cc40c80f6fca01411892ec52b11021b3dc22ec3bc9d5afd1c5334"}, + {file = "Pillow-10.0.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:c189af0545965fa8d3b9613cfdb0cd37f9d71349e0f7750e1fd704648d475ed2"}, + {file = "Pillow-10.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce7b031a6fc11365970e6a5686d7ba8c63e4c1cf1ea143811acbb524295eabed"}, + {file = "Pillow-10.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:db24668940f82321e746773a4bc617bfac06ec831e5c88b643f91f122a785684"}, + {file = "Pillow-10.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:efe8c0681042536e0d06c11f48cebe759707c9e9abf880ee213541c5b46c5bf3"}, + {file = "Pillow-10.0.0.tar.gz", hash = "sha256:9c82b5b3e043c7af0d95792d0d20ccf68f61a1fec6b3530e718b688422727396"}, ] [package.extras] @@ -3145,13 +3184,13 @@ tqdm = "*" [[package]] name = "prompt-toolkit" -version = "3.0.38" +version = "3.0.39" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.7.0" files = [ - {file = "prompt_toolkit-3.0.38-py3-none-any.whl", hash = "sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f"}, - {file = "prompt_toolkit-3.0.38.tar.gz", hash = "sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b"}, + {file = "prompt_toolkit-3.0.39-py3-none-any.whl", hash = "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88"}, + {file = "prompt_toolkit-3.0.39.tar.gz", hash = "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac"}, ] [package.dependencies] @@ -3326,6 +3365,17 @@ files = [ {file = "pyclipper-1.3.0.post4.tar.gz", hash = "sha256:b73b19d2a1b895edcacaf4acb441e13e99b9e5fd53b9c0dfd2e1326e2bf68a7a"}, ] +[[package]] +name = "pycodestyle" +version = "2.10.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, + {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, +] + [[package]] name = "pycountry" version = "22.3.5" @@ -3441,6 +3491,34 @@ files = [ [package.extras] docs = ["matplotlib", "numpy", "numpydoc", "pillow", "sphinx", "sphinx-copybutton", "sphinx-gallery", "sphinx_rtd_theme", "sphinxcontrib-napoleon"] +[[package]] +name = "pydocstyle" +version = "6.3.0" +description = "Python docstring style checker" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, + {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, +] + +[package.dependencies] +snowballstemmer = ">=2.2.0" + +[package.extras] +toml = ["tomli (>=1.2.3)"] + +[[package]] +name = "pyflakes" +version = "3.0.1" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, + {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, +] + [[package]] name = "pygetwindow" version = "0.0.4" @@ -3503,13 +3581,13 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2023.4" +version = "2023.5" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.7" files = [ - {file = "pyinstaller-hooks-contrib-2023.4.tar.gz", hash = "sha256:9c11197653de9605a81975325a60b9369e9cdc37c009b6aeb0221a57211f9388"}, - {file = "pyinstaller_hooks_contrib-2023.4-py2.py3-none-any.whl", hash = "sha256:fc9892e46fa19d05725205413fb21a764f2f6ff1e70ba95322fb02420a665a45"}, + {file = "pyinstaller-hooks-contrib-2023.5.tar.gz", hash = "sha256:cca6cdc31e739954b5bbbf05ef3f71fe448e9cdacad3a2197243bcf99bea2c00"}, + {file = "pyinstaller_hooks_contrib-2023.5-py2.py3-none-any.whl", hash = "sha256:e60185332a6b56691f471d364e9e9405b03091ca27c96e0dbebdedb7624457fd"}, ] [[package]] @@ -3855,18 +3933,19 @@ cli = ["click (>=5.0)"] [[package]] name = "python-engineio" -version = "4.4.1" +version = "4.5.1" description = "Engine.IO server and client for Python" optional = false python-versions = ">=3.6" files = [ - {file = "python-engineio-4.4.1.tar.gz", hash = "sha256:eb3663ecb300195926b526386f712dff84cd092c818fb7b62eeeda9160120c29"}, - {file = "python_engineio-4.4.1-py3-none-any.whl", hash = "sha256:28ab67f94cba2e5f598cbb04428138fd6bb8b06d3478c939412da445f24f0773"}, + {file = "python-engineio-4.5.1.tar.gz", hash = "sha256:b167a1b208fcdce5dbe96a61a6ca22391cfa6715d796c22de93e3adf9c07ae0c"}, + {file = "python_engineio-4.5.1-py3-none-any.whl", hash = "sha256:67a675569f3e9bb274a8077f3c2068a8fe79cbfcb111cf31ca27b968484fe6c7"}, ] [package.extras] asyncio-client = ["aiohttp (>=3.4)"] client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] +docs = ["sphinx"] [[package]] name = "python-levenshtein" @@ -5589,13 +5668,13 @@ test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6. [[package]] name = "typing-extensions" -version = "4.7.0" +version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" files = [ - {file = "typing_extensions-4.7.0-py3-none-any.whl", hash = "sha256:5d8c9dac95c27d20df12fb1d97b9793ab8b2af8a3a525e68c80e21060c161771"}, - {file = "typing_extensions-4.7.0.tar.gz", hash = "sha256:935ccf31549830cda708b42289d44b6f74084d616a00be651601a4f968e77c82"}, + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] [[package]] @@ -6066,4 +6145,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "3.10.x" -content-hash = "042fe3e83c940c1b6d1b9c128adc1eedda2e325175d4b5512764be79854146eb" +content-hash = "fb0b53155332c2c1de5cf109f8503bf398b591e6066c3fdabc12f3ed9509c7c1" diff --git a/pyproject.toml b/pyproject.toml index fe4175cd9..b8d29c37b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Operating System :: OS Independent" ] -readme = ["README.md"] +readme = "README.md" repository = "https://github.com/mldsai/openadapt" homepage = "https://openadapt.ai/" @@ -73,8 +73,11 @@ torchvision = "^0.15.2" sumy = "0.11.0" nltk = "3.8.1" pywinauto = {version = "^0.6.8", markers = "sys_platform == 'win32'"} +flake8 = "^6.0.0" +flake8-docstrings = "^1.7.0" moviepy = "1.0.3" python-levenshtein = "^0.21.1" +flake8-annotations = "^3.0.1" pre-commit = "^3.3.3" pympler = "^1.0.1" psutil = "^5.9.5" @@ -97,6 +100,14 @@ record = "openadapt.record:start" replay = "openadapt.replay:start" app = "openadapt.app.main:run_app" +[tool.black] +extend-exclude = ''' +/( + | venv + | alembic +)/ +''' + [tool.semantic_release] version_variable = [ "openadapt/__init__.py:__version__" diff --git a/setup.py b/setup.py index 611dea26b..e1faa44a0 100644 --- a/setup.py +++ b/setup.py @@ -7,8 +7,7 @@ $ pip install -e . """ -from setuptools import setup, find_packages - +from setuptools import find_packages, setup MODULE_NAME = "openadapt" MODULE_VERSION = "0.1.0" diff --git a/tests/conftest.py b/tests/conftest.py index f018d074c..387a707ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,31 +1,33 @@ +"""This module contains fixtures and setup for testing.""" + import os -import pytest -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, engine, text +import pytest from openadapt.config import ROOT_DIRPATH -from openadapt.models import db +from openadapt.db import Base + @pytest.fixture(scope="session") -def setup_database(request): +def setup_database(request: pytest.FixtureRequest) -> engine: + """Set up a database for testing.""" # Create a new database or connect to an existing one db_url = ROOT_DIRPATH / "temp.db" - engine = create_engine( - f"sqlite:///{db_url}" - ) + engine = create_engine(f"sqlite:///{db_url}") # Create the database tables (if necessary) - db.Base.metadata.create_all(bind=engine) + Base.metadata.create_all(bind=engine) # Read the SQL file and execute the statements to seed the database - with open(ROOT_DIRPATH / 'assets/fixtures.sql', 'r') as file: + with open(ROOT_DIRPATH / "assets/fixtures.sql", "r") as file: statements = file.read() with engine.connect() as connection: connection.execute(text(statements)) - # Define the teardown function - def teardown(): + def teardown() -> None: + """Teardown function to clean up resources after testing.""" # Add code here to drop tables, clean up resources, etc. # This code will be executed after the tests complete (whether or not they pass) # Replace it with the appropriate cleanup operations for your project diff --git a/tests/openadapt/test_crop.py b/tests/openadapt/test_crop.py index 98ef7acb3..7ce80b179 100644 --- a/tests/openadapt/test_crop.py +++ b/tests/openadapt/test_crop.py @@ -1,10 +1,24 @@ -from unittest.mock import Mock +"""Module to test cropping functionality.""" + + from unittest import mock +from unittest.mock import Mock + from PIL import Image -import numpy as np + from openadapt.models import Screenshot -def test_crop_active_window(): + +def test_crop_active_window() -> None: + """Test the crop_active_window function. + + This function creates a mock action event with a mock window event, + sets up the necessary environment, performs the cropping operation, + and verifies that the image size has been reduced. + + Returns: + None + """ # Create a mock action event with a mock window event action_event_mock = Mock() window_event_mock = Mock() @@ -17,9 +31,9 @@ def test_crop_active_window(): action_event_mock.window_event = window_event_mock # Mock the utils.get_scale_ratios to return some fixed ratios - with mock.patch('openadapt.utils.get_scale_ratios', return_value=(1, 1)): + with mock.patch("openadapt.utils.get_scale_ratios", return_value=(1, 1)): # Create a dummy image and put it in a Screenshot object - image = Image.new('RGB', (500, 500), color='white') + image = Image.new("RGB", (500, 500), color="white") screenshot = Screenshot() screenshot._image = image @@ -30,6 +44,6 @@ def test_crop_active_window(): screenshot.crop_active_window(action_event=action_event_mock) # Verify that the image size has been reduced - assert ((screenshot._image.size[0] < original_size[0]) or - (screenshot._image.size[1] < original_size[1])) - + assert (screenshot._image.size[0] < original_size[0]) or ( + screenshot._image.size[1] < original_size[1] + ) diff --git a/tests/openadapt/test_events.py b/tests/openadapt/test_events.py index 2cbeb766b..154a4e0b2 100644 --- a/tests/openadapt/test_events.py +++ b/tests/openadapt/test_events.py @@ -1,17 +1,14 @@ +"""Module to test events.py.""" + from functools import partial from pprint import pformat +from typing import Callable, Optional import itertools -import pytest from deepdiff import DeepDiff from loguru import logger +import pytest -from openadapt.models import ActionEvent, WindowEvent -from openadapt.utils import ( - get_double_click_interval_seconds, - rows2dicts, - override_double_click_interval_seconds, -) from openadapt.events import ( discard_unused_events, merge_consecutive_keyboard_events, @@ -20,7 +17,12 @@ merge_consecutive_mouse_scroll_events, remove_redundant_mouse_move_events, ) - +from openadapt.models import ActionEvent, WindowEvent +from openadapt.utils import ( + get_double_click_interval_seconds, + override_double_click_interval_seconds, + rows2dicts, +) # default duration between consecutive events # this needs to be small enough such that dt_short + DEFAULT < x, @@ -38,7 +40,12 @@ timestamp_raw = _start_time -def reset_timestamp(): +def reset_timestamp() -> None: + """Reset the timestamp counters to their initial values. + + Returns: + None + """ global timestamp global timestamp_raw timestamp = _start_time @@ -46,16 +53,34 @@ def reset_timestamp(): @pytest.fixture(autouse=True) -def _reset_timestamp(): +def _reset_timestamp() -> None: + """Fixture that automatically resets the timestamp counters before each test. + + Returns: + None + """ reset_timestamp() def make_action_event( - event_dict, - dt=None, - get_pre_children=None, - get_post_children=None, -): + event_dict: dict, + dt: float = None, + get_pre_children: Callable[[], list[ActionEvent]] = None, + get_post_children: Callable[[], list[ActionEvent]] = None, +) -> ActionEvent: + """Create an ActionEvent instance with the given attributes. + + Args: + event_dict (dict): Dictionary containing the event attributes. + dt (float, optional): Time difference in seconds from the previous event. + get_pre_children (function, optional): returns the list of + pre-children events. + get_post_children (function, optional): returns the list of + post-children events. + + Returns: + ActionEvent: An instance of the ActionEvent class. + """ assert "chidren" not in event_dict, event_dict["children"] children = get_children_with_timestamps(get_pre_children) event_dict["children"] = children @@ -90,7 +115,18 @@ def make_action_event( return event -def get_children_with_timestamps(get_children): +def get_children_with_timestamps( + get_children: Callable[[], list[ActionEvent]] +) -> list[ActionEvent]: + """Get the list of children events with timestamps. + + Args: + get_children (Callable[[], list[ActionEvent]]): Function that returns + the list of children events. + + Returns: + list[ActionEvent]: list of children events with timestamps. + """ if not get_children: return [] @@ -110,27 +146,57 @@ def get_children_with_timestamps(get_children): return children -def make_move_event(x=0, y=0, get_pre_children=None, get_post_children=None): +def make_move_event( + x: int = 0, + y: int = 0, + get_pre_children: Optional[Callable[[], list[ActionEvent]]] = None, + get_post_children: Optional[Callable[[], list[ActionEvent]]] = None, +) -> ActionEvent: + """Create a move event with the given attributes. + + Args: + x (int, optional): X-coordinate of the mouse. + y (int, optional): Y-coordinate of the mouse. + get_pre_children (function, optional): returns list of + pre-children events. + get_post_children (function, optional): returns the list of + post-children events. + + Returns: + ActionEvent: An instance of the ActionEvent class representing the move event. + """ return make_action_event( - { - "name": "move", - "mouse_x": x, - "mouse_y": y, - }, + {"name": "move", "mouse_x": x, "mouse_y": y}, get_pre_children=get_pre_children, get_post_children=get_post_children, ) def make_click_event( - pressed, - mouse_x=0, - mouse_y=0, - dt=None, - button_name="left", - get_pre_children=None, - get_post_children=None, -): + pressed: bool, + mouse_x: int = 0, + mouse_y: int = 0, + dt: float = None, + button_name: str = "left", + get_pre_children: Optional[Callable[[], list[ActionEvent]]] = None, + get_post_children: Optional[Callable[[], list[ActionEvent]]] = None, +) -> ActionEvent: + """Create a click event with the given attributes. + + Args: + pressed (bool): True if the mouse button is pressed, False otherwise. + mouse_x (int, optional): X-coordinate of the mouse. + mouse_y (int, optional): Y-coordinate of the mouse. + dt (float, optional): Time difference in seconds from the previous event. + button_name (str, optional): Name of the mouse button. Defaults to "left". + get_pre_children (function, optional): Function that returns the list + of pre-children events. + get_post_children (function, optional): Function that returns the list + of post-children events. + + Returns: + ActionEvent: An instance of the ActionEvent class representing the click event. + """ return make_action_event( { "name": "click", @@ -145,15 +211,36 @@ def make_click_event( ) -def make_scroll_event(dy=0, dx=0): - return make_action_event({ - "name": "scroll", - "mouse_dx": dx, - "mouse_dy": dy, - }) +def make_scroll_event(dy: int = 0, dx: int = 0) -> ActionEvent: + """Create a scroll event with the given attributes. + + Args: + dy (int, optional): Vertical scroll amount. Defaults to 0. + dx (int, optional): Horizontal scroll amount. Defaults to 0. + + Returns: + ActionEvent: An instance of the ActionEvent class representing the scroll event. + """ + return make_action_event({"name": "scroll", "mouse_dx": dx, "mouse_dy": dy}) + + +def make_click_events( + dt_released: float, + dt_pressed: float = None, + button_name: str = "left", +) -> tuple[ActionEvent, ActionEvent]: + """Create a pair of click events with the given time differences and button name. + Args: + dt_released (float): Time difference in seconds between the press + and release events. + dt_pressed (float, optional): Time difference in seconds from the previous + event for the press event. + button_name (str, optional): Name of the mouse button. Defaults to "left". -def make_click_events(dt_released, dt_pressed=None, button_name="left"): + Returns: + tuple: A tuple containing the press and release events. + """ return ( make_click_event(True, dt=dt_pressed, button_name=button_name), make_click_event(False, dt=dt_released, button_name=button_name), @@ -161,8 +248,27 @@ def make_click_events(dt_released, dt_pressed=None, button_name="left"): def make_processed_click_event( - name, dt, get_children, mouse_x=0, mouse_y=0, button_name="left", -): + name: str, + dt: float, + get_children: Optional[Callable[[], list[ActionEvent]]], + mouse_x: int = 0, + mouse_y: int = 0, + button_name: str = "left", +) -> ActionEvent: + """Create a processed click event with the given attributes. + + Args: + name (str): Name of the processed click event. + dt (float): Time difference in seconds from the previous event. + get_children (function): Function that returns the list of children events. + mouse_x (int, optional): X-coordinate of the mouse. Defaults to 0. + mouse_y (int, optional): Y-coordinate of the mouse. Defaults to 0. + button_name (str, optional): Name of the mouse button. Defaults to "left". + + Returns: + ActionEvent: An instance of the ActionEvent class representing + the processed click event. + """ return make_action_event( { "name": name, @@ -176,8 +282,25 @@ def make_processed_click_event( def make_singleclick_event( - dt, get_children, mouse_x=0, mouse_y=0, button_name="left", -): + dt: float, + get_children: Optional[Callable[[], list[ActionEvent]]], + mouse_x: int = 0, + mouse_y: int = 0, + button_name: str = "left", +) -> ActionEvent: + """Create a single click event with the given attributes. + + Args: + dt (float): Time difference in seconds from the previous event. + get_children (function): Function that returns the list of children events. + mouse_x (int, optional): X-coordinate of the mouse. Defaults to 0. + mouse_y (int, optional): Y-coordinate of the mouse. Defaults to 0. + button_name (str, optional): Name of the mouse button. Defaults to "left". + + Returns: + ActionEvent: An instance of the ActionEvent class representing + the single click event. + """ return make_processed_click_event( "singleclick", dt, @@ -189,8 +312,25 @@ def make_singleclick_event( def make_doubleclick_event( - dt, get_children, mouse_x=0, mouse_y=0, button_name="left", -): + dt: float, + get_children: Optional[Callable[[], list[ActionEvent]]], + mouse_x: int = 0, + mouse_y: int = 0, + button_name: str = "left", +) -> ActionEvent: + """Create a double click event with the given attributes. + + Args: + dt (float): Time difference in seconds from the previous event. + get_children (function): Function that returns the list of children events. + mouse_x (int, optional): X-coordinate of the mouse. Defaults to 0. + mouse_y (int, optional): Y-coordinate of the mouse. Defaults to 0. + button_name (str, optional): Name of the mouse button. Defaults to "left". + + Returns: + ActionEvent: An instance of the ActionEvent class representing + the double click event. + """ return make_processed_click_event( "doubleclick", dt, @@ -201,65 +341,64 @@ def make_doubleclick_event( ) -def test_merge_consecutive_mouse_click_events(): +def test_merge_consecutive_mouse_click_events() -> None: + """Test case for merging consecutive mouse click events. + + Returns: + None + """ if OVERRIDE_DOUBLE_CLICK_INTERVAL_SECONDS: - override_double_click_interval_seconds( - OVERRIDE_DOUBLE_CLICK_INTERVAL_SECONDS - ) + override_double_click_interval_seconds(OVERRIDE_DOUBLE_CLICK_INTERVAL_SECONDS) double_click_interval_seconds = get_double_click_interval_seconds() dt_short = double_click_interval_seconds / 10 dt_long = double_click_interval_seconds * 10 raw_events = [ *make_click_events(dt_long, button_name="right"), - # doubleclick *make_click_events(dt_short), *make_click_events(dt_long), - # doubleclick *make_click_events(dt_short), *make_click_events(dt_long), - *make_click_events(dt_long, button_name="right"), - # singleclick *make_click_events(dt_long), ] logger.info(f"raw_events=\n{pformat(rows2dicts(raw_events))}") reset_timestamp() - expected_events = rows2dicts([ - *make_click_events(dt_long, button_name="right"), - make_doubleclick_event( - dt_long, - lambda: [ - *make_click_events(dt_short), - *make_click_events(dt_long), - ], - ), - make_doubleclick_event( - dt_long, - lambda: [ - *make_click_events(dt_short), - *make_click_events(dt_long), - ], - ), - *make_click_events(dt_long, button_name="right"), - make_singleclick_event( - dt_long, - lambda: [ - *make_click_events(dt_long), - ] - ), - ]) - logger.info(f"expected_events=\n{pformat(expected_events)}") - actual_events = rows2dicts( - merge_consecutive_mouse_click_events(raw_events) + expected_events = rows2dicts( + [ + *make_click_events(dt_long, button_name="right"), + make_doubleclick_event( + dt_long, + lambda: [ + *make_click_events(dt_short), + *make_click_events(dt_long), + ], + ), + make_doubleclick_event( + dt_long, + lambda: [ + *make_click_events(dt_short), + *make_click_events(dt_long), + ], + ), + *make_click_events(dt_long, button_name="right"), + make_singleclick_event(dt_long, lambda: [*make_click_events(dt_long)]), + ] ) + logger.info(f"expected_events=\n{pformat(expected_events)}") + actual_events = rows2dicts(merge_consecutive_mouse_click_events(raw_events)) logger.info(f"actual_events=\n{pformat(actual_events)}") assert expected_events == actual_events -def test_merge_consecutive_mouse_move_events(): +def test_merge_consecutive_mouse_move_events() -> None: + """Test case for merging consecutive mouse move events. + + Returns: + None + """ raw_events = [ make_scroll_event(), make_move_event(0), @@ -271,19 +410,27 @@ def test_merge_consecutive_mouse_move_events(): ] logger.info(f"raw_events=\n{pformat(rows2dicts(raw_events))}") reset_timestamp() - expected_events = rows2dicts([ - make_scroll_event(), - make_move_event(2, get_pre_children=lambda: [ - make_move_event(0), - make_move_event(1), - make_move_event(2), - ]), - make_scroll_event(), - make_move_event(4, get_pre_children=lambda: [ - make_move_event(3), - make_move_event(4), - ]), - ]) + expected_events = rows2dicts( + [ + make_scroll_event(), + make_move_event( + 2, + get_pre_children=lambda: [ + make_move_event(0), + make_move_event(1), + make_move_event(2), + ], + ), + make_scroll_event(), + make_move_event( + 4, + get_pre_children=lambda: [ + make_move_event(3), + make_move_event(4), + ], + ), + ] + ) logger.info(f"expected_events=\n{pformat(expected_events)}") actual_events = rows2dicts( merge_consecutive_mouse_move_events( @@ -296,7 +443,12 @@ def test_merge_consecutive_mouse_move_events(): assert expected_events == actual_events -def test_merge_consecutive_mouse_scroll_events(): +def test_merge_consecutive_mouse_scroll_events() -> None: + """Test case for merging consecutive mouse scroll events. + + Returns: + None + """ raw_events = [ make_move_event(), make_scroll_event(dx=2), @@ -308,65 +460,90 @@ def test_merge_consecutive_mouse_scroll_events(): ] logger.info(f"raw_events=\n{pformat(rows2dicts(raw_events))}") reset_timestamp() - expected_events = rows2dicts([ - make_move_event(), - make_scroll_event(dx=2), - make_move_event(), - make_scroll_event(dx=1, dy=1), - ]) - logger.info(f"expected_events=\n{pformat(expected_events)}") - actual_events = rows2dicts( - merge_consecutive_mouse_scroll_events(raw_events) + expected_events = rows2dicts( + [ + make_move_event(), + make_scroll_event(dx=2), + make_move_event(), + make_scroll_event(dx=1, dy=1), + ] ) + logger.info(f"expected_events=\n{pformat(expected_events)}") + actual_events = rows2dicts(merge_consecutive_mouse_scroll_events(raw_events)) logger.info(f"actual_events=\n{pformat(actual_events)}") assert expected_events == actual_events -def test_remove_redundant_mouse_move_events(): +def test_remove_redundant_mouse_move_events() -> None: + """Test case for removing redundant mouse move events. + + Returns: + None + """ # certain failure modes only appear in longer event chains - raw_events = list(itertools.chain(*[ - [ - make_move_event(1), - make_click_event(True, 1), - make_move_event(1), - make_click_event(False, 1), - make_move_event(2), - make_click_event(True, 2), - make_move_event(3), - make_click_event(False, 3), - make_move_event(3), - ] - for _ in range(2) - ])) + raw_events = list( + itertools.chain( + *[ + [ + make_move_event(1), + make_click_event(True, 1), + make_move_event(1), + make_click_event(False, 1), + make_move_event(2), + make_click_event(True, 2), + make_move_event(3), + make_click_event(False, 3), + make_move_event(3), + ] + for _ in range(2) + ] + ) + ) logger.info(f"raw_events=\n{pformat(rows2dicts(raw_events))}") reset_timestamp() - expected_events = rows2dicts([ - make_click_event(True, 1, get_pre_children=lambda: [ - make_move_event(1), - ]), - make_click_event(False, 1, get_post_children=lambda: [ - make_move_event(1), - ]), - make_click_event(True, 2, get_post_children=lambda: [ - make_move_event(2), - ]), - make_click_event(False, 3, get_post_children=lambda: [ - make_move_event(3), - ]), - make_click_event(True, 1, get_post_children=lambda: [ - make_move_event(3), - make_move_event(1), - ]), - make_click_event(False, 1, get_post_children=lambda: [ - make_move_event(1), - ]), - make_click_event(True, 2, get_post_children=lambda: [ - make_move_event(2), - ]), - make_click_event(False, 3, get_post_children=lambda: [ - make_move_event(3), - ]), - ]) + expected_events = rows2dicts( + [ + make_click_event(True, 1, get_pre_children=lambda: [make_move_event(1)]), + make_click_event( + False, + 1, + get_post_children=lambda: [make_move_event(1)], + ), + make_click_event( + True, + 2, + get_post_children=lambda: [make_move_event(2)], + ), + make_click_event( + False, + 3, + get_post_children=lambda: [make_move_event(3)], + ), + make_click_event( + True, + 1, + get_post_children=lambda: [ + make_move_event(3), + make_move_event(1), + ], + ), + make_click_event( + False, + 1, + get_post_children=lambda: [make_move_event(1)], + ), + make_click_event( + True, + 2, + get_post_children=lambda: [make_move_event(2)], + ), + make_click_event( + False, + 3, + get_post_children=lambda: [make_move_event(3)], + ), + ] + ) logger.info(f"expected_events=\n{pformat(expected_events)}") actual_events = rows2dicts( remove_redundant_mouse_move_events(raw_events), @@ -375,38 +552,69 @@ def test_remove_redundant_mouse_move_events(): assert expected_events == actual_events -def make_press_event(char=None, name=None): +def make_press_event(char: str = None, name: str = None) -> ActionEvent: + """Create a press event with the given character or key name. + + Args: + char (str, optional): Character corresponding to the pressed key. + Defaults to None. + name (str, optional): Name of the pressed key. Defaults to None. + + Returns: + ActionEvent: An instance of the ActionEvent class representing the press event. + """ assert (char or name) and not (char and name), (char, name) - return make_action_event({ - "name": "press", - "key_char": char, - "key_name": name, - }) + return make_action_event({"name": "press", "key_char": char, "key_name": name}) + +def make_release_event(char: str = None, name: str = None) -> ActionEvent: + """Create a release event with the given character or key name. -def make_release_event(char=None, name=None): + Args: + char (str, optional): Character corresponding to the released key. + Defaults to None. + name (str, optional): Name of the released key. Defaults to None. + + Returns: + ActionEvent: An instance of the ActionEvent class representing + the release event. + """ assert (char or name) and not (char and name), (char, name) - return make_action_event({ - "name": "release", - "key_char": char, - "key_name": name, - }) + return make_action_event({"name": "release", "key_char": char, "key_name": name}) -def make_type_event(get_children): - return make_action_event( - { - "name": "type", - }, - get_pre_children=get_children, - ) +def make_type_event( + get_children: Optional[Callable[[], list[ActionEvent]]] +) -> ActionEvent: + """Create a type event with the given children events. + + Args: + get_children (function): Function that returns the list of children events. + + Returns: + ActionEvent: An instance of the ActionEvent class representing the type event. + """ + return make_action_event({"name": "type"}, get_pre_children=get_children) -def make_key_events(char): +def make_key_events(char: str) -> tuple[ActionEvent, ActionEvent]: + """Create a pair of press and release events for the given character. + + Args: + char (str): Character corresponding to the key. + + Returns: + tuple: A tuple containing the press and release events. + """ return make_press_event(char), make_release_event(char) -def test_merge_consecutive_keyboard_events(): +def test_merge_consecutive_keyboard_events() -> None: + """Test case for merging consecutive keyboard events. + + Returns: + None + """ raw_events = [ make_click_event(True), *make_key_events("a"), @@ -425,36 +633,45 @@ def test_merge_consecutive_keyboard_events(): ] logger.info(f"raw_events=\n{pformat(rows2dicts(raw_events))}") reset_timestamp() - expected_events = rows2dicts([ - make_click_event(True), - make_type_event(lambda: [ - *make_key_events("a"), - *make_key_events("b"), - *make_key_events("c"), - *make_key_events("d"), - *make_key_events("e"), - ]), - make_click_event(False), - make_type_event(lambda: [ - make_press_event("f"), - make_press_event("g"), - make_release_event("f"), - make_press_event("h"), - make_release_event("g"), - make_release_event("h"), - ]), - make_scroll_event(1), - ]) - logger.info(f"expected_events=\n{pformat(expected_events)}") - actual_events = rows2dicts( - merge_consecutive_keyboard_events(raw_events) + expected_events = rows2dicts( + [ + make_click_event(True), + make_type_event( + lambda: [ + *make_key_events("a"), + *make_key_events("b"), + *make_key_events("c"), + *make_key_events("d"), + *make_key_events("e"), + ] + ), + make_click_event(False), + make_type_event( + lambda: [ + make_press_event("f"), + make_press_event("g"), + make_release_event("f"), + make_press_event("h"), + make_release_event("g"), + make_release_event("h"), + ] + ), + make_scroll_event(1), + ] ) + logger.info(f"expected_events=\n{pformat(expected_events)}") + actual_events = rows2dicts(merge_consecutive_keyboard_events(raw_events)) logger.info(f"actual_events=\n{pformat(actual_events)}") diff = DeepDiff(expected_events, actual_events) assert not diff, pformat(diff) -def test_merge_consecutive_keyboard_events__grouped(): +def test_merge_consecutive_keyboard_events__grouped() -> None: + """Test case for merging consecutive keyboard events with grouping. + + Returns: + None + """ raw_events = [ make_click_event(True), *make_key_events("a"), @@ -472,27 +689,25 @@ def test_merge_consecutive_keyboard_events__grouped(): ] logger.info(f"raw_events=\n{pformat(rows2dicts(raw_events))}") reset_timestamp() - expected_events = rows2dicts([ - make_click_event(True), - make_type_event(lambda: [ - *make_key_events("a"), - *make_key_events("b"), - ]), - make_type_event(lambda: [ - make_press_event(name="ctrl"), - *make_key_events("c"), - make_press_event(name="alt"), - *make_key_events("d"), - make_release_event(name="ctrl"), - *make_key_events("e"), - make_release_event(name="alt"), - ]), - make_type_event(lambda: [ - *make_key_events("f"), - *make_key_events("g"), - ]), - make_click_event(False), - ]) + expected_events = rows2dicts( + [ + make_click_event(True), + make_type_event(lambda: [*make_key_events("a"), *make_key_events("b")]), + make_type_event( + lambda: [ + make_press_event(name="ctrl"), + *make_key_events("c"), + make_press_event(name="alt"), + *make_key_events("d"), + make_release_event(name="ctrl"), + *make_key_events("e"), + make_release_event(name="alt"), + ] + ), + make_type_event(lambda: [*make_key_events("f"), *make_key_events("g")]), + make_click_event(False), + ] + ) logger.info(f"expected_events=\n{pformat(expected_events)}") actual_events = rows2dicts( merge_consecutive_keyboard_events(raw_events, group_named_keys=True) @@ -502,11 +717,27 @@ def test_merge_consecutive_keyboard_events__grouped(): assert not diff, pformat(diff) -def make_window_event(event_dict): +def make_window_event(event_dict: dict) -> WindowEvent: + """Create a WindowEvent with the given attributes. + + Args: + event_dict (dict): Dictionary containing the attributes of the WindowEvent. + + Returns: + WindowEvent: An instance of the WindowEvent class with the specified attributes. + """ return WindowEvent(**event_dict) -def test_discard_unused_events(): +def test_discard_unused_events() -> None: + """Test case for discarding unused events. + + Tests that the discard_unused_events function discards unused events based + on specified timestamp. + + Returns: + None + """ window_events = [ make_window_event({"timestamp": 0}), make_window_event({"timestamp": 1}), @@ -516,11 +747,17 @@ def test_discard_unused_events(): make_action_event({"window_event_timestamp": 0}), make_action_event({"window_event_timestamp": 2}), ] - expected_filtered_window_events = rows2dicts([ - make_window_event({"timestamp": 0}), - make_window_event({"timestamp": 2}), - ]) - actual_filtered_window_events = rows2dicts(discard_unused_events( - window_events, action_events, "window_event_timestamp", - )) + expected_filtered_window_events = rows2dicts( + [ + make_window_event({"timestamp": 0}), + make_window_event({"timestamp": 2}), + ] + ) + actual_filtered_window_events = rows2dicts( + discard_unused_events( + window_events, + action_events, + "window_event_timestamp", + ) + ) assert expected_filtered_window_events == actual_filtered_window_events diff --git a/tests/openadapt/test_scrub.py b/tests/openadapt/test_scrub.py index a9358cb7c..34cf84860 100644 --- a/tests/openadapt/test_scrub.py +++ b/tests/openadapt/test_scrub.py @@ -1,4 +1,4 @@ -"""Module to test scrub.py""" +"""Module to test scrub.py.""" from io import BytesIO import os @@ -6,14 +6,18 @@ from PIL import Image -from openadapt import scrub, config +from openadapt import config, scrub def _hex_to_rgb(hex_color: int) -> tuple[int, int, int]: - """ - Convert a hex color (int) to RGB - """ + """Convert a hex color (int) to RGB. + Args: + hex_color (int): Hex color value. + + Returns: + tuple[int, int, int]: RGB values. + """ assert 0x000000 <= hex_color <= 0xFFFFFF b = (hex_color >> 16) & 0xFF g = (hex_color >> 8) & 0xFF @@ -22,10 +26,7 @@ def _hex_to_rgb(hex_color: int) -> tuple[int, int, int]: def test_scrub_image() -> None: - """ - Test that the scrubbed image data is different - """ - + """Test that the scrubbed image data is different.""" warnings.filterwarnings("ignore", category=DeprecationWarning) # Read test image data from file @@ -49,7 +50,8 @@ def test_scrub_image() -> None: # Count the number of pixels having the color of the mask mask_pixels = sum( - 1 for pixel in scrubbed_image.getdata() + 1 + for pixel in scrubbed_image.getdata() if pixel == _hex_to_rgb(config.SCRUB_FILL_COLOR) ) total_pixels = scrubbed_image.width * scrubbed_image.height @@ -58,40 +60,28 @@ def test_scrub_image() -> None: scrubbed_image.close() os.remove(scrubbed_image_path) - # Assert that the number of mask pixels - # is approximately 1.5% the total number of pixels + # Assert ~1.5% mask pixels compared to total pixels. assert ( round((mask_pixels / total_pixels), 3) == 0.015 ) # Change this value as necessary def test_empty_string() -> None: - """ - Test that an empty string is returned - if an empty string is passed to the scrub function. - """ - + """Test empty string input for scrub function returns empty string.""" text = "" expected_output = "" assert scrub.scrub_text(text) == expected_output def test_no_scrub_string() -> None: - """ - Test that the same string is returned - """ - + """Test that the same string is returned.""" text = "This string doesn't have anything to scrub." expected_output = "This string doesn't have anything to scrub." assert scrub.scrub_text(text) == expected_output def test_scrub_email() -> None: - """ - Test that the email address is scrubbed - """ - - # Test scrubbing of email address + """Test that the email address is scrubbed.""" assert ( scrub.scrub_text("My email is john.doe@example.com.") == "My email is ." @@ -99,10 +89,7 @@ def test_scrub_email() -> None: def test_scrub_phone_number() -> None: - """ - Test that the phone number is scrubbed - """ - + """Test that the phone number is scrubbed.""" assert ( scrub.scrub_text("My phone number is 123-456-7890.") == "My phone number is ." @@ -110,20 +97,14 @@ def test_scrub_phone_number() -> None: def test_scrub_credit_card() -> None: - """ - Test that the credit card number is scrubbed - """ - + """Test that the credit card number is scrubbed.""" assert ( scrub.scrub_text("My credit card number is 4234-5678-9012-3456 and ") ) == "My credit card number is and " def test_scrub_date_of_birth() -> None: - """ - Test that the date of birth is scrubbed - """ - + """Test that the date of birth is scrubbed.""" assert ( scrub.scrub_text("My date of birth is 01/01/2000.") == "My date of birth is 01/01/2000." @@ -131,10 +112,7 @@ def test_scrub_date_of_birth() -> None: def test_scrub_address() -> None: - """ - Test that the address is scrubbed - """ - + """Test that the address is scrubbed.""" assert ( scrub.scrub_text("My address is 123 Main St, Toronto, On, CAN.") == "My address is 123 Main St, , On, ." @@ -142,10 +120,7 @@ def test_scrub_address() -> None: def test_scrub_ssn() -> None: - """ - Test that the social security number is scrubbed - """ - + """Test that the social security number is scrubbed.""" # Test scrubbing of social security number assert ( scrub.scrub_text("My social security number is 923-45-6789") @@ -154,10 +129,7 @@ def test_scrub_ssn() -> None: def test_scrub_dl() -> None: - """ - Test that the driver's license number is scrubbed - """ - + """Test that the driver's license number is scrubbed.""" assert ( scrub.scrub_text("My driver's license number is A123-456-789-012") == "My driver's license number is -456-789-012" @@ -165,10 +137,7 @@ def test_scrub_dl() -> None: def test_scrub_passport() -> None: - """ - Test that the passport number is scrubbed - """ - + """Test that the passport number is scrubbed.""" assert ( scrub.scrub_text("My passport number is A1234567.") == "My passport number is ." @@ -176,34 +145,25 @@ def test_scrub_passport() -> None: def test_scrub_national_id() -> None: - """ - Test that the national ID number is scrubbed - """ - + """Test that the national ID number is scrubbed.""" assert ( scrub.scrub_text("My national ID number is 1234567890123.") == "My national ID number is ." ) -def test_scrub_routing_number(): - """ - Test that the bank routing number is scrubbed - """ - +def test_scrub_routing_number() -> None: + """Test that the bank routing number is scrubbed.""" assert ( scrub.scrub_text("My bank routing number is 123456789.") - == "My bank routing number is ." or - scrub.scrub_text("My bank routing number is 123456789.") + == "My bank routing number is ." + or scrub.scrub_text("My bank routing number is 123456789.") == "My bank routing number is ." ) def test_scrub_bank_account() -> None: - """ - Test that the bank account number is scrubbed - """ - + """Test that the bank account number is scrubbed.""" assert ( scrub.scrub_text("My bank account number is 635526789012.") == "My bank account number is ." @@ -211,10 +171,7 @@ def test_scrub_bank_account() -> None: def test_scrub_all_together() -> None: - """ - Test that all PII/PHI types are scrubbed - """ - + """Test that all PII/PHI types are scrubbed.""" text_with_pii_phi = ( "John Smith's email is johnsmith@example.com and" " his phone number is 555-123-4567." @@ -222,8 +179,8 @@ def test_scrub_all_together() -> None: " his social security number is 923-45-6789." " He was born on 01/01/1980." ) - assert scrub.scrub_text(text_with_pii_phi) == ( - " email is and" + assert ( + scrub.scrub_text(text_with_pii_phi) == " email is and" " his phone number is ." "His credit card number is and" " his social security number is ." diff --git a/tests/openadapt/test_summary.py b/tests/openadapt/test_summary.py index ba6fe5d4c..9de7afb30 100644 --- a/tests/openadapt/test_summary.py +++ b/tests/openadapt/test_summary.py @@ -1,11 +1,9 @@ -""" -Tests the summarization function in summary.py -""" +"""Tests the summarization function in summary.py.""" + from fuzzywuzzy import fuzz -from openadapt.strategies.mixins.summary import SummaryReplayStrategyMixin from openadapt.models import Recording - +from openadapt.strategies.mixins.summary import SummaryReplayStrategyMixin RECORDING = Recording() @@ -13,27 +11,37 @@ class SummaryReplayStrategy(SummaryReplayStrategyMixin): """Custom Replay Strategy to solely test the Summary Mixin.""" - def __init__(self, recording: Recording): + def __init__(self, recording: Recording) -> None: + """Initialize the SummaryReplayStrategy object. + + Args: + recording (Recording): The recording object. + """ super().__init__(recording) - def get_next_action_event(self): + def get_next_action_event(self) -> None: + """Get the next action event.""" pass REPLAY = SummaryReplayStrategy(RECORDING) -def test_summary_empty(): +def test_summary_empty() -> None: + """Test that an empty text returns an empty summary.""" empty_text = "" actual = REPLAY.get_summary(empty_text, 1) assert len(actual) == 0 -def test_summary_sentence(): - story = "However, this bottle was not marked “poison,” so Alice ventured to taste it, \ - and finding it very nice, (it had, in fact, a sort of mixed flavour of cherry-tart, \ - custard, pine-apple, roast turkey, toffee, and hot buttered toast,) \ - she very soon finished it off." +def test_summary_sentence() -> None: + """Test the summarization of a sentence.""" + story = ( + "However, this bottle was not marked 'poison,' so Alice ventured to taste it," + " and finding it very nice," + " (it had, in fact, a sort of mixed flavour of cherry-tart," + " custard, pine-apple, roast turkey, toffee, and hot buttered toast,)" + " she very soon finished it off." + ) actual = REPLAY.get_summary(story, 1) assert fuzz.WRatio(actual, story) > 50 - \ No newline at end of file