commit 8e9cceaeaed40215edf096ace980270853b020f0 Author: upstream-mirror Date: Wed Sep 16 00:21:37 2026 +0500 snapshot of https://github.com/ActivityWatch/aw-watcher-window.git (master, 2026-09-16; blobs > 131072B omitted) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8e90a47 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,96 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + name: test on ${{ matrix.os }} (py-${{ matrix.python_version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python_version: [3.9] + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python_version }} + - name: Create virtualenv + shell: bash + run: | + python -m venv venv + - name: Install dependencies + shell: bash + run: | + # we need poetry==1.3.2 + # due to: https://github.com/python-poetry/poetry/issues/7611 + # seen in: https://github.com/ActivityWatch/aw-watcher-window/actions/runs/5891369412/job/15978283144 + pip install poetry==1.3.2 + source venv/bin/activate || source venv/Scripts/activate + if [ "${{ runner.os }}" = "macOS" ]; then + MACOSX_DEPLOYMENT_TARGET=12.0 make build + else + make build + fi + - name: Check macOS deployment target + if: runner.os == 'macOS' + shell: bash + run: | + otool -l aw_watcher_window/aw-watcher-window-macos | grep -A4 'LC_BUILD_VERSION' | grep 'minos 12.0' + - name: Run tests + shell: bash + run: | + source venv/bin/activate || source venv/Scripts/activate + make test + - name: Package + shell: bash + run: | + source venv/bin/activate || source venv/Scripts/activate + if [ "${{ runner.os }}" = "macOS" ]; then + MACOSX_DEPLOYMENT_TARGET=12.0 make package + else + make package + fi + - name: Test package + shell: bash + run: | + dist/aw-watcher-window/aw-watcher-window --help + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: aw-watcher-window-${{ runner.os }}-py${{ matrix.python_version }} + path: dist/aw-watcher-window + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Create virtualenv + shell: bash + run: | + python -m venv venv + - name: Install dependencies + shell: bash + run: | + pip install poetry + source venv/bin/activate || source venv/Scripts/activate + make build + - name: Typecheck + shell: bash + run: | + source venv/bin/activate || source venv/Scripts/activate + make typecheck diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3d1854d --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "39 22 * * 3" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ python ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: +security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b099ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__ +*.pyc +*.egg-info +build +dist +*.swp +.mypy_cache +aw-watcher-window-macos diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..54362c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: build test package clean + +MACOSX_DEPLOYMENT_TARGET ?= 12.0 + +build: + poetry install + # if macOS, build swift + if [ "$(shell uname)" = "Darwin" ]; then \ + make build-swift; \ + fi + +build-swift: aw_watcher_window/aw-watcher-window-macos + +aw_watcher_window/aw-watcher-window-macos: aw_watcher_window/macos.swift + swiftc -target "$(shell uname -m)-apple-macosx$(MACOSX_DEPLOYMENT_TARGET)" $^ -o $@ + +test: + poetry run aw-watcher-window --help # Ensures that it at least starts + poetry run python -m pytest tests/ + make typecheck + +typecheck: + poetry run mypy aw_watcher_window/ --ignore-missing-imports + +package: + pyinstaller aw-watcher-window.spec --clean --noconfirm + +clean: + rm -rf build dist + rm -rf aw_watcher_window/__pycache__ + rm aw_watcher_window/aw-watcher-window-macos diff --git a/README.md b/README.md new file mode 100644 index 0000000..41de1a3 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +aw-watcher-window +================= + +Cross-platform window-Watcher for Linux (X11), macOS, Windows. + +[![Build Status](https://travis-ci.org/ActivityWatch/aw-watcher-window.svg?branch=master)](https://travis-ci.org/ActivityWatch/aw-watcher-window) + +## How to install + +To install the pre-built application, go to https://activitywatch.net/downloads/ + +To build your own packaged application, run `make package` + +To install the latest git version directly from github without cloning, run +`pip install git+https://github.com/ActivityWatch/aw-watcher-window.git` + +To install from a cloned version, cd into the directory and run +`poetry install` to install inside an virtualenv. You can run the binary via `aw-watcher-window`. + +If you want to install it system-wide it can be installed with `pip install .`, but that has the issue +that it might not get the exact version of the dependencies due to not reading the poetry.lock file. + +## Usage + +In order for this watcher to be available in the UI, you'll need to have a Away From Computer (afk) watcher running alongside it. + +### Note to macOS users + +To log current window title the terminal needs access to macOS accessibility API. +This can be enabled in `System Preferences > Security & Privacy > Accessibility`, then add the Terminal to this list. If this is not enabled the watcher can only log current application, and not window title. + diff --git a/aw-watcher-window.spec b/aw-watcher-window.spec new file mode 100644 index 0000000..a0648d6 --- /dev/null +++ b/aw-watcher-window.spec @@ -0,0 +1,40 @@ +import platform + +block_cipher = None + +a = Analysis( + ["aw_watcher_window/__main__.py"], + pathex=[], + binaries=[("aw_watcher_window/aw-watcher-window-macos", "aw_watcher_window")] if platform.system() == "Darwin" else [], + datas=[ + ("aw_watcher_window/printAppStatus.jxa", "aw_watcher_window"), + ], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +exe = EXE( + pyz, + a.scripts, + exclude_binaries=True, + name="aw-watcher-window", + contents_directory=".", + debug=False, + strip=False, + upx=True, + console=True, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name="aw-watcher-window", +) diff --git a/aw_watcher_window/__init__.py b/aw_watcher_window/__init__.py new file mode 100644 index 0000000..68e28ec --- /dev/null +++ b/aw_watcher_window/__init__.py @@ -0,0 +1,6 @@ +def main(): + from .main import main as _main + + return _main() + +__all__ = ["main"] diff --git a/aw_watcher_window/__main__.py b/aw_watcher_window/__main__.py new file mode 100644 index 0000000..6405bfd --- /dev/null +++ b/aw_watcher_window/__main__.py @@ -0,0 +1,4 @@ +from aw_watcher_window import main + +if __name__ == "__main__": + main() diff --git a/aw_watcher_window/config.py b/aw_watcher_window/config.py new file mode 100644 index 0000000..e6bf075 --- /dev/null +++ b/aw_watcher_window/config.py @@ -0,0 +1,97 @@ +import argparse + +import tomlkit + +from aw_core.config import load_config_toml + +# Only end-user options belong here. load_config_toml() writes this template into +# the user's config file on first run; it comments out keys but NOT table headers, +# so anything listed here is authored into every fresh install's config. +default_config = """ +[aw-watcher-window] +exclude_title = false +exclude_titles = [] +poll_time = 1.0 +strategy_macos = "swift" +""".strip() + +# Research Edition defaults, deliberately kept out of default_config: these are +# study/dev knobs, not end-user options, and should not be persisted into the +# config of users who are not part of a study. They are still read normally when +# a user does set them (see load_config), which is how the Research Edition build +# and study participants configure them. +# +# The Research Edition release build rewrites the flag below with +# sed -i 's/^research_enabled = false$/research_enabled = true/' +# (ActivityWatch/activitywatch, .github/workflows/release.yml). Keep that line at +# column 0 and byte-identical. +research_defaults = """ +research_enabled = false +""".strip() + + +def load_config(): + config = load_config_toml("aw-watcher-window", default_config)["aw-watcher-window"] + # Research defaults fill in only what the user's config didn't set. + for key, value in tomlkit.parse(research_defaults).items(): + config.setdefault(key, value) + return config + + +def parse_args(): + config = load_config() + + default_poll_time = config["poll_time"] + default_exclude_title = config["exclude_title"] + default_exclude_titles = config["exclude_titles"] + default_strategy_macos = config["strategy_macos"] + default_research_enabled = config.get("research_enabled", False) + + parser = argparse.ArgumentParser( + description="A cross platform window watcher for Activitywatch.\nSupported on: Linux (X11), macOS and Windows." + ) + parser.add_argument("--host", dest="host") + parser.add_argument("--port", dest="port") + parser.add_argument("--testing", dest="testing", action="store_true") + parser.add_argument( + "--exclude-title", + dest="exclude_title", + action="store_true", + default=default_exclude_title, + ) + parser.add_argument( + "--exclude-titles", + dest="exclude_titles", + nargs='+', + default=default_exclude_titles, + help="Exclude window titles by regular expression. Can specify multiple times." + ) + parser.add_argument("--verbose", dest="verbose", action="store_true") + parser.add_argument( + "--poll-time", dest="poll_time", type=float, default=default_poll_time + ) + parser.add_argument( + "--strategy", + dest="strategy", + default=default_strategy_macos, + choices=["jxa", "applescript", "swift"], + help="(macOS only) strategy to use for retrieving the active window", + ) + research_group = parser.add_mutually_exclusive_group() + research_group.add_argument( + "--research", + dest="research_enabled", + action="store_true", + default=default_research_enabled, + help="Enable Research Edition mode: browser titles are classified into study categories, non-browser titles are dropped. Category map must be set in the config file.", + ) + research_group.add_argument( + "--no-research", + dest="research_enabled", + action="store_false", + help="Disable Research Edition mode, even when enabled in the config file.", + ) + parsed_args = parser.parse_args() + parsed_args.research_category_map = dict(config.get("research_category_map", {})) + parsed_args.research_app_category_map = dict(config.get("research_app_category_map", {})) + return parsed_args diff --git a/aw_watcher_window/exceptions.py b/aw_watcher_window/exceptions.py new file mode 100644 index 0000000..99bceab --- /dev/null +++ b/aw_watcher_window/exceptions.py @@ -0,0 +1,2 @@ +class FatalError(Exception): + """Exception raised when the program should exit with a fatal error.""" diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py new file mode 100644 index 0000000..ff21310 --- /dev/null +++ b/aw_watcher_window/lib.py @@ -0,0 +1,81 @@ +import sys +from typing import Optional + +from .exceptions import FatalError + + +def get_current_window_linux() -> Optional[dict]: + from . import xlib + + window = xlib.get_current_window() + + if window is None: + cls = "unknown" + name = "unknown" + else: + cls = xlib.get_window_class(window) + name = xlib.get_window_name(window) + + return {"app": cls, "title": name} + + +def get_current_window_macos(strategy: str) -> Optional[dict]: + # TODO should we use unknown when the title is blank like the other platforms? + + # `jxa` is the default & preferred strategy. It includes the url + incognito status + if strategy == "jxa": + from . import macos_jxa + + return macos_jxa.getInfo() + elif strategy == "applescript": + from . import macos_applescript + + return macos_applescript.getInfo() + else: + raise FatalError(f"invalid strategy '{strategy}'") + + +def get_current_window_windows() -> Optional[dict]: + from . import windows + + window_handle = windows.get_active_window_handle() + + # hwnd 0 means no foreground window (e.g. during UAC prompt, lock screen, + # or secure desktop). Return None so heartbeat_loop skips this poll cycle. + if not window_handle: + return None + + try: + app = windows.get_app_name(window_handle) + except Exception: + # Fall back to WMI for elevated/admin processes where OpenProcess fails + try: + app = windows.get_app_name_wmi(window_handle) + except Exception: + app = None + + title = windows.get_window_title(window_handle) + + if app is None: + app = "unknown" + if title is None: + title = "unknown" + + return {"app": app, "title": title} + + +def get_current_window(strategy: Optional[str] = None) -> Optional[dict]: + """ + :raises FatalError: if a fatal error occurs (e.g. unsupported platform, X server closed) + """ + + if sys.platform.startswith("linux"): + return get_current_window_linux() + elif sys.platform == "darwin": + if strategy is None: + raise FatalError("macOS strategy not specified") + return get_current_window_macos(strategy) + elif sys.platform in ["win32", "cygwin"]: + return get_current_window_windows() + else: + raise FatalError(f"Unknown platform: {sys.platform}") diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift new file mode 100644 index 0000000..9eb97df --- /dev/null +++ b/aw_watcher_window/macos.swift @@ -0,0 +1,751 @@ +import Cocoa +import ScriptingBridge + +@objc protocol ChromeTab { + @objc optional var URL: String { get } + @objc optional var title: String { get } +} + +@objc protocol ChromeWindow { + @objc optional var activeTab: ChromeTab { get } + @objc optional var mode: String { get } +} + +extension SBObject: ChromeWindow, ChromeTab {} + +@objc protocol ChromeProtocol { + @objc optional func windows() -> [ChromeWindow] +} + +extension SBApplication: ChromeProtocol {} + +// https://github.com/tingraldi/SwiftScripting/blob/4346eba0f47e806943601f5fb2fe978e2066b310/Frameworks/SafariScripting/SafariScripting/Safari.swift#L37 + +@objc public protocol SafariDocument { + @objc optional var name: String { get } // Its name. + @objc optional var modified: Bool { get } // Has it been modified since the last save? + @objc optional var file: URL { get } // Its location on disk, if it has one. + @objc optional var source: String { get } // The HTML source of the web page currently loaded in the document. + @objc optional var URL: String { get } // The current URL of the document. + @objc optional var text: String { get } // The text of the web page currently loaded in the document. Modifications to text aren't reflected on the web page. + @objc optional func setURL(_ URL: String!) // The current URL of the document. +} + +@objc public protocol SafariTab { + @objc optional var source: String { get } // The HTML source of the web page currently loaded in the tab. + @objc optional var URL: String { get } // The current URL of the tab. + @objc optional var index: NSNumber { get } // The index of the tab, ordered left to right. + @objc optional var text: String { get } // The text of the web page currently loaded in the tab. Modifications to text aren't reflected on the web page. + @objc optional var visible: Bool { get } // Whether the tab is currently visible. + @objc optional var name: String { get } // The name of the tab. + @objc optional func setURL(_ URL: String!) // The current URL of the tab. +} + +@objc public protocol SafariWindow { + @objc optional var name: String { get } // The title of the window. + @objc optional func id() -> Int // The unique identifier of the window. + @objc optional var index: Int { get } // The index of the window, ordered front to back. + @objc optional var document: SafariDocument { get } // The document whose contents are displayed in the window. + @objc optional func tabs() -> SBElementArray + @objc optional var currentTab: SafariTab { get } // The current tab. +} +extension SBObject: SafariWindow {} + +@objc public protocol SafariApplication { + @objc optional func documents() -> SBElementArray + @objc optional func windows() -> [SafariWindow] + @objc optional var name: String { get } // The name of the application. + @objc optional var frontmost: Bool { get } // Is this the active application? +} +extension SBApplication: SafariApplication {} + +// AW-specific structs + +struct NetworkMessage: Codable, Equatable { + var app: String + var title: String? + var url: String? +} + +struct Heartbeat: Codable { + var timestamp: Date + var data: NetworkMessage +} + +enum HeartbeatError: Error { + case error(msg: String) +} + +struct Bucket: Codable { + var client: String + var type: String + var hostname: String +} + +// there's no builtin logging library on macos which has levels & hits stdout, so we build our own simple one +// there a complex open source one, but it makes it harder to compile this simple one-file swift application +let dateFormatter = DateFormatter(); + +func logTimestamp() -> String { + let now = Date() + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return dateFormatter.string(from: now) +} + +// generate log prefix based on level +func logPrefix(_ level: String) -> String { + return "\(logTimestamp()) [aw-watcher-window-macos] [\(level)]" +} + +let logLevel = ProcessInfo.processInfo.environment["LOG_LEVEL"]?.uppercased() ?? "INFO" + +func debug(_ msg: String) { + if (logLevel == "DEBUG") { + print("\(logPrefix("DEBUG")) \(msg)") + fflush(stdout) + } +} + +func log(_ msg: String) { + print("\(logPrefix("INFO")) \(msg)") + fflush(stdout) +} + +func error(_ msg: String) { + print("\(logPrefix("ERROR")) \(msg)") + fflush(stdout) +} + +// Placeholder values, set in start() from CLI arguments +var baseurl = "http://localhost:5600" +// NOTE: this differs from the hostname we get from Python, here we get `.local`, but in Python we get `.localdomain` +var clientHostname = ProcessInfo.processInfo.hostName +var clientName = "aw-watcher-window" +var bucketName = "\(clientName)_\(clientHostname)" +var excludeTitle = false +var excludeTitlePatterns: [NSRegularExpression] = [] +var researchEnabled = false +var researchCategoryMap: [(pattern: String, category: String)] = [] +var researchAppCategoryMap: [(app: String, category: String)] = [] + +let researchBrowserApps = Set([ + "chrome", + "google chrome", + "google chrome canary", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "chromium", + "chromium-browser", + "chromium.exe", + "brave browser", + "brave", + "brave-browser", + "arc", + "arc browser", + "firefox", + "firefox developer edition", + "firefox-esr", + "safari", + "edge", + "microsoft edge", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", + "opera", + "vivaldi", + "vivaldi.exe", + "chrome.exe", + "brave.exe", + "firefox.exe", + "msedge.exe", + "opera.exe", +]) + +let main = MainThing() +var oldHeartbeat: Heartbeat? + +let encoder = JSONEncoder() +let formatter = ISO8601DateFormatter() +formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + +encoder.dateEncodingStrategy = .custom({ date, encoder in + var container = encoder.singleValueContainer() + let dateString = formatter.string(from: date) + try container.encode(dateString) +}) + +start() +RunLoop.main.run() + +func compileExcludeTitlePattern(_ pattern: String) -> NSRegularExpression { + do { + return try NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + } catch let regexError { + error("Invalid regex pattern: \(pattern) — \(regexError.localizedDescription)") + exit(1) + } +} + +func parseOptionalArguments(_ arguments: ArraySlice) { + var index = arguments.startIndex + while index < arguments.endIndex { + let argument = arguments[index] + + if argument == "--exclude-title" { + excludeTitle = true + index = arguments.index(after: index) + continue + } + + if argument == "--exclude-titles" { + let nextIndex = arguments.index(after: index) + guard nextIndex < arguments.endIndex else { + error("Missing value for --exclude-titles") + exit(1) + } + excludeTitlePatterns.append(compileExcludeTitlePattern(arguments[nextIndex])) + index = arguments.index(after: nextIndex) + continue + } + + if argument == "--research" { + researchEnabled = true + index = arguments.index(after: index) + continue + } + + if argument == "--research-category" { + let patternIndex = arguments.index(after: index) + let categoryIndex = patternIndex < arguments.endIndex ? arguments.index(after: patternIndex) : arguments.endIndex + guard patternIndex < arguments.endIndex, categoryIndex < arguments.endIndex else { + error("Missing pattern/category values for --research-category") + exit(1) + } + researchCategoryMap.append((pattern: arguments[patternIndex], category: arguments[categoryIndex])) + index = arguments.index(after: categoryIndex) + continue + } + + if argument == "--research-app-category" { + let appIndex = arguments.index(after: index) + let categoryIndex = appIndex < arguments.endIndex ? arguments.index(after: appIndex) : arguments.endIndex + guard appIndex < arguments.endIndex, categoryIndex < arguments.endIndex else { + error("Missing app/category values for --research-app-category") + exit(1) + } + researchAppCategoryMap.append((app: arguments[appIndex], category: arguments[categoryIndex])) + index = arguments.index(after: categoryIndex) + continue + } + + error("Unknown argument: \(argument)") + exit(1) + } +} + +func titleShouldBeExcluded(_ title: String) -> Bool { + let range = NSRange(title.startIndex.. Bool { + return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) +} + +// Return the category of the *longest* pattern contained in `haystack`. +// Longest-match beats map order because study maps routinely contain both a +// generic and a specific form of the same host ("google.com" vs +// "docs.google.com"); first-match-wins would file every Google Docs/Drive/ +// Calendar/Meet URL under the generic entry. Ties resolve to map order. +// Twin of `_longest_match` in research_filter.py — keep the two in sync. +func longestResearchMatch(_ haystack: String) -> String? { + var bestCategory: String? + var bestLength = -1 + for item in researchCategoryMap { + if item.pattern.isEmpty { continue } + // Compare by Unicode scalar count, not Character count: Swift's `count` + // measures grapheme clusters while Python's `len()` measures code points, + // which would let the two twins pick different winners for non-ASCII + // patterns of equal visual length. + let patternLength = item.pattern.unicodeScalars.count + if haystack.range(of: item.pattern, options: [.caseInsensitive]) != nil, + patternLength > bestLength + { + bestCategory = item.category + bestLength = patternLength + } + } + return bestCategory +} + +func classifyResearch(_ title: String, url: String?) -> String { + // Try URL first — more reliable than page title, which can change mid-load + if let url = url, !url.isEmpty { + if let category = longestResearchMatch(url) { + return category + } + } + if let category = longestResearchMatch(title) { + return category + } + return "excluded" +} + +func classifyApp(_ app: String) -> String { + // Case-insensitive exact lookup of app name in the app category map. + // Returns the mapped category, or "Excluded" when the app is not in the map. + // Both sides are trimmed and lowercased so a configured key carrying stray + // whitespace matches identically here and in the Python path + // (research_filter.classify_app). Without trimming the configured key, a map + // entry like " Microsoft Outlook" would classify on Linux/Windows but fall + // through to "Excluded" on the macOS Swift path for the same config. + let appLower = app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + for item in researchAppCategoryMap { + if item.app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == appLower { + return item.category + } + } + return "Excluded" +} + +func applyResearchFilter(_ data: NetworkMessage) -> NetworkMessage { + if !researchEnabled { + return data + } + + if isResearchBrowser(data.app) { + return NetworkMessage(app: data.app, title: classifyResearch(data.title ?? "", url: data.url), url: nil) + } + + // Non-browser: map app to study category when a map is provided; + // otherwise keep app name and drop title (legacy behaviour). + if researchAppCategoryMap.isEmpty { + return NetworkMessage(app: data.app, title: nil, url: nil) + } + // Replace the raw app identity with its category — the app name is the + // sensitive identifier for non-browser apps, so it must not be retained. + return NetworkMessage(app: classifyApp(data.app), title: nil, url: nil) +} + +func start() { + // Arguments should be: + // - url + port + // - bucket_id + // - hostname + // - client_id + let arguments = CommandLine.arguments + + // Check that we get the 4 required arguments plus any optional flags + if arguments.count < 5 { + print("Usage: aw-watcher-window [--exclude-title] [--exclude-titles ...] [--research] [--research-category ...] [--research-app-category ...]") + exit(1) + } + + baseurl = arguments[1] + bucketName = arguments[2] + clientHostname = arguments[3] + clientName = arguments[4] + parseOptionalArguments(arguments.dropFirst(5)) + + guard checkAccess() else { + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + start() + } + return + } + + createBucket() + + // listen for changes in focused application + NSWorkspace.shared.notificationCenter.addObserver( + main, + selector: #selector(main.focusedAppChanged), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + + main.focusedAppChanged() + + // Start the polling timer + main.pollingTimer = Timer.scheduledTimer(timeInterval: 10.0, target: main, selector: #selector(main.pollActiveWindow), userInfo: nil, repeats: true) +} + +// TODO might be better to have the python wrapper create this before launching the swift application +func createBucket() { + let payload = try! encoder.encode( + Bucket(client: clientName, type: "currentwindow", hostname: clientHostname)) + + let url = URL(string: "\(baseurl)/api/0/buckets/\(bucketName)")! + Task { + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + let (_, response) = try await URLSession.shared.upload(for: urlRequest, from: payload) + guard (200...299).contains((response as! HTTPURLResponse).statusCode) else { + log("Failed to create bucket") + return + } + } +} + +func sendHeartbeat(_ heartbeat: Heartbeat) { + let oldPayloadDifferent = oldHeartbeat != nil && oldHeartbeat!.data != heartbeat.data + let timeSinceLastHeartbeat = oldHeartbeat != nil ? heartbeat.timestamp.timeIntervalSince(oldHeartbeat!.timestamp) : -1.0 + + // if you resize a window a ton of events (subsecond) will be fired + // we enforce a 1s minimum gap between events to avoid this + if timeSinceLastHeartbeat != -1.0 && timeSinceLastHeartbeat <= 0.5 { + debug("skipping heartbeat, last heartbeat was sent 1s ago") + return + } + + // TODO running these async could cause weird state issues since the observer stuff can send a log of heartbeats + // in a short time under certain circumstances, and we don't want to send them all + Task { + if oldPayloadDifferent { + debug("sending old heartbeat for merging") + + do { + // unlike the python aw-client library, we do not enforce a `commit_interval` and instead send the old event (which is not invalid) + // at the current time with a pulse value equal to the time since this event was originally sent. The aw-server will then merge + // this new event with the original event, extending the recorded time spent on this particular window/application. + + let refreshedOldHeartbeat = Heartbeat( + // it is important to refresh the hearbeat using the timestamp where the user stopped working on the previous application + // more info: https://github.com/ActivityWatch/aw-watcher-window/pull/69 + // we don't *think* this millisecond subtraction is necessary, but it may be: + // https://github.com/ActivityWatch/aw-watcher-window/pull/69#discussion_r987064282 + timestamp: heartbeat.timestamp - 0.001, + data: oldHeartbeat!.data + ) + + try await sendHeartbeatSingle(refreshedOldHeartbeat, pulsetime: timeSinceLastHeartbeat + 1) + } catch { + log("Failed to send old heartbeat: \(error)") + return + } + } + + do { + let since_last_seconds = oldHeartbeat != nil ? heartbeat.timestamp.timeIntervalSince(oldHeartbeat!.timestamp) : 0 + try await sendHeartbeatSingle(heartbeat, pulsetime: since_last_seconds + 1) + } catch { + log("Failed to send heartbeat: \(error)") + return + } + + oldHeartbeat = heartbeat + } +} + +func sendHeartbeatSingle(_ heartbeat: Heartbeat, pulsetime: Double) async throws { + let url = URL(string: "\(baseurl)/api/0/buckets/\(bucketName)/heartbeat?pulsetime=\(pulsetime)")! + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "POST" + urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload = try! encoder.encode(heartbeat) + let (_, response) = try await URLSession.shared.upload(for: urlRequest, from: payload) + + guard (200...299).contains((response as! HTTPURLResponse).statusCode) else { + throw HeartbeatError.error(msg: "Failed to send heartbeat: \(response)") + } + + debug("[heartbeat] bucket: \(bucketName), timestamp: \(heartbeat.timestamp), pulsetime: \(round(pulsetime * 10) / 10), app: \(heartbeat.data.app), title: \(heartbeat.data.title ?? ""), url: \(heartbeat.data.url ?? "")") +} + +class MainThing { + var observer: AXObserver? + var oldWindow: AXUIElement? + var pollingTimer: Timer? + + // list of chrome equivalent browsers + let CHROME_BROWSERS = [ + "Google Chrome", + "Google Chrome Canary", + "Chromium", + "Brave Browser", + ] + + // Gecko-based browsers have no scripting interface for tabs, but expose the + // current page URL on their accessibility tree's AXWebArea node + let FIREFOX_BROWSERS = [ + "Firefox", + "Firefox Developer Edition", + "Firefox Nightly", + "Zen", + "Zen Browser", + "LibreWolf", + "Waterfox", + "Floorp", + ] + + // upper bound on accessibility elements examined per lookup, so a + // pathological tree can't stall the watcher (the web area is typically + // found within a few dozen elements) + let AX_TRAVERSAL_LIMIT = 384 + + // Search the window's accessibility tree breadth-first for an AXWebArea node + // and return its AXURL, the URL of the loaded page. The top-level document's + // web area sits shallow in Gecko's tree and is reached before any web areas + // of nested iframes, so the first hit is the current page. + // (there is no kAXWebAreaRole constant in HIServices; the role string is + // defined by the browsers themselves) + func geckoURL(window: AXUIElement) -> String? { + var queue: [AXUIElement] = [window] + var index = 0 + while index < queue.count && index < AX_TRAVERSAL_LIMIT { + let element = queue[index] + index += 1 + + var roleRef: AnyObject? + AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleRef) + if roleRef as? String == "AXWebArea" { + var urlRef: AnyObject? + AXUIElementCopyAttributeValue(element, kAXURLAttribute as CFString, &urlRef) + if let url = urlRef as? NSURL { + return url.absoluteString + } + // no URL on the web area (e.g. page still loading); stop rather than + // keep searching, since a deeper hit would be an iframe's web area + return urlRef as? String + } + + var childrenRef: AnyObject? + AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef) + if let children = childrenRef as? [AXUIElement] { + queue.append(contentsOf: children) + } + } + return nil + } + + @objc func pollActiveWindow() { + debug("Polling active window") + + guard let frontmost = NSWorkspace.shared.frontmostApplication else { + log("Failed to get frontmost application from polling") + return + } + + let pid = frontmost.processIdentifier + let focusedApp = AXUIElementCreateApplication(pid) + + var focusedWindow: AnyObject? + AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow) + + if focusedWindow != nil { + focusedWindowChanged(observer!, window: focusedWindow as! AXUIElement) + } + } + + deinit { + pollingTimer?.invalidate() + } + + func windowTitleChanged( + _ axObserver: AXObserver, + axElement: AXUIElement, + notification: CFString + ) { + guard let frontmost = NSWorkspace.shared.frontmostApplication else { + log("Failed to get frontmost application from window title notification") + return + } + + // calculate now before executing any scripting since that can take some time + let nowTime = Date.now + + var windowTitle: AnyObject? + AXUIElementCopyAttributeValue(axElement, kAXTitleAttribute as CFString, &windowTitle) + + let applicationName = frontmost.localizedName ?? frontmost.bundleIdentifier ?? "" + var data = NetworkMessage(app: applicationName, title: windowTitle as? String ?? "") + + if CHROME_BROWSERS.contains(applicationName) { + debug("Chrome browser detected, extracting URL and title") + + guard let bundleIdentifier = frontmost.bundleIdentifier else { + log("Failed to get bundle identifier from frontmost application, which was recognized to be Chrome") + return + } + let chromeObject: ChromeProtocol = SBApplication.init(bundleIdentifier: bundleIdentifier)! + + guard let windows = chromeObject.windows, + let frontWindow = windows().first else { + log("Failed to get chrome front window") + return + } + guard let activeTab = frontWindow.activeTab else { + log("Failed to get chrome active tab") + return + } + + if frontWindow.mode == "incognito" { + data = NetworkMessage(app: "", title: "") + } else { + data.url = activeTab.URL + + // the tab title is more accurate and often different than the window title + // however, in some cases the binary does not have the right permissions to read + // the title properly and will return a blank string + + if let tabTitle = activeTab.title { + if(tabTitle != "" && data.title != tabTitle) { + error("tab title diff: \(tabTitle), window title: \(data.title)") + data.title = tabTitle + } + } + } + } else if frontmost.localizedName == "Safari" { + debug("Safari browser detected, extracting URL and title") + + guard let bundleIdentifier = frontmost.bundleIdentifier else { + log("Failed to get bundle identifier from frontmost application, which was recognized to be Safari") + return + } + let safariObject: SafariApplication = SBApplication.init(bundleIdentifier: bundleIdentifier)! + + guard let windows = safariObject.windows, + let frontWindow = windows().first else { + log("Failed to get safari front window") + return + } + guard let activeTab = frontWindow.currentTab else { + log("Failed to get safari active tab") + return + } + + // Safari doesn't allow incognito mode to be inspected, so we do not know if we should hide the url + data.url = activeTab.URL + + // comment above applies here as well + if let tabTitle = activeTab.name { + if tabTitle != "" && data.title != tabTitle { + error("tab title diff: \(tabTitle), window title: \(data.title)") + data.title = tabTitle + } + } + } else if FIREFOX_BROWSERS.contains(applicationName) { + debug("Firefox-based browser detected, extracting URL from accessibility tree") + + // note: private windows are not hidden here (unlike the Chrome incognito + // branch) — Gecko does not mark them in the accessibility tree, and their + // window titles carry a "Private Browsing" suffix for rules to match + data.url = geckoURL(window: axElement) + + if data.url == nil { + // Newer Gecko builds instantiate their accessibility engine lazily and + // no longer treat plain tree walks as an assistive client, leaving the + // window's AX tree without any web content. Requesting + // AXEnhancedUserInterface (as VoiceOver does) turns the engine on; the + // call may report an error while the engine spins up, but the tree is + // populated for subsequent polls and stays on for the browser session. + let axApp = AXUIElementCreateApplication(frontmost.processIdentifier) + AXUIElementSetAttributeValue(axApp, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) + } + } + + if researchEnabled { + data = applyResearchFilter(data) + } else if excludeTitle || titleShouldBeExcluded(data.title ?? "") { + data.title = "excluded" + // the URL identifies the page at least as precisely as the title does, + // so an excluded window must not report it either + data.url = nil + } + + let heartbeat = Heartbeat(timestamp: nowTime, data: data) + sendHeartbeat(heartbeat) + } + + @objc func focusedWindowChanged(_ observer: AXObserver, window: AXUIElement) { + debug("Focused window changed") + + if oldWindow != nil { + AXObserverRemoveNotification(observer, oldWindow!, kAXFocusedWindowChangedNotification as CFString) + } + + let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + AXObserverAddNotification(observer, window, kAXTitleChangedNotification as CFString, selfPtr) + + windowTitleChanged( + observer, axElement: window, notification: kAXTitleChangedNotification as CFString) + + oldWindow = window + } + + @objc func focusedAppChanged() { + debug("Focused app changed") + + if observer != nil { + CFRunLoopRemoveSource( + RunLoop.current.getCFRunLoop(), + AXObserverGetRunLoopSource(observer!), + CFRunLoopMode.defaultMode + ) + } + + guard let frontmost = NSWorkspace.shared.frontmostApplication else { + log("Failed to get frontmost application from app change notification") + return + } + + let pid = frontmost.processIdentifier + let focusedApp = AXUIElementCreateApplication(pid) + + AXObserverCreate( + pid, + { + ( + _ axObserver: AXObserver, + axElement: AXUIElement, + notification: CFString, + userData: UnsafeMutableRawPointer? + ) -> Void in + guard let userData = userData else { + log("Missing userData") + return + } + let application = Unmanaged.fromOpaque(userData).takeUnretainedValue() + if notification == kAXFocusedWindowChangedNotification as CFString { + application.focusedWindowChanged(axObserver, window: axElement) + } else { + application.windowTitleChanged( + axObserver, + axElement: axElement, + notification: notification + ) + } + }, &observer) + + let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + AXObserverAddNotification(observer!, focusedApp, kAXFocusedWindowChangedNotification as CFString, selfPtr) + + CFRunLoopAddSource( + RunLoop.current.getCFRunLoop(), + AXObserverGetRunLoopSource(observer!), + CFRunLoopMode.defaultMode + ) + + var focusedWindow: AnyObject? + AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow) + + if focusedWindow != nil { + focusedWindowChanged(observer!, window: focusedWindow as! AXUIElement) + } + } +} + +// TODO I believe this is handled by the python wrapper so it isn't needed here +func checkAccess() -> Bool { + let checkOptPrompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as NSString + let options = [checkOptPrompt: true] + let accessEnabled = AXIsProcessTrustedWithOptions(options as CFDictionary?) + return accessEnabled +} diff --git a/aw_watcher_window/macos_applescript.py b/aw_watcher_window/macos_applescript.py new file mode 100644 index 0000000..171eb4b --- /dev/null +++ b/aw_watcher_window/macos_applescript.py @@ -0,0 +1,62 @@ +from typing import Dict +from Foundation import NSAppleScript + +# the applescript version of the macos strategy is kept here until the jxa +# approach is proven out in production environments +# https://github.com/ActivityWatch/aw-watcher-window/pull/52 + +source = """ +global frontApp, frontAppName, windowTitle + +set windowTitle to "" +tell application "System Events" + set frontApp to first application process whose frontmost is true + set frontAppName to name of frontApp + tell process frontAppName + try + tell (1st window whose value of attribute "AXMain" is true) + set windowTitle to value of attribute "AXTitle" + end tell + end try + end tell +end tell + +return frontAppName & " +" & windowTitle +""" + +script = None + + +def getInfo() -> Dict[str, str]: + # Cache compiled script + global script + if script is None: + script = NSAppleScript.alloc().initWithSource_(source) + + # Call script + result, errorinfo = script.executeAndReturnError_(None) + if errorinfo: + raise Exception(errorinfo) + output = result.stringValue() + + # Ensure there's no extra newlines in the output + assert len(output.split("\n")) == 2 + + app = getApp(output) + title = getTitle(output) + + return {"app": app, "title": title} + + +def getApp(info: str) -> str: + return info.split('\n')[0] + + +def getTitle(info: str) -> str: + return info.split('\n')[1] + + +if __name__ == "__main__": + info = getInfo() + print(info) diff --git a/aw_watcher_window/macos_cli.py b/aw_watcher_window/macos_cli.py new file mode 100644 index 0000000..172b4ce --- /dev/null +++ b/aw_watcher_window/macos_cli.py @@ -0,0 +1,23 @@ +def build_swift_command( + binpath, + server_address, + bucket_id, + client_hostname, + client_name, + exclude_title=False, + exclude_titles=None, + research_category_map=None, + research_app_category_map=None, +): + command = [binpath, server_address, bucket_id, client_hostname, client_name] + if exclude_title: + command.append("--exclude-title") + for title in exclude_titles or []: + command.extend(["--exclude-titles", title]) + if research_category_map is not None: + command.append("--research") + for pattern, category in research_category_map.items(): + command.extend(["--research-category", pattern, category]) + for app_name, category in (research_app_category_map or {}).items(): + command.extend(["--research-app-category", app_name, category]) + return command diff --git a/aw_watcher_window/macos_jxa.py b/aw_watcher_window/macos_jxa.py new file mode 100644 index 0000000..26dfcf4 --- /dev/null +++ b/aw_watcher_window/macos_jxa.py @@ -0,0 +1,75 @@ +import os +import json +import logging +from typing import Dict + +logger = logging.getLogger(__name__) +script = None + + +def compileScript(): + """ + Compiles the JXA script and caches the result. + + Resources: + - https://stackoverflow.com/questions/44209057/how-can-i-run-jxa-from-swift + - https://stackoverflow.com/questions/16065162/calling-applescript-from-python-without-using-osascript-or-appscript + """ + + # use a global variable to cache the compiled script for performance + global script + if script: + return script + + from OSAKit import OSAScript, OSALanguage + + scriptPath = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "printAppStatus.jxa" + ) + with open(scriptPath) as f: + scriptContents = f.read() + + # remove shebang line + if scriptContents.split("\n")[0].startswith("#"): + scriptContents = "\n".join(scriptContents.split("\n")[1:]) + + script = OSAScript.alloc().initWithSource_language_( + scriptContents, OSALanguage.languageForName_("JavaScript") + ) + success, err = script.compileAndReturnError_(None) + + # should only occur if jxa was modified incorrectly + if not success: + raise Exception(f"error compiling jxa script: {err['NSLocalizedDescription']}") + + return script + + +def getInfo() -> Dict[str, str]: + script = compileScript() + + result, err = script.executeAndReturnError_(None) + + if err: + # error structure: + # { + # NSLocalizedDescription = "Error: Error: Can't get object."; + # NSLocalizedFailureReason = "Error: Error: Can't get object."; + # OSAScriptErrorBriefMessageKey = "Error: Error: Can't get object."; + # OSAScriptErrorMessageKey = "Error: Error: Can't get object."; + # OSAScriptErrorNumberKey = "-1728"; + # OSAScriptErrorRangeKey = "NSRange: {0, 0}"; + # } + + raise Exception(f"jxa error: {err['NSLocalizedDescription']}") + + return json.loads(result.stringValue()) + + +if __name__ == "__main__": + print(getInfo()) + print("Waiting 5 seconds...") + import time + + time.sleep(5) + print(getInfo()) diff --git a/aw_watcher_window/macos_permissions.py b/aw_watcher_window/macos_permissions.py new file mode 100644 index 0000000..e131e7e --- /dev/null +++ b/aw_watcher_window/macos_permissions.py @@ -0,0 +1,42 @@ +import logging +from multiprocessing import Process + +logger = logging.getLogger(__name__) + + +def background_ensure_permissions() -> None: + permission_process = Process(target=ensure_permissions, args=(())) + permission_process.start() + return + + +def ensure_permissions() -> None: + # noreorder + from AppKit import ( # fmt: skip + NSURL, + NSAlert, + NSAlertFirstButtonReturn, + NSWorkspace, + ) + from ApplicationServices import AXIsProcessTrusted # fmt: skip + + accessibility_permissions = AXIsProcessTrusted() + if not accessibility_permissions: + logger.info("No accessibility permissions, prompting user") + title = "Missing accessibility permissions" + info = "To let ActivityWatch capture window titles grant it accessibility permissions. \n If you've already given ActivityWatch accessibility permissions and are still seeing this dialog, try removing and re-adding them." + + alert = NSAlert.new() + alert.setMessageText_(title) + alert.setInformativeText_(info) + + alert.addButtonWithTitle_("Open accessibility settings") + alert.addButtonWithTitle_("Close") + + choice = alert.runModal() + if choice == NSAlertFirstButtonReturn: + NSWorkspace.sharedWorkspace().openURL_( + NSURL.URLWithString_( + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) + ) diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py new file mode 100644 index 0000000..9d20740 --- /dev/null +++ b/aw_watcher_window/main.py @@ -0,0 +1,225 @@ +import logging +import os +import re +import signal +import subprocess +import sys +from datetime import datetime, timezone +from time import sleep + +from aw_client import ActivityWatchClient +from aw_core.log import setup_logging +from aw_core.models import Event + +from .config import parse_args +from .exceptions import FatalError +from .lib import get_current_window +from .research_filter import transform as research_transform +from .macos_cli import build_swift_command +from .macos_permissions import background_ensure_permissions + +logger = logging.getLogger(__name__) + +# run with LOG_LEVEL=DEBUG +log_level = os.environ.get("LOG_LEVEL") +if log_level: + logger.setLevel(logging.__getattribute__(log_level.upper())) + + +def compute_pulsetime(poll_time: float) -> float: + """Scale pulsetime with poll_time so OS scheduling jitter doesn't break heartbeat chains. + + At poll_time=1s, jitter ~0.15s is well within 1s margin (poll_time+1). + At poll_time=5s, jitter ~0.75s exceeds the 1s margin ~10% of the time, + causing missing time in the timeline. max(poll_time*1.5, poll_time+1) keeps + backward compatibility at poll_time≤2s while fixing the problem at higher + polling intervals. See: https://github.com/ActivityWatch/activitywatch/issues/1177 + """ + return max(poll_time * 1.5, poll_time + 1.0) + + +def kill_process(pid): + logger.info("Killing process {}".format(pid)) + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + logger.info("Process {} already dead".format(pid)) + + +def try_compile_title_regex(title): + try: + return re.compile(title, re.IGNORECASE) + except re.error: + logger.error(f"Invalid regex pattern: {title}") + exit(1) + + +def main(): + args = parse_args() + + if sys.platform.startswith("linux") and ( + "DISPLAY" not in os.environ or not os.environ["DISPLAY"] + ): + raise Exception("DISPLAY environment variable not set") + + setup_logging( + name="aw-watcher-window", + testing=args.testing, + verbose=args.verbose, + log_stderr=True, + log_file=True, + ) + if sys.platform == "darwin": + background_ensure_permissions() + + client = ActivityWatchClient( + "aw-watcher-window", host=args.host, port=args.port, testing=args.testing + ) + + bucket_id = f"{client.client_name}_{client.client_hostname}" + event_type = "currentwindow" + + client.create_bucket(bucket_id, event_type, queued=True) + + logger.info("aw-watcher-window started") + client.wait_for_start() + + with client: + research_category_map = ( + args.research_category_map + if args.research_enabled + else None + ) + research_app_category_map = ( + args.research_app_category_map + if args.research_enabled + else None + ) + if sys.platform == "darwin" and args.strategy == "swift": + logger.info("Using swift strategy, calling out to swift binary") + binpath = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "aw-watcher-window-macos" + ) + + try: + p = subprocess.Popen( + build_swift_command( + binpath, + client.server_address, + bucket_id, + client.client_hostname, + client.client_name, + exclude_title=args.exclude_title, + exclude_titles=args.exclude_titles, + research_category_map=research_category_map, + research_app_category_map=research_app_category_map, + ) + ) + # terminate swift process when this process dies + signal.signal(signal.SIGTERM, lambda *_: kill_process(p.pid)) + p.wait() + except KeyboardInterrupt: + print("KeyboardInterrupt") + kill_process(p.pid) + else: + heartbeat_loop( + client, + bucket_id, + poll_time=args.poll_time, + strategy=args.strategy, + exclude_title=args.exclude_title, + exclude_titles=[ + try_compile_title_regex(title) + for title in args.exclude_titles + if title is not None + ], + research_category_map=research_category_map, + research_app_category_map=research_app_category_map, + ) + + +def heartbeat_loop( + client, + bucket_id, + poll_time, + strategy, + exclude_title=False, + exclude_titles=[], + research_category_map=None, + research_app_category_map=None, +): + while True: + if os.getppid() == 1: + logger.info("window-watcher stopped because parent process died") + break + + current_window = None + try: + current_window = get_current_window(strategy) + logger.debug(current_window) + except (FatalError, OSError): + # Fatal exceptions should quit the program + try: + logger.exception("Fatal error, stopping") + except OSError: + pass + break + except Exception: + # Non-fatal exceptions should be logged + try: + # If stdout has been closed, this exception-print can cause (I think) + # OSError: [Errno 5] Input/output error + # See: https://github.com/ActivityWatch/activitywatch/issues/756#issue-1296352264 + # + # However, I'm unable to reproduce the OSError in a test (where I close stdout before logging), + # so I'm in uncharted waters here... but this solution should work. + logger.exception("Exception thrown while trying to get active window") + except OSError: + break + + if current_window is None: + logger.debug("Unable to fetch window, trying again on next poll") + else: + current_window = transform_window( + current_window, + exclude_title=exclude_title, + exclude_titles=exclude_titles, + research_category_map=research_category_map, + research_app_category_map=research_app_category_map, + ) + + now = datetime.now(timezone.utc) + current_window_event = Event(timestamp=now, data=current_window) + + client.heartbeat( + bucket_id, + current_window_event, + pulsetime=compute_pulsetime(poll_time), + queued=True, + ) + + sleep(poll_time) + + +def transform_window( + current_window, + exclude_title=False, + exclude_titles=None, + research_category_map=None, + research_app_category_map=None, +): + if research_category_map is not None: + return research_transform( + current_window, + research_category_map, + app_category_map=research_app_category_map, + ) + + for pattern in exclude_titles or []: + if pattern.search(current_window["title"]): + current_window["title"] = "excluded" + + if exclude_title: + current_window["title"] = "excluded" + + return current_window diff --git a/aw_watcher_window/printAppStatus.jxa b/aw_watcher_window/printAppStatus.jxa new file mode 100755 index 0000000..757d43c --- /dev/null +++ b/aw_watcher_window/printAppStatus.jxa @@ -0,0 +1,65 @@ +#!/usr/bin/osascript -l JavaScript + +// adapted from: +// https://gist.github.com/EvanLovely/cb01eafb0d61515c835ecd56f6ac199a + +// new to jxa? +// - https://apple-dev.groups.io/g/jxa/wiki/3202 +// - interactive repl: `osascript -il JavaScript` +// - API reference: Script Editor -> File -> Open Dictionary + +var seApp = Application("System Events"); +var oProcess = seApp.processes.whose({frontmost: true})[0]; +var appName = oProcess.displayedName(); + +// as of 05/01/21 incognio & url are not actively used in AW +// variables must be set to `undefined` since this script is re-run via osascript +// and the previously set values will be cached otherwise +var url = undefined, incognito = undefined, title = undefined; + +// it's not possible to get the URL from firefox +// https://stackoverflow.com/questions/17846948/does-firefox-offer-applescript-support-to-get-url-of-windows + +switch(appName) { + case "Safari": + // incognito is not available via safari applescript + url = Application(appName).documents[0].url(); + title = Application(appName).documents[0].name(); + break; + case "Google Chrome": + case "Google Chrome Canary": + case "Chromium": + case "Brave Browser": + const activeWindow = Application(appName).windows[0]; + const activeTab = activeWindow.activeTab(); + + url = activeTab.url(); + title = activeTab.name(); + incognito = activeWindow.mode() === 'incognito'; + break; + case "Firefox": + case "Firefox Developer Edition": + title = Application(appName).windows[0].name(); + break; + default: + mainWindow = oProcess. + windows(). + find(w => w.attributes.byName("AXMain").value() === true) + + // in some cases, the primary window of an application may not be found + // this occurs rarely and seems to be triggered by switching to a different application + if(mainWindow) { + title = mainWindow. + attributes. + byName("AXTitle"). + value() + } +} + +// key names must match expected names in lib.py +JSON.stringify({ + app: appName, + url, + title, + incognito +}); diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py new file mode 100644 index 0000000..756ffe8 --- /dev/null +++ b/aw_watcher_window/research_filter.py @@ -0,0 +1,186 @@ +""" +Research Edition: privacy-sensitive window-title → category transform. + +Replicates the approach used by AW research forks: +- Browser apps: window titles get classified into study categories. + Unmatched titles → 'excluded'. +- Non-browser apps: app name is mapped to a broad study category where + possible, otherwise replaced with 'Excluded'. Supply the mapping in + ``[aw-watcher-window.research_app_category_map]``. Without a map the + previous behaviour is preserved (app name kept, title dropped). + +Enable via config (``research_enabled = true``) and optionally supply a +category map in ``[aw-watcher-window.research_category_map]``. If the map +is empty, browser titles are classified as ``excluded`` and non-browser +behaviour falls back to title-drop. + +macOS note: on macOS the default ``swift`` strategy captures browser URLs via +accessibility APIs and includes them in the window dict. This filter uses those +URLs for more reliable category classification (``classify_title`` prefers URL +over title) and strips them from the output before the event is recorded. +""" + +from typing import Optional + +# Known browser applications (lowercase, for case-insensitive matching) +BROWSER_APPS = frozenset( + { + "chrome", + "google chrome", + "google chrome canary", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "chromium", + "chromium-browser", + "chromium.exe", + "brave browser", + "brave", + "brave-browser", + "arc", + "arc browser", + "firefox", + "firefox developer edition", + "firefox-esr", + "safari", + "edge", + "microsoft edge", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", + "opera", + "vivaldi", + "vivaldi.exe", + "chrome.exe", + "brave.exe", + "firefox.exe", + "msedge.exe", + "opera.exe", + } +) + + +def is_browser(app: str) -> bool: + """Return True if *app* is a known browser (case-insensitive).""" + return app.strip().lower() in BROWSER_APPS + + +def _longest_match(haystack: str, category_map: dict) -> Optional[str]: + """ + Return the category of the *longest* pattern contained in *haystack*. + + Longest-match beats insertion order because study maps routinely contain + both a generic and a specific form of the same host: ``google.com`` → + *Search & Navigation* and ``docs.google.com`` → *Work & Productivity*. + First-match-wins would file every Google Docs/Drive/Calendar/Meet URL as + Search, since ``google.com`` is a substring of all of them. + + Ties (equal-length patterns) resolve to the first one in map order. + """ + best_category: Optional[str] = None + best_len = -1 + for pattern, category in category_map.items(): + pattern_lower = pattern.lower() + if pattern_lower and pattern_lower in haystack and len(pattern_lower) > best_len: + best_category = category + best_len = len(pattern_lower) + return best_category + + +def classify_title(title: str, category_map: dict, url: str = "") -> str: + """ + Classify a browser window into a study category via substring matching. + + Tries *url* first when provided (more reliable than page titles, which can + change mid-load), then falls back to *title*. + *category_map*: ``{substring: category_name}`` — the **longest** matching + substring wins, so a specific host (``docs.google.com``) is not shadowed by + a generic one (``google.com``) that happens to be listed earlier. + Returns the category name, or ``"excluded"`` if nothing matched. + """ + if url: + category = _longest_match(url.lower(), category_map) + if category is not None: + return category + category = _longest_match(title.lower(), category_map) + if category is not None: + return category + return "excluded" + + +def classify_app(app: str, app_category_map: dict) -> str: + """ + Classify a non-browser application into a study category. + + Performs a case-insensitive exact lookup of *app* in *app_category_map* + (``{app_name: category_name}``). Both the lookup key and the configured + keys are normalized to lowercase, so mixed-case map keys (e.g. ``"Microsoft + Outlook"``) match their lowercase application names. Returns the mapped + category, or ``"Excluded"`` when the app is not in the map. + + Note: uses capital-E ``"Excluded"`` to match Matthias's original classifier + convention (``EXCLUDED = "Excluded"``), distinct from the lowercase + ``"excluded"`` used for browser title mismatches. + """ + app_lower = app.strip().lower() + for map_app, category in app_category_map.items(): + if map_app.strip().lower() == app_lower: + return category + return "Excluded" + + +def transform( + window: dict, + category_map: Optional[dict], + app_category_map: Optional[dict] = None, +) -> dict: + """ + Apply Research Edition transforms to a window-data dict. + + *window*: ``{"app": str, "title": str}`` (may also carry *url*, + *incognito* on macOS JXA — URLs are stripped and incognito is preserved). + *category_map*: study-specific URL/title mapping dict, or ``None`` to no-op. + *app_category_map*: optional app-name → category mapping for non-browser + apps. When supplied, non-browser apps are replaced with their mapped + category (or ``"Excluded"`` if unmapped) instead of keeping the raw app name. + + Returns the transformed window dict (never mutates the input). + """ + if category_map is None: + return window + + app = window.get("app", "") + + if is_browser(app): + # Browser: replace title with a study category + title = window.get("title", "") + url = window.get("url", "") + category = classify_title(title, category_map, url=url) + # Don't spread window dict — URLs must not be exposed for privacy + result = {"app": app, "title": category} + # Preserve incognito flag if present (metadata, not a privacy concern) + if "incognito" in window: + result["incognito"] = window["incognito"] + return result + else: + # Non-browser: map app to a study category when a map is provided, + # otherwise keep the raw app name and drop the title. + # An empty map is treated as "not provided" — so research_enabled with + # no configured app map falls back to legacy behaviour instead of + # classifying every non-browser app as "Excluded". + if app_category_map: + app_category = classify_app(app, app_category_map) + # Replace the raw app identity with its category: the app name is + # the sensitive identifier for non-browser apps (e.g. "Microsoft + # Outlook", a niche tool, a company-internal app), so it must not + # be retained. Title/URL are dropped entirely. + result = {"app": app_category} + else: + # Legacy behaviour: title dropped, app name kept as-is. + result = {"app": app} + # Preserve incognito flag if present (metadata, not a privacy concern) + # Note: URL is NOT preserved for non-browser apps — on macOS JXA, + # apps like Mail or file managers can expose sensitive URLs (mailbox://, file://) + if "incognito" in window: + result["incognito"] = window["incognito"] + return result diff --git a/aw_watcher_window/windows.py b/aw_watcher_window/windows.py new file mode 100644 index 0000000..44403a8 --- /dev/null +++ b/aw_watcher_window/windows.py @@ -0,0 +1,89 @@ +import os +import time +from typing import Optional + +import win32api +import win32gui +import win32process +import wmi + + +def get_app_path(hwnd) -> Optional[str]: + """Get application path given hwnd.""" + path = None + + _, pid = win32process.GetWindowThreadProcessId(hwnd) + process = win32api.OpenProcess( + 0x0400, False, pid + ) # PROCESS_QUERY_INFORMATION = 0x0400 + + try: + path = win32process.GetModuleFileNameEx(process, 0) + finally: + win32api.CloseHandle(process) + + return path + + +def get_app_name(hwnd) -> Optional[str]: + """Get application filename given hwnd.""" + path = get_app_path(hwnd) + + if path is None: + return None + + return os.path.basename(path) + + +def get_window_title(hwnd): + return win32gui.GetWindowText(hwnd) + + +def get_active_window_handle(): + hwnd = win32gui.GetForegroundWindow() + return hwnd + + +# WMI-version, used as fallback if win32gui/win32process/win32api fails (such as for "run as admin" processes) + +c = wmi.WMI() + +""" +Much of this derived from: http://stackoverflow.com/a/14973422/965332 +""" + + +def get_app_name_wmi(hwnd) -> Optional[str]: + """Get application filename given hwnd.""" + name = None + _, pid = win32process.GetWindowThreadProcessId(hwnd) + for p in c.query("SELECT Name FROM Win32_Process WHERE ProcessId = %s" % str(pid)): + name = p.Name + break + return name + + +def get_app_path_wmi(hwnd) -> Optional[str]: + """Get application path given hwnd.""" + path = None + + _, pid = win32process.GetWindowThreadProcessId(hwnd) + for p in c.query( + "SELECT ExecutablePath FROM Win32_Process WHERE ProcessId = %s" % str(pid) + ): + path = p.ExecutablePath + break + + return path + + +if __name__ == "__main__": + while True: + hwnd = get_active_window_handle() + print("Title:", get_window_title(hwnd)) + print("App: ", get_app_name(hwnd)) + print("App (wmi): ", get_app_name_wmi(hwnd)) + print("Path: ", get_app_path(hwnd)) + print("Path (wmi): ", get_app_path_wmi(hwnd)) + + time.sleep(1.0) diff --git a/aw_watcher_window/xlib.py b/aw_watcher_window/xlib.py new file mode 100644 index 0000000..a8c36e6 --- /dev/null +++ b/aw_watcher_window/xlib.py @@ -0,0 +1,174 @@ +import logging +from typing import Optional + +import Xlib +import Xlib.display +from Xlib import X +from Xlib.xobject.drawable import Window + +from .exceptions import FatalError + +logger = logging.getLogger(__name__) + +display = Xlib.display.Display() +screen = display.screen() + +NET_WM_NAME = display.intern_atom("_NET_WM_NAME") +UTF8_STRING = display.intern_atom("UTF8_STRING") + + +def _get_current_window_id() -> Optional[int]: + atom = display.get_atom("_NET_ACTIVE_WINDOW") + window_prop = screen.root.get_full_property(atom, X.AnyPropertyType) + + if window_prop is None: + logger.warning("window_prop was None") + return None + + # window_prop may contain more than one value, but it seems that it's always the first we want. + # The second has in my attempts always been 0 or rubbish. + window_id = window_prop.value[0] + return window_id if window_id != 0 else None + + +def _get_window(window_id: int) -> Window: + return display.create_resource_object("window", window_id) + + +def get_current_window() -> Optional[Window]: + """ + Returns the current window, or None if no window is active. + """ + try: + window_id = _get_current_window_id() + if window_id is None: + return None + else: + return _get_window(window_id) + except Xlib.error.ConnectionClosedError: + # when the X server closes the connection, we should exit + # note that stdio is probably closed at this point, so we can't print anything (causes OSError) + try: + logger.warning("X server closed connection, exiting") + except OSError: + pass + raise FatalError() + + +# Things that can lead to unknown cls/name: +# - (cls+name) Empty desktop in xfce (no window focused) +# - (name) Chrome (fixed, didn't support when WM_NAME was UTF8_STRING) + + +def get_window_name(window: Window) -> str: + """After some annoying debugging I resorted to pretty much copying selfspy. + Source: https://github.com/gurgeh/selfspy/blob/8a34597f81000b3a1be12f8cde092a40604e49cf/selfspy/sniff_x.py#L165""" + try: + d = window.get_full_property(NET_WM_NAME, UTF8_STRING) + except Xlib.error.XError as e: + logger.warning( + f"Unable to get window property NET_WM_NAME, got a {type(e).__name__} exception from Xlib" + ) + # I strongly suspect window.get_wm_name() will also fail and we should return "unknown" right away. + # But I don't know, so I pass the thing on, for now. + d = None + if d is None or d.format != 8: + try: + # Fallback. + r = window.get_wm_name() + if r is None: + return "unknown" + elif isinstance(r, str): + return r + else: + logger.warning( + "I don't think this case will ever happen, but not sure so leaving this message here just in case." + ) + return r.decode("latin1") # WM_NAME with type=STRING. + except Xlib.error.BadWindow as e: + # I comment on the log, the number of messages that pop up is very annoying + # Also, it does not give much information about the error + # But I leave it in case someone sees it useful + # logger.warning( + # f"Unable to get window property WM_NAME, got a {type(e).__name__} exception from Xlib" + # ) + return "unknown" + else: + # Fixing utf8 issue on Ubuntu (https://github.com/gurgeh/selfspy/issues/133) + # Thanks to https://github.com/gurgeh/selfspy/issues/133#issuecomment-142943681 + try: + return d.value.decode("utf8") + except UnicodeError: + logger.warning( + f"Failed to decode one or more characters which will be skipped, bytes are: {d.value}" + ) + if isinstance(d.value, bytes): + return d.value.decode("utf8", "ignore") + else: + return d.value.encode("utf8").decode("utf8", "ignore") + + +def get_window_class(window: Window) -> str: + cls = None + + try: + cls = window.get_wm_class() + except Xlib.error.BadWindow: + logger.warning("Unable to get window class, got a BadWindow exception.") + + # TODO: Is this needed? + # nikanar: Indeed, it seems that it is. But it would be interesting to see how often this succeeds, and if it is low, maybe fail earlier. + if not cls: + print("") + logger.warning("Code made an unclear branch") + try: + window = window.query_tree().parent + except Xlib.error.BadWindow: + # I comment on the log, the number of messages that pop up is very annoying + # Also, it does not give much information about the error + # But I leave it in case someone sees it useful + # logger.warning( + # "Unable to get window query_tree().parent, got a BadWindow exception." + # ) + return "unknown" + except Xlib.error.XError as e: + logger.warning( + f"Unable to get window query_tree().parent, got a {type(e).__name__} exception from Xlib" + ) + return "unknown" + if window: + return get_window_class(window) + else: + return "unknown" + + cls = cls[1] + return cls + + +def get_window_pid(window: Window) -> str: + atom = display.get_atom("_NET_WM_PID") + pid_property = window.get_full_property(atom, X.AnyPropertyType) + if pid_property: + pid = pid_property.value[-1] + return pid + else: + # TODO: Needed? + raise Exception("pid_property was None") + + +if __name__ == "__main__": + from time import sleep + + while True: + print("-" * 20) + window = get_current_window() + if window is None: + print("unable to get active window") + name, cls = "unknown", "unknown" + else: + cls = get_window_class(window) + name = get_window_name(window) + print("name:", name) + print("class:", cls) + + sleep(1) diff --git a/aw_watcher_window/xprop.py b/aw_watcher_window/xprop.py new file mode 100644 index 0000000..1c65426 --- /dev/null +++ b/aw_watcher_window/xprop.py @@ -0,0 +1,128 @@ +import sys +import subprocess +import re +import logging +from subprocess import PIPE +from typing import List + +logger = logging.getLogger(__name__) + +# req_version is 3.5 due to usage of subprocess.run +# It would be nice to be able to use 3.4 as well since it's still common as of May 2016 +req_version = (3, 5) +cur_version = sys.version_info + +if not cur_version >= req_version: + logger.error("Your Python version is too old, 3.5 or higher is required.") + exit(1) + + +def xprop_id(window_id) -> str: + cmd = ["xprop"] + cmd.append("-id") + cmd.append(window_id) + p = subprocess.run(cmd, stdout=PIPE) + return str(p.stdout, "utf8") + + +def xprop_root() -> str: + cmd = ["xprop"] + cmd.append("-root") + p = subprocess.run(cmd, stdout=PIPE) + return str(p.stdout, "utf8") + + +def get_active_window_id(): + lines = xprop_root().split("\n") + match="_NET_ACTIVE_WINDOW(WINDOW)" + result = None + for line in lines: + if match in line: + result = line + break + wid = "0x0" + if result: + wids = re.findall("0x[0-9a-f]*", result) + if len(wids) > 0: + wid = wids[0] + return wid + + +def get_window_ids(): + lines = xprop_root().split("\n") + client_list = next(filter(lambda x: "_NET_CLIENT_LIST(" in x, lines)) + window_ids = re.findall("0x[0-9a-f]*", client_list) + return window_ids + + +def _extract_xprop_field(line): + return "".join(line.split("=")[1:]).strip(" \n") + + +def get_xprop_field(fieldname, xprop_output): + return list(map(_extract_xprop_field, re.findall(fieldname + ".*\n", xprop_output))) + + +def get_xprop_field_str(fieldname, xprop_output) -> str: + field = None + try: + field = get_xprop_field(fieldname, xprop_output)[0].strip('"') + except IndexError: + pass + if not field: + field = "unknown" + return field + + +def get_xprop_field_strlist(fieldname, xprop_output) -> List[str]: + return [s.strip('"') for s in get_xprop_field(fieldname, xprop_output)] + + +def get_xprop_field_int(fieldname, xprop_output) -> int: + field = None + try: + field = int(get_xprop_field(fieldname, xprop_output)[0]) + except IndexError: + pass + if not field: + field = -1 + return field + + +def get_xprop_field_class(xprop_output) -> List[str]: + classname: List[str] = [] + try: + classname = [c.strip('", ') for c in get_xprop_field("WM_CLASS", xprop_output)[0].split(',')] + except IndexError: + pass + if not classname: + classname = ["unknown"] + return classname + + +def get_window(wid, active_window=False): + s = xprop_id(wid) + window = { + "id": wid, + "active": active_window, + "name": get_xprop_field_str("WM_NAME", s), + "class": get_xprop_field_class(s), + "desktop": get_xprop_field_int("WM_DESKTOP", s), + "command": get_xprop_field("WM_COMMAND", s), + "role": get_xprop_field_strlist("WM_WINDOW_ROLE", s), + "pid": get_xprop_field_int("WM_PID", s), + } + + return window + + +def get_windows(wids, active_window_id=None): + return [get_window(wid, active_window=(wid == active_window_id)) for wid in wids] + + +if __name__ == "__main__": + from time import sleep + logging.basicConfig(level=logging.INFO) + while True: + sleep(1) + print("Active window id: " + str(get_active_window_id())) diff --git a/misc/aw-watcher-x11.service b/misc/aw-watcher-x11.service new file mode 100644 index 0000000..b2a5cb9 --- /dev/null +++ b/misc/aw-watcher-x11.service @@ -0,0 +1,14 @@ +[Unit] +Description=X11 window watcher for ActivityWatch +Documentation= +After=aw-server.service + +[Service] +Type=simple +ExecStart=/usr/bin/env aw-watcher-x11 +Environment="DISPLAY=:0" +Restart=always +KillSignal=SIGINT + +[Install] +WantedBy=default.target diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..472ed12 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1095 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "altgraph" +version = "0.17.4" +description = "Python graph (network) package" +optional = false +python-versions = "*" +files = [ + {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"}, + {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"}, +] + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "aw-client" +version = "0.5.14" +description = "Client library for ActivityWatch" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "aw_client-0.5.14-py3-none-any.whl", hash = "sha256:18a72a4c6a662f01028f97aa50bd1eb8d00d7b531a544e9c25fc1bc86380bfa9"}, + {file = "aw_client-0.5.14.tar.gz", hash = "sha256:94cf9614029ee416c44590126f2975512d7b37869bf8b8de62c5e03d68996859"}, +] + +[package.dependencies] +aw-core = ">=0.5.16,<0.6.0" +click = ">=8.0,<9.0" +persist-queue = "*" +requests = "*" +tabulate = "*" +typing-extensions = "*" + +[[package]] +name = "aw-core" +version = "0.5.17" +description = "Core library for ActivityWatch" +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "aw_core-0.5.17-py3-none-any.whl", hash = "sha256:8c3dae7fddd23984f7711e4ff7933ba45ce25e5e416fdde4a9f1677e95c64feb"}, + {file = "aw_core-0.5.17.tar.gz", hash = "sha256:f8ac5418d6a1de2b868bc75447d4d495aebc577a77eea56ba11dc2bde3771e27"}, +] + +[package.dependencies] +deprecation = "*" +iso8601 = "*" +jsonschema = ">=4.3,<5.0" +peewee = "==3.*" +platformdirs = "3.10" +rfc3339-validator = ">=0.1.4,<0.2.0" +strict-rfc3339 = ">=0.7,<0.8" +timeslot = "*" +tomlkit = "*" + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +optional = false +python-versions = "*" +files = [ + {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, + {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, +] + +[package.dependencies] +packaging = "*" + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.5.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, + {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "importlib-resources" +version = "6.4.5" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, + {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +description = "Simple module to parse ISO 8601 dates" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, + {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} +jsonschema-specifications = ">=2023.03.6" +pkgutil-resolve-name = {version = ">=1.3.10", markers = "python_version < \"3.9\""} +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2023.12.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c"}, + {file = "jsonschema_specifications-2023.12.1.tar.gz", hash = "sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc"}, +] + +[package.dependencies] +importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} +referencing = ">=0.31.0" + +[[package]] +name = "macholib" +version = "1.16.3" +description = "Mach-O header analysis and editing" +optional = false +python-versions = "*" +files = [ + {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"}, + {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"}, +] + +[package.dependencies] +altgraph = ">=0.17" + +[[package]] +name = "mypy" +version = "1.12.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed"}, + {file = "mypy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469"}, + {file = "mypy-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e"}, + {file = "mypy-1.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a"}, + {file = "mypy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff"}, + {file = "mypy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7"}, + {file = "mypy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57"}, + {file = "mypy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309"}, + {file = "mypy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f"}, + {file = "mypy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9"}, + {file = "mypy-1.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164"}, + {file = "mypy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475"}, + {file = "mypy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9"}, + {file = "mypy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642"}, + {file = "mypy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601"}, + {file = "mypy-1.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521"}, + {file = "mypy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893"}, + {file = "mypy-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721"}, + {file = "mypy-1.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3"}, + {file = "mypy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b"}, + {file = "mypy-1.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f"}, + {file = "mypy-1.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e"}, + {file = "mypy-1.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7"}, + {file = "mypy-1.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b"}, + {file = "mypy-1.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0"}, + {file = "mypy-1.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8"}, + {file = "mypy-1.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0"}, + {file = "mypy-1.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1"}, + {file = "mypy-1.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e"}, + {file = "mypy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa"}, + {file = "mypy-1.12.0-py3-none-any.whl", hash = "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266"}, + {file = "mypy-1.12.0.tar.gz", hash = "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "peewee" +version = "3.17.7" +description = "a little orm" +optional = false +python-versions = "*" +files = [ + {file = "peewee-3.17.7.tar.gz", hash = "sha256:6aefc700bd530fc6ac23fa19c9c5b47041751d92985b799169c8e318e97eabaa"}, +] + +[[package]] +name = "pefile" +version = "2023.2.7" +description = "Python PE parsing module" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, + {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, +] + +[[package]] +name = "persist-queue" +version = "1.0.0" +description = "A thread-safe disk based persistent queue in Python." +optional = false +python-versions = "*" +files = [ + {file = "persist-queue-1.0.0.tar.gz", hash = "sha256:3ffb746902d3023fd09eb46897609fdee6c77b1641f19e2fc8d98d744bdfc845"}, + {file = "persist_queue-1.0.0-py3-none-any.whl", hash = "sha256:81bb20030b480fcacecc3abe6261480c818246f4d838fdf0217e36c2552a5f3a"}, +] + +[package.extras] +extra = ["DBUtils (<3.0.0)", "PyMySQL", "cbor2 (>=5.2.0)", "msgpack (>=0.5.6)"] + +[[package]] +name = "pkgutil-resolve-name" +version = "1.3.10" +description = "Resolve a name to an object." +optional = false +python-versions = ">=3.6" +files = [ + {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, + {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, +] + +[[package]] +name = "platformdirs" +version = "3.10.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, + {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pyinstaller" +version = "6.11.0" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +optional = false +python-versions = "<3.14,>=3.8" +files = [ + {file = "pyinstaller-6.11.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:6fd68a3c1207635c49326c54881b89d5c3bd9ba061bbc9daa58c0902db1be39e"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:eddd53f231e51adc65088eac4f40057ca803a990239828d4a9229407fb866239"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_i686.whl", hash = "sha256:e6d229009e815542833fe00332b589aa6984a06f794dc16f2ce1acab1c567590"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:7d2cd2ebdcd6860f8a4abe2977264a7b6d260a7147047008971c7cfc66a656a4"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:d9ec6d4398b4eebc1d4c00437716264ba8406bc2746f594e253070a82378a584"}, + {file = "pyinstaller-6.11.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:04f71828aa9531ab18c9656985c1f09b83d10332c73a1f4a113a48b491906955"}, + {file = "pyinstaller-6.11.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:a843d470768d68b05684ccf4860c45b2eb13727f41667c0b2cd8f57ae231bd18"}, + {file = "pyinstaller-6.11.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:963dedc1f37144a4385f58f7f65f1c69c004a67faae522a2085b5ddb230c908b"}, + {file = "pyinstaller-6.11.0-py3-none-win32.whl", hash = "sha256:c71024c8a19c7b221b9152b2baff4c3ba849cada68dcdd34382ba09f0107451f"}, + {file = "pyinstaller-6.11.0-py3-none-win_amd64.whl", hash = "sha256:0e229610c22b96d741d905706f9496af472c1a9216a118988f393c98ecc3f51f"}, + {file = "pyinstaller-6.11.0-py3-none-win_arm64.whl", hash = "sha256:a5f716bb507517912fda39d109dead91fc0dd2e7b2859562522b63c61aa21676"}, + {file = "pyinstaller-6.11.0.tar.gz", hash = "sha256:cb4d433a3db30d9d17cf5f2cf7bb4df80a788d493c1d67dd822dc5791d9864af"}, +] + +[package.dependencies] +altgraph = "*" +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +packaging = ">=22.0" +pefile = {version = ">=2022.5.30,<2024.8.26 || >2024.8.26", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2024.8" +pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} +setuptools = ">=42.0.0" + +[package.extras] +completion = ["argcomplete"] +hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2024.9" +description = "Community maintained hooks for PyInstaller" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyinstaller_hooks_contrib-2024.9-py3-none-any.whl", hash = "sha256:1ddf9ba21d586afa84e505bb5c65fca10b22500bf3fdb89ee2965b99da53b891"}, + {file = "pyinstaller_hooks_contrib-2024.9.tar.gz", hash = "sha256:4793869f370d1dc4806c101efd2890e3c3e703467d8d27bb5a3db005ebfb008d"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +packaging = ">=22.0" +setuptools = ">=42.0.0" + +[[package]] +name = "pyobjc-core" +version = "10.3.1" +description = "Python<->ObjC Interoperability Module" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyobjc_core-10.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ea46d2cda17921e417085ac6286d43ae448113158afcf39e0abe484c58fb3d78"}, + {file = "pyobjc_core-10.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:899d3c84d2933d292c808f385dc881a140cf08632907845043a333a9d7c899f9"}, + {file = "pyobjc_core-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6ff5823d13d0a534cdc17fa4ad47cf5bee4846ce0fd27fc40012e12b46db571b"}, + {file = "pyobjc_core-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2581e8e68885bcb0e11ec619e81ef28e08ee3fac4de20d8cc83bc5af5bcf4a90"}, + {file = "pyobjc_core-10.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ea98d4c2ec39ca29e62e0327db21418696161fb138ee6278daf2acbedf7ce504"}, + {file = "pyobjc_core-10.3.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:4c179c26ee2123d0aabffb9dbc60324b62b6f8614fb2c2328b09386ef59ef6d8"}, + {file = "pyobjc_core-10.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cb901fce65c9be420c40d8a6ee6fff5ff27c6945f44fd7191989b982baa66dea"}, + {file = "pyobjc_core-10.3.1.tar.gz", hash = "sha256:b204a80ccc070f9ab3f8af423a3a25a6fd787e228508d00c4c30f8ac538ba720"}, +] + +[[package]] +name = "pyobjc-framework-applicationservices" +version = "10.3.1" +description = "Wrappers for the framework ApplicationServices on macOS" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyobjc_framework_ApplicationServices-10.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b694260d423c470cb90c3a7009cfde93e332ea6fb4b9b9526ad3acbd33460e3d"}, + {file = "pyobjc_framework_ApplicationServices-10.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d886ba1f65df47b77ff7546f3fc9bc7d08cfb6b3c04433b719f6b0689a2c0d1f"}, + {file = "pyobjc_framework_ApplicationServices-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:be157f2c3ffb254064ef38249670af8cada5e519a714d2aa5da3740934d89bc8"}, + {file = "pyobjc_framework_ApplicationServices-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:57737f41731661e4a3b78793ec9173f61242a32fa560c3e4e58484465d049c32"}, + {file = "pyobjc_framework_ApplicationServices-10.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c429eca69ee675e781e4e55f79e939196b47f02560ad865b1ba9ac753b90bd77"}, + {file = "pyobjc_framework_ApplicationServices-10.3.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:4f1814a17041a20adca454044080b52e39a4ebc567ad2c6a48866dd4beaa192a"}, + {file = "pyobjc_framework_ApplicationServices-10.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1252f1137f83eb2c6b9968d8c591363e8859dd2484bc9441d8f365bcfb43a0e4"}, + {file = "pyobjc_framework_applicationservices-10.3.1.tar.gz", hash = "sha256:f27cb64aa4d129ce671fd42638c985eb2a56d544214a95fe3214a007eacc4790"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.1" +pyobjc-framework-Cocoa = ">=10.3.1" +pyobjc-framework-CoreText = ">=10.3.1" +pyobjc-framework-Quartz = ">=10.3.1" + +[[package]] +name = "pyobjc-framework-cocoa" +version = "10.3.1" +description = "Wrappers for the Cocoa frameworks on macOS" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyobjc_framework_Cocoa-10.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4cb4f8491ab4d9b59f5187e42383f819f7a46306a4fa25b84f126776305291d1"}, + {file = "pyobjc_framework_Cocoa-10.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5f31021f4f8fdf873b57a97ee1f3c1620dbe285e0b4eaed73dd0005eb72fd773"}, + {file = "pyobjc_framework_Cocoa-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11b4e0bad4bbb44a4edda128612f03cdeab38644bbf174de0c13129715497296"}, + {file = "pyobjc_framework_Cocoa-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:de5e62e5ccf2871a94acf3bf79646b20ea893cc9db78afa8d1fe1b0d0f7cbdb0"}, + {file = "pyobjc_framework_Cocoa-10.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c5af24610ab639bd1f521ce4500484b40787f898f691b7a23da3339e6bc8b90"}, + {file = "pyobjc_framework_Cocoa-10.3.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a7151186bb7805deea434fae9a4423335e6371d105f29e73cc2036c6779a9dbc"}, + {file = "pyobjc_framework_Cocoa-10.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:743d2a1ac08027fd09eab65814c79002a1d0421d7c0074ffd1217b6560889744"}, + {file = "pyobjc_framework_cocoa-10.3.1.tar.gz", hash = "sha256:1cf20714daaa986b488fb62d69713049f635c9d41a60c8da97d835710445281a"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.1" + +[[package]] +name = "pyobjc-framework-coretext" +version = "10.3.1" +description = "Wrappers for the framework CoreText on macOS" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyobjc_framework_CoreText-10.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd6123cfccc38e32be884d1a13fb62bd636ecb192b9e8ae2b8011c977dec229e"}, + {file = "pyobjc_framework_CoreText-10.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:834142a14235bd80edaef8d3a28d1e203ed3c988810a9b78005df7c561390288"}, + {file = "pyobjc_framework_CoreText-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ae6c09d29eeaf30a67aa70e08a465b1f1e47d12e22b3a34ae8bc8fdb7e2e7342"}, + {file = "pyobjc_framework_CoreText-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:51ca95df1db9401366f11a7467f64be57f9a0630d31c357237d4062df0216938"}, + {file = "pyobjc_framework_CoreText-10.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b75bdc267945b3f33c937c108d79405baf9d7c4cd530f922e5df243082a5031"}, + {file = "pyobjc_framework_CoreText-10.3.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:029b24c338f58fc32a004256d8559507e4f366dfe4eb09d3144273d536012d90"}, + {file = "pyobjc_framework_CoreText-10.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:418a55047dbff999fcd2b78cca167c4105587020b6c51567cfa28993bbfdc8ed"}, + {file = "pyobjc_framework_coretext-10.3.1.tar.gz", hash = "sha256:b8fa2d5078ed774431ae64ba886156e319aec0b8c6cc23dabfd86778265b416f"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.1" +pyobjc-framework-Cocoa = ">=10.3.1" +pyobjc-framework-Quartz = ">=10.3.1" + +[[package]] +name = "pyobjc-framework-osakit" +version = "10.3.1" +description = "Wrappers for the framework OSAKit on macOS" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyobjc_framework_OSAKit-10.3.1-py2.py3-none-any.whl", hash = "sha256:aaa60e6f455febe45f9be6487c59f11450de81bd7f49a6e4db576a38bcaf1c68"}, + {file = "pyobjc_framework_osakit-10.3.1.tar.gz", hash = "sha256:0af326b831fa29fca11ffe2b641807ad3c233be9eb403e62328fa784528da4ab"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.1" +pyobjc-framework-Cocoa = ">=10.3.1" + +[[package]] +name = "pyobjc-framework-quartz" +version = "10.3.1" +description = "Wrappers for the Quartz frameworks on macOS" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyobjc_framework_Quartz-10.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5ef4fd315ed2bc42ef77fdeb2bae28a88ec986bd7b8079a87ba3b3475348f96e"}, + {file = "pyobjc_framework_Quartz-10.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:96578d4a3e70164efe44ad7dc320ecd4e211758ffcde5dcd694de1bbdfe090a4"}, + {file = "pyobjc_framework_Quartz-10.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ca35f92486869a41847a1703bb176aab8a53dbfd8e678d1f4d68d8e6e1581c71"}, + {file = "pyobjc_framework_Quartz-10.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:00a0933267e3a46ea4afcc35d117b2efb920f06de797fa66279c52e7057e3590"}, + {file = "pyobjc_framework_Quartz-10.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a161bedb4c5257a02ad56a910cd7eefb28bdb0ea78607df0d70ed4efe4ea54c1"}, + {file = "pyobjc_framework_Quartz-10.3.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d7a8028e117a94923a511944bfa9daf9744e212f06cf89010c60934a479863a5"}, + {file = "pyobjc_framework_Quartz-10.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:de00c983b3267eb26fa42c6ed9f15e2bf006bde8afa7fe2b390646aa21a5d6fc"}, + {file = "pyobjc_framework_quartz-10.3.1.tar.gz", hash = "sha256:b6d7e346d735c9a7f147cd78e6da79eeae416a0b7d3874644c83a23786c6f886"}, +] + +[package.dependencies] +pyobjc-core = ">=10.3.1" +pyobjc-framework-Cocoa = ">=10.3.1" + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "python-xlib" +version = "0.31" +description = "Python X Library" +optional = false +python-versions = "*" +files = [ + {file = "python-xlib-0.31.tar.gz", hash = "sha256:74d83a081f532bc07f6d7afcd6416ec38403d68f68b9b9dc9e1f28fbf2d799e9"}, + {file = "python_xlib-0.31-py2.py3-none-any.whl", hash = "sha256:1ec6ce0de73d9e6592ead666779a5732b384e5b8fb1f1886bd0a81cafa477759"}, +] + +[package.dependencies] +six = ">=1.10.0" + +[[package]] +name = "pywin32" +version = "306" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"}, + {file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"}, + {file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"}, + {file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"}, + {file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"}, + {file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"}, + {file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"}, + {file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"}, + {file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"}, + {file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"}, + {file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"}, + {file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"}, + {file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"}, + {file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"}, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, +] + +[[package]] +name = "referencing" +version = "0.35.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, + {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "rpds-py" +version = "0.20.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "rpds_py-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3ad0fda1635f8439cde85c700f964b23ed5fc2d28016b32b9ee5fe30da5c84e2"}, + {file = "rpds_py-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bb4a0d90fdb03437c109a17eade42dfbf6190408f29b2744114d11586611d6f"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6377e647bbfd0a0b159fe557f2c6c602c159fc752fa316572f012fc0bf67150"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb851b7df9dda52dc1415ebee12362047ce771fc36914586b2e9fcbd7d293b3e"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e0f80b739e5a8f54837be5d5c924483996b603d5502bfff79bf33da06164ee2"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a8c94dad2e45324fc74dce25e1645d4d14df9a4e54a30fa0ae8bad9a63928e3"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8e604fe73ba048c06085beaf51147eaec7df856824bfe7b98657cf436623daf"}, + {file = "rpds_py-0.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df3de6b7726b52966edf29663e57306b23ef775faf0ac01a3e9f4012a24a4140"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf258ede5bc22a45c8e726b29835b9303c285ab46fc7c3a4cc770736b5304c9f"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:55fea87029cded5df854ca7e192ec7bdb7ecd1d9a3f63d5c4eb09148acf4a7ce"}, + {file = "rpds_py-0.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae94bd0b2f02c28e199e9bc51485d0c5601f58780636185660f86bf80c89af94"}, + {file = "rpds_py-0.20.0-cp310-none-win32.whl", hash = "sha256:28527c685f237c05445efec62426d285e47a58fb05ba0090a4340b73ecda6dee"}, + {file = "rpds_py-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:238a2d5b1cad28cdc6ed15faf93a998336eb041c4e440dd7f902528b8891b399"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac2f4f7a98934c2ed6505aead07b979e6f999389f16b714448fb39bbaa86a489"}, + {file = "rpds_py-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:220002c1b846db9afd83371d08d239fdc865e8f8c5795bbaec20916a76db3318"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d7919548df3f25374a1f5d01fbcd38dacab338ef5f33e044744b5c36729c8db"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:758406267907b3781beee0f0edfe4a179fbd97c0be2e9b1154d7f0a1279cf8e5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d61339e9f84a3f0767b1995adfb171a0d00a1185192718a17af6e124728e0f5"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1259c7b3705ac0a0bd38197565a5d603218591d3f6cee6e614e380b6ba61c6f6"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c1dc0f53856b9cc9a0ccca0a7cc61d3d20a7088201c0937f3f4048c1718a209"}, + {file = "rpds_py-0.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7e60cb630f674a31f0368ed32b2a6b4331b8350d67de53c0359992444b116dd3"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbe982f38565bb50cb7fb061ebf762c2f254ca3d8c20d4006878766e84266272"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:514b3293b64187172bc77c8fb0cdae26981618021053b30d8371c3a902d4d5ad"}, + {file = "rpds_py-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0a26ffe9d4dd35e4dfdd1e71f46401cff0181c75ac174711ccff0459135fa58"}, + {file = "rpds_py-0.20.0-cp311-none-win32.whl", hash = "sha256:89c19a494bf3ad08c1da49445cc5d13d8fefc265f48ee7e7556839acdacf69d0"}, + {file = "rpds_py-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:c638144ce971df84650d3ed0096e2ae7af8e62ecbbb7b201c8935c370df00a2c"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a84ab91cbe7aab97f7446652d0ed37d35b68a465aeef8fc41932a9d7eee2c1a6"}, + {file = "rpds_py-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56e27147a5a4c2c21633ff8475d185734c0e4befd1c989b5b95a5d0db699b21b"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2580b0c34583b85efec8c5c5ec9edf2dfe817330cc882ee972ae650e7b5ef739"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80d4a7900cf6b66bb9cee5c352b2d708e29e5a37fe9bf784fa97fc11504bf6c"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50eccbf054e62a7b2209b28dc7a22d6254860209d6753e6b78cfaeb0075d7bee"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49a8063ea4296b3a7e81a5dfb8f7b2d73f0b1c20c2af401fb0cdf22e14711a96"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea438162a9fcbee3ecf36c23e6c68237479f89f962f82dae83dc15feeceb37e4"}, + {file = "rpds_py-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d7585c463087bddcfa74c2ba267339f14f2515158ac4db30b1f9cbdb62c8ef"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4c7d1a051eeb39f5c9547e82ea27cbcc28338482242e3e0b7768033cb083821"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4df1e3b3bec320790f699890d41c59d250f6beda159ea3c44c3f5bac1976940"}, + {file = "rpds_py-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2cf126d33a91ee6eedc7f3197b53e87a2acdac63602c0f03a02dd69e4b138174"}, + {file = "rpds_py-0.20.0-cp312-none-win32.whl", hash = "sha256:8bc7690f7caee50b04a79bf017a8d020c1f48c2a1077ffe172abec59870f1139"}, + {file = "rpds_py-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:0e13e6952ef264c40587d510ad676a988df19adea20444c2b295e536457bc585"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa9a0521aeca7d4941499a73ad7d4f8ffa3d1affc50b9ea11d992cd7eff18a29"}, + {file = "rpds_py-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1f1d51eccb7e6c32ae89243cb352389228ea62f89cd80823ea7dd1b98e0b91"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a86a9b96070674fc88b6f9f71a97d2c1d3e5165574615d1f9168ecba4cecb24"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c8ef2ebf76df43f5750b46851ed1cdf8f109d7787ca40035fe19fbdc1acc5a7"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b25f024b421d5859d156750ea9a65651793d51b76a2e9238c05c9d5f203a9"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57eb94a8c16ab08fef6404301c38318e2c5a32216bf5de453e2714c964c125c8"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1940dae14e715e2e02dfd5b0f64a52e8374a517a1e531ad9412319dc3ac7879"}, + {file = "rpds_py-0.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d20277fd62e1b992a50c43f13fbe13277a31f8c9f70d59759c88f644d66c619f"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:06db23d43f26478303e954c34c75182356ca9aa7797d22c5345b16871ab9c45c"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2a5db5397d82fa847e4c624b0c98fe59d2d9b7cf0ce6de09e4d2e80f8f5b3f2"}, + {file = "rpds_py-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a35df9f5548fd79cb2f52d27182108c3e6641a4feb0f39067911bf2adaa3e57"}, + {file = "rpds_py-0.20.0-cp313-none-win32.whl", hash = "sha256:fd2d84f40633bc475ef2d5490b9c19543fbf18596dcb1b291e3a12ea5d722f7a"}, + {file = "rpds_py-0.20.0-cp313-none-win_amd64.whl", hash = "sha256:9bc2d153989e3216b0559251b0c260cfd168ec78b1fac33dd485750a228db5a2"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f2fbf7db2012d4876fb0d66b5b9ba6591197b0f165db8d99371d976546472a24"}, + {file = "rpds_py-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1e5f3cd7397c8f86c8cc72d5a791071431c108edd79872cdd96e00abd8497d29"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce9845054c13696f7af7f2b353e6b4f676dab1b4b215d7fe5e05c6f8bb06f965"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c3e130fd0ec56cb76eb49ef52faead8ff09d13f4527e9b0c400307ff72b408e1"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b16aa0107ecb512b568244ef461f27697164d9a68d8b35090e9b0c1c8b27752"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa7f429242aae2947246587d2964fad750b79e8c233a2367f71b554e9447949c"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af0fc424a5842a11e28956e69395fbbeab2c97c42253169d87e90aac2886d751"}, + {file = "rpds_py-0.20.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b8c00a3b1e70c1d3891f0db1b05292747f0dbcfb49c43f9244d04c70fbc40eb8"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:40ce74fc86ee4645d0a225498d091d8bc61f39b709ebef8204cb8b5a464d3c0e"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:4fe84294c7019456e56d93e8ababdad5a329cd25975be749c3f5f558abb48253"}, + {file = "rpds_py-0.20.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:338ca4539aad4ce70a656e5187a3a31c5204f261aef9f6ab50e50bcdffaf050a"}, + {file = "rpds_py-0.20.0-cp38-none-win32.whl", hash = "sha256:54b43a2b07db18314669092bb2de584524d1ef414588780261e31e85846c26a5"}, + {file = "rpds_py-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:a1862d2d7ce1674cffa6d186d53ca95c6e17ed2b06b3f4c476173565c862d232"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3fde368e9140312b6e8b6c09fb9f8c8c2f00999d1823403ae90cc00480221b22"}, + {file = "rpds_py-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9824fb430c9cf9af743cf7aaf6707bf14323fb51ee74425c380f4c846ea70789"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11ef6ce74616342888b69878d45e9f779b95d4bd48b382a229fe624a409b72c5"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c52d3f2f82b763a24ef52f5d24358553e8403ce05f893b5347098014f2d9eff2"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d35cef91e59ebbeaa45214861874bc6f19eb35de96db73e467a8358d701a96c"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d72278a30111e5b5525c1dd96120d9e958464316f55adb030433ea905866f4de"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4c29cbbba378759ac5786730d1c3cb4ec6f8ababf5c42a9ce303dc4b3d08cda"}, + {file = "rpds_py-0.20.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6632f2d04f15d1bd6fe0eedd3b86d9061b836ddca4c03d5cf5c7e9e6b7c14580"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d0b67d87bb45ed1cd020e8fbf2307d449b68abc45402fe1a4ac9e46c3c8b192b"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec31a99ca63bf3cd7f1a5ac9fe95c5e2d060d3c768a09bc1d16e235840861420"}, + {file = "rpds_py-0.20.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e6c9976e38f4d8c4a63bd8a8edac5307dffd3ee7e6026d97f3cc3a2dc02a0b"}, + {file = "rpds_py-0.20.0-cp39-none-win32.whl", hash = "sha256:569b3ea770c2717b730b61998b6c54996adee3cef69fc28d444f3e7920313cf7"}, + {file = "rpds_py-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:e6900ecdd50ce0facf703f7a00df12374b74bbc8ad9fe0f6559947fb20f82364"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:617c7357272c67696fd052811e352ac54ed1d9b49ab370261a80d3b6ce385045"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9426133526f69fcaba6e42146b4e12d6bc6c839b8b555097020e2b78ce908dcc"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deb62214c42a261cb3eb04d474f7155279c1a8a8c30ac89b7dcb1721d92c3c02"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcaeb7b57f1a1e071ebd748984359fef83ecb026325b9d4ca847c95bc7311c92"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d454b8749b4bd70dd0a79f428731ee263fa6995f83ccb8bada706e8d1d3ff89d"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d807dc2051abe041b6649681dce568f8e10668e3c1c6543ebae58f2d7e617855"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c20f0ddeb6e29126d45f89206b8291352b8c5b44384e78a6499d68b52ae511"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7f19250ceef892adf27f0399b9e5afad019288e9be756d6919cb58892129f51"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4f1ed4749a08379555cebf4650453f14452eaa9c43d0a95c49db50c18b7da075"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:dcedf0b42bcb4cfff4101d7771a10532415a6106062f005ab97d1d0ab5681c60"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:39ed0d010457a78f54090fafb5d108501b5aa5604cc22408fc1c0c77eac14344"}, + {file = "rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f918a1a130a6dfe1d7fe0f105064141342e7dd1611f2e6a21cd2f5c8cb1cfb3e"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f60012a73aa396be721558caa3a6fd49b3dd0033d1675c6d59c4502e870fcf0c"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d2b1ad682a3dfda2a4e8ad8572f3100f95fad98cb99faf37ff0ddfe9cbf9d03"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:614fdafe9f5f19c63ea02817fa4861c606a59a604a77c8cdef5aa01d28b97921"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa518bcd7600c584bf42e6617ee8132869e877db2f76bcdc281ec6a4113a53ab"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0475242f447cc6cb8a9dd486d68b2ef7fbee84427124c232bff5f63b1fe11e5"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f90a4cd061914a60bd51c68bcb4357086991bd0bb93d8aa66a6da7701370708f"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:def7400461c3a3f26e49078302e1c1b38f6752342c77e3cf72ce91ca69fb1bc1"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:65794e4048ee837494aea3c21a28ad5fc080994dfba5b036cf84de37f7ad5074"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:faefcc78f53a88f3076b7f8be0a8f8d35133a3ecf7f3770895c25f8813460f08"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5b4f105deeffa28bbcdff6c49b34e74903139afa690e35d2d9e3c2c2fba18cec"}, + {file = "rpds_py-0.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdfc3a892927458d98f3d55428ae46b921d1f7543b89382fdb483f5640daaec8"}, + {file = "rpds_py-0.20.0.tar.gz", hash = "sha256:d72a210824facfdaf8768cf2d7ca25a042c30320b3020de2fa04640920d4e121"}, +] + +[[package]] +name = "setuptools" +version = "75.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, + {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] +core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "strict-rfc3339" +version = "0.7" +description = "Strict, simple, lightweight RFC3339 functions" +optional = false +python-versions = "*" +files = [ + {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "timeslot" +version = "0.1.2" +description = "Data type for representing time slots with a start and end." +optional = false +python-versions = ">=3.6,<4.0" +files = [ + {file = "timeslot-0.1.2-py3-none-any.whl", hash = "sha256:2f8efaec7b0a4c1e56a92ec05533219332dd9d8b577539077664c233996911b5"}, + {file = "timeslot-0.1.2.tar.gz", hash = "sha256:a2ac998657e3f3b9ca928757b4906add2c05390c5fc14ed792bb9028d08547b1"}, +] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "wmi" +version = "1.5.1" +description = "Windows Management Instrumentation" +optional = false +python-versions = "*" +files = [ + {file = "WMI-1.5.1-py2.py3-none-any.whl", hash = "sha256:1d6b085e5c445141c475476000b661f60fff1aaa19f76bf82b7abb92e0ff4942"}, + {file = "WMI-1.5.1.tar.gz", hash = "sha256:b6a6be5711b1b6c8d55bda7a8befd75c48c12b770b9d227d31c1737dbf0d40a6"}, +] + +[package.dependencies] +pywin32 = "*" + +[package.extras] +all = ["pytest", "sphinx", "twine", "wheel"] +dev = ["pytest", "sphinx", "twine", "wheel"] +docs = ["sphinx"] +package = ["twine", "wheel"] +tests = ["pytest"] + +[[package]] +name = "zipp" +version = "3.20.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, + {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.8" +content-hash = "1fa11eec7bc2b926894e8c9fd040b95f9a6624c354d41cd446edc699a5754548" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2547a74 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[tool.poetry] +name = "aw-watcher-window" +version = "0.2.0" +description = "Window watcher for ActivityWatch" +authors = ["Erik Bjäreholt "] +license = "MPL-2.0" + +[tool.poetry.scripts] +aw-watcher-window = "aw_watcher_window:main" + +[tool.poetry.dependencies] +python = "^3.8" +aw-client = "^0.5.14" +pywin32 = {version = "306", platform = "win32"} +wmi = {version = "*", platform = "win32"} +pyobjc-framework-ApplicationServices = { version = "*", platform="darwin"} +pyobjc-framework-CoreText = {version = "*", platform="darwin"} +pyobjc-framework-OSAKit = {version = "*", platform="darwin"} + +# locked due to https://github.com/python-xlib/python-xlib/pull/242 leading to 100% CPU stalls +# see: https://github.com/ActivityWatch/aw-watcher-window/issues/89 +# also locked in aw-watcher-afk +python-xlib = {version = "0.31", platform = "linux"} + +[tool.poetry.dev-dependencies] +pytest = "*" +mypy = "*" +macholib = {version = "*", platform = "darwin"} # Needed for pyinstaller +pyinstaller = {version = "*", python = "^3.8,<3.14"} + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..62ab678 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,176 @@ +import os +import pathlib +import re +import sys + +import aw_core.dirs +import pytest +import tomlkit + +from aw_watcher_window import config as config_module + +# The Research Edition release build (release.yml) patches config.py with +# scripts/patch_research_edition_config.py before running `make test`, which +# flips research_enabled's default to true. The guards below assert the +# pristine (non-research) defaults and must not fire on that build. +RESEARCH_BUILD = os.environ.get("AW_RESEARCH_EDITION") == "true" + + +def _patch_config_dir(monkeypatch, tmp_path): + """Point aw_core's config dir at tmp_path on all platforms. + + XDG_CONFIG_HOME is Linux-only; appdirs ignores it on macOS/Windows. + Patching get_config_dir directly is cross-platform. + """ + def _mock(module_name=None): + base = tmp_path / "activitywatch" + result = base / module_name if module_name else base + result.mkdir(parents=True, exist_ok=True) + return str(result) + monkeypatch.setattr(aw_core.dirs, "get_config_dir", _mock) + + +def test_first_run_config_has_no_research_keys(tmp_path, monkeypatch): + """Research/dev options must never be authored into a fresh user's config. + + load_config_toml() writes the default_config template to disk on first run. + It comments out keys but leaves table headers uncommented, so a research + table in the template lands verbatim in every new user's config file. + Verified by symptom: run against an empty config dir and read what was written. + """ + _patch_config_dir(monkeypatch, tmp_path) + + config_module.load_config() + + written = next(tmp_path.rglob("aw-watcher-window.toml")).read_text() + assert "research" not in written + + # The file must still be valid TOML, and parse to nothing but comments. + assert dict(tomlkit.parse(written)["aw-watcher-window"]) == {} + + +def test_research_options_are_read_when_user_sets_them(tmp_path, monkeypatch): + """Absent from the template, but honoured when a Research Edition user opts in.""" + _patch_config_dir(monkeypatch, tmp_path) + config_path = tmp_path / "activitywatch" / "aw-watcher-window" + config_path.mkdir(parents=True) + (config_path / "aw-watcher-window.toml").write_text( + "[aw-watcher-window]\n" + "research_enabled = true\n" + "\n" + "[aw-watcher-window.research_category_map]\n" + 'youtube = "Entertainment"\n' + "\n" + "[aw-watcher-window.research_app_category_map]\n" + '"Microsoft Outlook" = "Communication"\n' + ) + monkeypatch.setattr(sys, "argv", ["aw-watcher-window"]) + + args = config_module.parse_args() + + assert args.research_enabled is True + assert args.research_category_map == {"youtube": "Entertainment"} + assert args.research_app_category_map == {"Microsoft Outlook": "Communication"} + + +@pytest.mark.skipif( + RESEARCH_BUILD, + reason="research edition build patches this default on purpose (release.yml)", +) +def test_research_edition_sed_target_is_intact(): + """The Research Edition release build patches this file with sed. + + ActivityWatch/activitywatch .github/workflows/release.yml runs + ``sed -i 's/^research_enabled = false$/research_enabled = true/'`` against + this module. That target lives in another repo, so pin it here — otherwise + reformatting research_defaults silently breaks the Research Edition build. + """ + # Check the source FILE, not the evaluated string. sed anchors on ^...$ in + # the file, so an indented ` research_enabled = false` inside the literal + # still strips to the same value — the string-level check would stay green + # while the release build broke. + source = pathlib.Path(config_module.__file__).read_text() + assert re.search( + r"^research_enabled = false$", source, re.MULTILINE + ), "Research Edition sed target moved or got indented; see release.yml" + + # Simulate what release.yml sed actually does: apply the replacement to the + # source FILE and confirm the patched line appears at column 0. Checking the + # runtime string (research_defaults) alone is insufficient — .strip() would + # return the same value even if the source line is indented, keeping this + # assertion green while the real sed-on-source would silently fail to match. + patched_source = re.sub( + r"^research_enabled = false$", + "research_enabled = true", + source, + flags=re.MULTILINE, + ) + assert re.search(r"^research_enabled = true$", patched_source, re.MULTILINE), ( + "sed patch did not produce 'research_enabled = true' in source" + ) + # Also verify the patched value is structurally valid TOML with the flag set. + # The triple-quoted literal's runtime value is equivalent to the source content + # (both are stripped of surrounding whitespace), so we can reuse it here. + patched_defaults = re.sub( + r"^research_enabled = false$", + "research_enabled = true", + config_module.research_defaults, + flags=re.MULTILINE, + ) + assert tomlkit.parse(patched_defaults)["research_enabled"] is True + + +@pytest.mark.skipif( + RESEARCH_BUILD, + reason="research edition build patches this default on purpose (release.yml)", +) +def test_parse_args_defaults_research_off_without_config(tmp_path, monkeypatch): + """No research keys anywhere => disabled, with empty maps.""" + _patch_config_dir(monkeypatch, tmp_path) + monkeypatch.setattr(sys, "argv", ["aw-watcher-window"]) + + args = config_module.parse_args() + + assert args.research_enabled is False + assert args.research_category_map == {} + assert args.research_app_category_map == {} + + +def test_parse_args_attaches_research_category_map(monkeypatch): + monkeypatch.setattr( + config_module, + "load_config", + lambda: { + "exclude_title": False, + "exclude_titles": [], + "poll_time": 1.0, + "strategy_macos": "swift", + "research_enabled": True, + "research_category_map": {"youtube": "Entertainment"}, + }, + ) + monkeypatch.setattr(sys, "argv", ["aw-watcher-window"]) + + args = config_module.parse_args() + + assert args.research_category_map == {"youtube": "Entertainment"} + + +def test_no_research_overrides_config_enabled(monkeypatch): + monkeypatch.setattr( + config_module, + "load_config", + lambda: { + "exclude_title": False, + "exclude_titles": [], + "poll_time": 1.0, + "strategy_macos": "swift", + "research_enabled": True, + "research_category_map": {}, + }, + ) + monkeypatch.setattr(sys, "argv", ["aw-watcher-window", "--no-research"]) + + args = config_module.parse_args() + + assert args.research_enabled is False diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..facda11 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,215 @@ +import re +from types import SimpleNamespace + +import pytest + +import aw_watcher_window.main as main_module +from aw_watcher_window.macos_cli import build_swift_command + + +def test_research_mode_passes_map_to_macos_swift_strategy(monkeypatch): + commands = [] + + class FakeProcess: + pid = 123 + + def wait(self): + return None + + class FakeClient: + client_name = "aw-watcher-window" + client_hostname = "host.localdomain" + server_address = "http://localhost:5600" + + def __init__(self, *args, **kwargs): + pass + + def create_bucket(self, *args, **kwargs): + pass + + def wait_for_start(self): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(main_module.sys, "platform", "darwin") + monkeypatch.setattr(main_module, "background_ensure_permissions", lambda: None) + monkeypatch.setattr(main_module, "setup_logging", lambda **kwargs: None) + monkeypatch.setattr(main_module, "ActivityWatchClient", FakeClient) + monkeypatch.setattr(main_module.signal, "signal", lambda *args, **kwargs: None) + monkeypatch.setattr( + main_module.subprocess, + "Popen", + lambda command: commands.append(command) or FakeProcess(), + ) + monkeypatch.setattr( + main_module, + "parse_args", + lambda: SimpleNamespace( + testing=True, + verbose=False, + host=None, + port=None, + strategy="swift", + exclude_title=False, + exclude_titles=[], + research_enabled=True, + research_category_map={"youtube": "Youtube"}, + research_app_category_map={}, + ), + ) + + main_module.main() + + assert commands[0][-4:] == ["--research", "--research-category", "youtube", "Youtube"] + + +def test_build_swift_command_omits_optional_filters(): + command = build_swift_command( + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + ) + + assert command == [ + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + ] + + +def test_build_swift_command_passes_title_filters(): + command = build_swift_command( + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + exclude_title=True, + exclude_titles=["Zoom", "Slack.*huddle"], + ) + + assert command == [ + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + "--exclude-title", + "--exclude-titles", + "Zoom", + "--exclude-titles", + "Slack.*huddle", + ] + + +def test_build_swift_command_passes_empty_research_map(): + command = build_swift_command( + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + research_category_map={}, + ) + + assert command == [ + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + "--research", + ] + + +def test_build_swift_command_passes_research_categories(): + command = build_swift_command( + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + research_category_map={"youtube": "Youtube", "gmail": "Email"}, + ) + + assert command == [ + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + "--research", + "--research-category", + "youtube", + "Youtube", + "--research-category", + "gmail", + "Email", + ] + + +def test_research_transform_takes_precedence_over_exclude_titles(): + window = { + "app": "Chrome", + "title": "YouTube - Google Chrome", + "url": "https://youtube.com/watch?v=abc", + } + + transformed = main_module.transform_window( + window, + exclude_titles=[re.compile("youtube", re.IGNORECASE)], + research_category_map={"youtube": "Youtube"}, + ) + + assert transformed == {"app": "Chrome", "title": "Youtube"} + + +def test_research_transform_takes_precedence_over_exclude_title(): + window = { + "app": "Chrome", + "title": "YouTube - Google Chrome", + "url": "https://youtube.com/watch?v=abc", + } + + transformed = main_module.transform_window( + window, + exclude_title=True, + research_category_map={"youtube": "Youtube"}, + ) + + assert transformed == {"app": "Chrome", "title": "Youtube"} + + +def test_legacy_exclude_titles_still_apply_without_research_mode(): + window = {"app": "Chrome", "title": "YouTube - Google Chrome"} + + transformed = main_module.transform_window( + window, + exclude_titles=[re.compile("youtube", re.IGNORECASE)], + ) + + assert transformed == {"app": "Chrome", "title": "excluded"} + + +@pytest.mark.parametrize( + "poll_time,expected_pulsetime", + [ + (1.0, 2.0), + (2.0, 3.0), + (5.0, 7.5), + (10.0, 15.0), + ], +) +def test_pulsetime_scales_with_poll_time( + poll_time: float, expected_pulsetime: float +): + assert main_module.compute_pulsetime(poll_time) == expected_pulsetime diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py new file mode 100644 index 0000000..c6b7fa7 --- /dev/null +++ b/tests/test_research_filter.py @@ -0,0 +1,423 @@ +"""Tests for aw-watcher-window Research Edition filter.""" + +import re +import unittest +from pathlib import Path + +from aw_watcher_window.research_filter import ( + BROWSER_APPS, + classify_app, + classify_title, + is_browser, + transform, +) + + +class TestIsBrowser(unittest.TestCase): + def test_known_browsers(self): + for app in BROWSER_APPS: + self.assertTrue(is_browser(app), f"{app!r} should be a browser") + + def test_case_insensitive(self): + for app in ( + "Chrome", + "GOOGLE CHROME", + "Google-chrome", + "Firefox", + "Safari", + "Edge", + "Microsoft Edge", + ): + self.assertTrue(is_browser(app), f"{app!r} should be a browser") + + def test_linux_wm_class_browser_names(self): + for app in ( + "Google-chrome", + "Brave-browser", + "Chromium-browser", + "Microsoft-edge", + ): + self.assertTrue(is_browser(app), f"{app!r} should be a browser") + + def test_non_browsers(self): + for app in ("Slack", "Terminal", "iTerm2", "Code", "zoom.us", ""): + self.assertFalse(is_browser(app), f"{app!r} should not be a browser") + + def test_python_and_swift_browser_aliases_match(self): + swift = (Path(__file__).parents[1] / "aw_watcher_window" / "macos.swift").read_text() + match = re.search( + r"let\s+researchBrowserApps\s*=\s*Set\s*\(\s*\[(?P.*?)\]\s*\)", + swift, + re.DOTALL, + ) + self.assertIsNotNone(match) + swift_browser_apps = set(re.findall(r'"([^"]+)"', match.group("entries"))) + self.assertEqual(swift_browser_apps, BROWSER_APPS) + + +class TestClassifyTitle(unittest.TestCase): + CATEGORY_MAP = { + "youtube": "Youtube", + "facebook": "Facebook", + "twitter": "Twitter", + "reddit": "Forums & Blogs", + "gmail": "Email", + "outlook": "Email", + } + + def test_exact_substring_match(self): + self.assertEqual( + classify_title("YouTube - Google Chrome", self.CATEGORY_MAP), + "Youtube", + ) + + def test_case_insensitive_match(self): + self.assertEqual( + classify_title("YOUTUBE - Mozilla Firefox", self.CATEGORY_MAP), + "Youtube", + ) + + def test_title_with_notification_prefix(self): + self.assertEqual( + classify_title("Facebook (1) Notification - Brave Browser", self.CATEGORY_MAP), + "Facebook", + ) + + def test_no_match_returns_excluded(self): + self.assertEqual( + classify_title("systemd (SYSTEM) - man", self.CATEGORY_MAP), + "excluded", + ) + + def test_longest_pattern_wins(self): + # Both "facebook" (8) and "twitter" (7) present — the longer pattern wins + result = classify_title("Facebook Twitter integration - Chrome", self.CATEGORY_MAP) + self.assertEqual(result, "Facebook") + + def test_equal_length_patterns_resolve_to_map_order(self): + # "gmail" and "inbox" are both 5 chars — the earlier map entry wins + category_map = {"gmail": "Email", "inbox": "Other"} + self.assertEqual(classify_title("gmail inbox", category_map), "Email") + + def test_empty_title(self): + self.assertEqual(classify_title("", self.CATEGORY_MAP), "excluded") + + def test_url_matches_when_title_does_not(self): + # URL is used for classification when title gives no match + result = classify_title("New Tab", self.CATEGORY_MAP, url="https://youtube.com/watch?v=abc") + self.assertEqual(result, "Youtube") + + def test_url_takes_priority_over_title(self): + # URL match wins even when title would match a different category + result = classify_title("Gmail - Google", self.CATEGORY_MAP, url="https://youtube.com/") + self.assertEqual(result, "Youtube") + + def test_url_fallback_to_title(self): + # If URL doesn't match any pattern, title is used + result = classify_title("Facebook - Social", self.CATEGORY_MAP, url="https://example.com/unrelated") + self.assertEqual(result, "Facebook") + + def test_url_case_insensitive(self): + result = classify_title("", self.CATEGORY_MAP, url="HTTPS://YOUTUBE.COM/WATCH") + self.assertEqual(result, "Youtube") + + +class TestTransform(unittest.TestCase): + CATEGORY_MAP = { + "youtube": "Youtube", + "facebook": "Facebook", + } + + def test_no_category_map_returns_unchanged(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + self.assertEqual(transform(window, None), window) + + def test_empty_category_map_excludes_browser_title(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + self.assertEqual(transform(window, {}), {"app": "Chrome", "title": "excluded"}) + + def test_empty_category_map_still_drops_non_browser_title(self): + window = {"app": "Terminal", "title": "bash"} + self.assertEqual(transform(window, {}), {"app": "Terminal"}) + + def test_browser_title_classified(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["app"], "Chrome") + self.assertEqual(result["title"], "Youtube") + + def test_browser_title_excluded_when_no_match(self): + window = {"app": "Firefox", "title": "Vim documentation"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["app"], "Firefox") + self.assertEqual(result["title"], "excluded") + + def test_non_browser_drops_title(self): + window = {"app": "iTerm2", "title": "~/projects/foo"} + result = transform(window, self.CATEGORY_MAP) + self.assertNotIn("title", result) + self.assertEqual(result["app"], "iTerm2") + + def test_non_browser_strips_url_but_preserves_incognito(self): + # Privacy fix: non-browser URLs must be stripped to prevent leaking sensitive + # file paths or email URLs (file://, mailbox://); incognito flag is non-sensitive + window = { + "app": "Finder", + "title": "Documents", + "url": "file:///Users/jdoe/sensitive-folder/", + "incognito": False, + } + result = transform(window, self.CATEGORY_MAP) + self.assertNotIn("title", result) + self.assertEqual(result["app"], "Finder") + self.assertNotIn("url", result, "Non-browser URLs must be stripped for privacy") + self.assertEqual(result["incognito"], False) + + def test_browser_strips_url_but_preserves_incognito(self): + # Privacy fix: browser URLs must be stripped to prevent leaking search queries + # and auth tokens; incognito flag is non-sensitive metadata and can be preserved + window = { + "app": "Chrome", + "title": "YouTube - Google Chrome", + "url": "https://youtube.com/search?q=secret", + "incognito": False, + } + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Youtube") + self.assertNotIn("url", result, "Browser URLs must be stripped for privacy") + self.assertEqual(result["incognito"], False) + + def test_non_browser_minimal(self): + window = {"app": "Terminal", "title": "bash"} + self.assertEqual(transform(window, self.CATEGORY_MAP), {"app": "Terminal"}) + + def test_browser_with_leading_trailing_spaces_in_app(self): + window = {"app": " Brave Browser ", "title": "Facebook"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Facebook") + + def test_linux_browser_wm_class_title_classified(self): + for app in ("Google-chrome", "Brave-browser"): + with self.subTest(app=app): + window = {"app": app, "title": "YouTube"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["app"], app) + self.assertEqual(result["title"], "Youtube") + + def test_original_classifier_browser_aliases_are_classified(self): + for app in ("Arc", "Arc Browser", "Vivaldi", "Vivaldi.exe", "Chromium.exe"): + with self.subTest(app=app): + window = { + "app": app, + "title": "New Tab", + "url": "https://youtube.com/watch?v=private", + } + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result, {"app": app, "title": "Youtube"}) + + def test_input_not_mutated(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + original = dict(window) + transform(window, self.CATEGORY_MAP) + self.assertEqual(window, original) + + def test_browser_url_used_for_classification(self): + # URL is used for classification when title doesn't match (Swift advantage) + window = { + "app": "Safari", + "title": "New Tab", # generic title — won't match + "url": "https://youtube.com/watch?v=abc123", + } + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Youtube") + self.assertNotIn("url", result) + + def test_browser_url_takes_priority_over_title(self): + # URL match takes precedence over title match + window = { + "app": "Chrome", + "title": "Facebook - Home", + "url": "https://youtube.com/", + } + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Youtube") + self.assertNotIn("url", result) + + +class TestClassifyApp(unittest.TestCase): + APP_CATEGORY_MAP = { + "microsoft outlook": "Email", + "outlook.exe": "Email", + "microsoft teams": "Work & Productivity", + "teams": "Work & Productivity", + "spotify": "Music & Audio", + "finder": "Excluded", + "steam": "Games", + } + + def test_exact_match(self): + self.assertEqual(classify_app("Microsoft Outlook", self.APP_CATEGORY_MAP), "Email") + + def test_case_insensitive(self): + self.assertEqual(classify_app("SPOTIFY", self.APP_CATEGORY_MAP), "Music & Audio") + self.assertEqual(classify_app("spotify", self.APP_CATEGORY_MAP), "Music & Audio") + self.assertEqual(classify_app("Spotify", self.APP_CATEGORY_MAP), "Music & Audio") + + def test_exe_suffix(self): + self.assertEqual(classify_app("outlook.exe", self.APP_CATEGORY_MAP), "Email") + + def test_unmapped_returns_excluded(self): + self.assertEqual(classify_app("iTerm2", self.APP_CATEGORY_MAP), "Excluded") + self.assertEqual(classify_app("Terminal", self.APP_CATEGORY_MAP), "Excluded") + self.assertEqual(classify_app("", self.APP_CATEGORY_MAP), "Excluded") + + def test_leading_trailing_spaces(self): + self.assertEqual(classify_app(" Spotify ", self.APP_CATEGORY_MAP), "Music & Audio") + + def test_explicit_excluded_entry(self): + # "Finder" is explicitly mapped to "Excluded" in Matthias's map + self.assertEqual(classify_app("Finder", self.APP_CATEGORY_MAP), "Excluded") + + def test_empty_map_returns_excluded(self): + self.assertEqual(classify_app("Spotify", {}), "Excluded") + + def test_mixed_case_map_keys(self): + # Configured keys may be mixed-case (as typed in config TOML); lookup + # must still match case-insensitively on both sides. + mixed_case_map = { + "Microsoft Outlook": "Email", + "Spotify": "Music & Audio", + } + self.assertEqual(classify_app("microsoft outlook", mixed_case_map), "Email") + self.assertEqual(classify_app("SPOTIFY", mixed_case_map), "Music & Audio") + + +class TestTransformWithAppMap(unittest.TestCase): + CATEGORY_MAP = { + "youtube": "Video Streaming", + "facebook": "Social Networking", + } + APP_CATEGORY_MAP = { + "microsoft outlook": "Email", + "microsoft teams": "Work & Productivity", + "spotify": "Music & Audio", + "finder": "Excluded", + } + + def test_non_browser_mapped_to_category(self): + # Raw app identity is REPLACED by its category; title is dropped. + window = {"app": "Microsoft Outlook", "title": "Inbox — johndoe@example.com"} + result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP) + self.assertEqual(result["app"], "Email") + self.assertNotIn("title", result) + + def test_non_browser_unmapped_returns_excluded(self): + # Unmapped non-browser app is anonymized to "Excluded", title dropped. + window = {"app": "iTerm2", "title": "~/projects/secret"} + result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP) + self.assertEqual(result["app"], "Excluded") + self.assertNotIn("title", result) + + def test_non_browser_title_and_url_stripped(self): + # App replaced by category; sensitive title AND URL must not leak + window = { + "app": "Microsoft Outlook", + "title": "Private inbox", + "url": "mailbox://private@corp.example.com/INBOX", + } + result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP) + self.assertEqual(result["app"], "Email") + self.assertNotIn("title", result) + self.assertNotIn("url", result) + + def test_non_browser_incognito_preserved(self): + window = {"app": "Spotify", "title": "Now playing: Secret Playlist", "incognito": True} + result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP) + self.assertEqual(result["app"], "Music & Audio") + self.assertEqual(result["incognito"], True) + + def test_browser_unaffected_by_app_map(self): + # Browser classification should not change even when an app map is present + window = {"app": "Chrome", "title": "YouTube - Chrome"} + result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP) + self.assertEqual(result["title"], "Video Streaming") + + def test_no_app_map_legacy_behaviour(self): + # Without app map, non-browser keeps raw app name and drops title (backward compat) + window = {"app": "Microsoft Outlook", "title": "Inbox"} + result = transform(window, self.CATEGORY_MAP, None) + self.assertEqual(result["app"], "Microsoft Outlook") + self.assertNotIn("title", result) + + def test_empty_app_map_falls_back_to_legacy(self): + # An empty app map is treated as "not provided": research_enabled with no + # configured app map must NOT classify every non-browser app as "Excluded". + window = {"app": "Microsoft Outlook", "title": "Inbox"} + result = transform(window, self.CATEGORY_MAP, {}) + self.assertEqual(result["app"], "Microsoft Outlook") + self.assertNotIn("title", result) + + def test_explicit_excluded_mapped_app(self): + # "Finder" → "Excluded" (explicit map entry, not a lookup miss) + window = {"app": "Finder", "title": "/Users/jdoe/Documents"} + result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP) + self.assertEqual(result["app"], "Excluded") + self.assertNotIn("title", result) + + def test_input_not_mutated_with_app_map(self): + window = {"app": "Spotify", "title": "Now playing"} + original = dict(window) + transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP) + self.assertEqual(window, original) + +class TestSpecificHostBeatsGenericHost(unittest.TestCase): + """Regression: a generic host must not shadow a more specific one. + + Study maps list ``google.com`` well before ``docs.google.com``. Under + first-match-wins every Google Workspace URL was filed as *Search & + Navigation* instead of *Work & Productivity* — reported from the + Research Edition b4 field test. + """ + + CATEGORY_MAP = { + "google.com": "Search & Navigation", + "docs.google.com": "Work & Productivity", + "drive.google.com": "Work & Productivity", + "calendar.google.com": "Work & Productivity", + "meet.google.com": "Work & Productivity", + } + + def test_workspace_urls_are_work_not_search(self): + for url in ( + "https://docs.google.com/document/d/abc/edit", + "https://drive.google.com/drive/my-drive", + "https://calendar.google.com/calendar/u/0/r", + "https://meet.google.com/xyz-abcd-efg", + ): + with self.subTest(url=url): + self.assertEqual( + classify_title("", self.CATEGORY_MAP, url=url), + "Work & Productivity", + ) + + def test_plain_google_search_still_matches_generic(self): + self.assertEqual( + classify_title("", self.CATEGORY_MAP, url="https://www.google.com/search?q=aw"), + "Search & Navigation", + ) + + def test_specific_host_also_wins_on_title_fallback(self): + self.assertEqual( + classify_title("docs.google.com - Untitled", self.CATEGORY_MAP), + "Work & Productivity", + ) + + def test_empty_pattern_is_ignored(self): + # An empty map key matches everything under `in`; it must not swallow input. + category_map = {"": "Bogus", "youtube": "Youtube"} + self.assertEqual(classify_title("youtube - Chrome", category_map), "Youtube") + self.assertEqual(classify_title("unrelated", category_map), "excluded") + + +if __name__ == "__main__": + unittest.main()