From 96755ecd218896fec81cfbfdfe06cfd2387108ae Mon Sep 17 00:00:00 2001 From: upstream-mirror Date: Wed, 16 Sep 2026 00:20:39 +0500 Subject: [PATCH] snapshot of https://github.com/ActivityWatch/aw-client.git (master, 2026-09-16; blobs > 131072B omitted) --- .github/workflows/build.yml | 48 ++ .github/workflows/codeql.yml | 41 + .github/workflows/examples.yml | 56 ++ .github/workflows/lint.yml | 15 + .gitignore | 14 + LICENSE.txt | 373 ++++++++++ Makefile | 32 + README.md | 74 ++ aw_client/__init__.py | 3 + aw_client/__main__.py | 3 + aw_client/classes.py | 89 +++ aw_client/cli.py | 332 +++++++++ aw_client/client.py | 636 ++++++++++++++++ aw_client/config.py | 206 ++++++ aw_client/profile.py | 111 +++ aw_client/py.typed | 1 + aw_client/queries.py | 334 +++++++++ aw_client/singleinstance.py | 47 ++ examples/email_report.py | 97 +++ examples/load_dataframe.py | 66 ++ examples/merge_buckets.py | 67 ++ examples/redact_sensitive.py | 147 ++++ examples/suggest_categories.py | 88 +++ examples/suggest_categories_gpt.py | 213 ++++++ examples/time_spent_today.py | 16 + examples/working_hours.py | 157 ++++ examples/working_hours_gspread.py | 129 ++++ poetry.lock | 1112 ++++++++++++++++++++++++++++ pyproject.toml | 51 ++ tests/conftest.py | 12 + tests/test_auth.py | 104 +++ tests/test_client.py | 130 ++++ tests/test_failqueue.py | 56 ++ tests/test_profile.py | 94 +++ tests/test_profile_config.py | 294 ++++++++ tests/test_requestqueue.py | 90 +++ 36 files changed, 5338 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/examples.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 aw_client/__init__.py create mode 100644 aw_client/__main__.py create mode 100644 aw_client/classes.py create mode 100755 aw_client/cli.py create mode 100644 aw_client/client.py create mode 100644 aw_client/config.py create mode 100644 aw_client/profile.py create mode 100644 aw_client/py.typed create mode 100644 aw_client/queries.py create mode 100644 aw_client/singleinstance.py create mode 100644 examples/email_report.py create mode 100644 examples/load_dataframe.py create mode 100644 examples/merge_buckets.py create mode 100644 examples/redact_sensitive.py create mode 100644 examples/suggest_categories.py create mode 100644 examples/suggest_categories_gpt.py create mode 100644 examples/time_spent_today.py create mode 100644 examples/working_hours.py create mode 100644 examples/working_hours_gspread.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_auth.py create mode 100755 tests/test_client.py create mode 100755 tests/test_failqueue.py create mode 100644 tests/test_profile.py create mode 100644 tests/test_profile_config.py create mode 100644 tests/test_requestqueue.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..51b2da3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macOS-latest] + python_version: [3.9] + + steps: + - uses: actions/checkout@v2 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python_version }} + + - name: Install dependencies + shell: bash + run: | + pip install poetry==1.3.2 + python -m venv venv + source venv/bin/activate || source venv/Scripts/activate + make build + + - name: Run tests + shell: bash + run: | + source venv/bin/activate || source venv/Scripts/activate + make test + make typecheck + + - name: Report coverage + shell: bash + run: | + # Allow to fail, since sometimes codecov servers are grumpy + bash <(curl -s https://codecov.io/bash) || true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f4298f7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "52 6 * * 1" + +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/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 0000000..617d321 --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,56 @@ +name: Examples + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + name: Test examples + runs-on: ${{ matrix.os }} + env: + RELEASE: false + strategy: + matrix: + os: [ubuntu-latest] + python_version: ["3.10"] + + steps: + - uses: actions/checkout@v3 + with: + submodules: 'recursive' + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python_version }} + + - name: Create virtualenv + shell: bash + run: | + pip install virtualenv + python -m virtualenv venv + + - name: Install dependencies + shell: bash + run: | + pip install poetry + source venv/bin/activate || source venv/Scripts/activate + make build + + - name: Set up ActivityWatch + uses: ActivityWatch/setup-action@master + with: + aw-server: "aw-server" + aw-version: "v0.12.0b2" + aw-testing: false + fakedata: true + + - name: Run examples + shell: bash + run: | + source venv/bin/activate || source venv/Scripts/activate + pip install pandas # needed for one of the examples + make test-examples diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..49289ec --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,15 @@ +name: Lint + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - uses: psf/black@stable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73d6065 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +build +dist +secret.env + +# Cache files +*.pyc +__pycache__ +.*cache +*.egg-info +*.swp + +# Output from example scripts +*.json +*.csv 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..b59e751 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: build test typecheck clean examples + +build: + poetry install + +test: + python -c "import aw_client" + pytest -s -vv tests/test_requestqueue.py tests/test_profile.py tests/test_profile_config.py + +test-integration: + pytest -v tests/test_client.py + +test-examples: + cd examples; pytest -v *.py + cd examples; yes | python3 load_dataframe.py + cd examples; python3 working_hours.py 'activitywatch|aw-|github.com' fakedata + +lint: + ruff check . + +lint-fix: + ruff check --fix . + +typecheck: + poetry run mypy + +format: + ruff format . + +clean: + rm -rf build dist + rm -rf aw_client/__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ca1223 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +aw-client +========= + +[![GitHub Actions badge](https://github.com/ActivityWatch/aw-client/workflows/Build/badge.svg)](https://github.com/ActivityWatch/aw-client/actions) +[![PyPI](https://img.shields.io/pypi/v/aw-client)](https://pypi.org/project/aw-client/) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Typechecking: Mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) + +Client library for ActivityWatch in Python. + +Please see [the documentation][docs] for usage, and take a look at `examples/`. + + - [Documentation][docs] + - [API Reference][apiref] + +[docs]: https://docs.activitywatch.net/en/latest/ +[apiref]: https://docs.activitywatch.net/en/latest/api/python.html#aw-client + +## How to install + +Install from pip: `pip install aw-client` + +Install the latest version directly from github without cloning: `pip install git+https://github.com/ActivityWatch/aw-client.git` + +To install from a cloned version: + + - clone repo: `git clone https://github.com/ActivityWatch/aw-client.git` + - cd into the directory: `cd aw-client` + - run `poetry install` (will create a virtualenv, if none activated) + - If you don't want to use poetry you can also use `pip install .`, but that might not get the exact version of the dependencies (due to not reading the `poetry.lock` file). + +## Usage + +For the CLI: + +``` +$ aw-client --help +Usage: aw-client [OPTIONS] COMMAND [ARGS]... + + CLI utility for aw-client to aid in interacting with the ActivityWatch + server + +Options: + --host TEXT Address of host + --port INTEGER Port to use + -v, --verbose Verbosity + --testing Set to use testing ports by default + --help Show this message and exit. + +Commands: + buckets List all buckets + canonical Query 'canonical events' for a single host (filtered,... + events Query events from bucket with ID `bucket_id` + heartbeat Send a heartbeat to bucket with ID `bucket_id` with JSON `data` + query Run a query in file at `path` on the server + report Generate an activity report +``` + + +## Debugging + +* Run python with `LOG_LEVEL=debug` to get additional debugging output +* If invalid events have been queued for submission, you may need to delete the file-based queues generated by this library +* To use the development version of this library use `aw-client = {path = "../aw-client" }` in `pyproject.toml` + +## Examples + +The [`examples/`](examples/) directory contains a couple of example scripts, including: + + - [`time_spent_today.py`](examples/time_spent_today.py) - fetches all non-afk events and sums their duration to get the total active time for the day. + - [`working_hours.py`](https://github.com/ActivityWatch/aw-client/blob/master/examples/working_hours.py) - computes hours worked per day (matching a "work" category rule), and exports matching events to a JSON file (for auditing). + - [`load_dataframe.py`](https://github.com/ActivityWatch/aw-client/blob/master/examples/load_dataframe.py) - loads events from a host using a categorizing & AFK-filtering query, put result in a pandas dataframe, and export as CSV. + - [`merge_buckets.py`](examples/merge_buckets.py) - merges two buckets with non-intersecting events by moving all events from one into the other. + - [`redact_sensitive.py`](examples/redact_sensitive.py) - redact sensitive events. diff --git a/aw_client/__init__.py b/aw_client/__init__.py new file mode 100644 index 0000000..d43dcb9 --- /dev/null +++ b/aw_client/__init__.py @@ -0,0 +1,3 @@ +from .client import ActivityWatchClient + +__all__ = ["ActivityWatchClient"] diff --git a/aw_client/__main__.py b/aw_client/__main__.py new file mode 100644 index 0000000..247ee14 --- /dev/null +++ b/aw_client/__main__.py @@ -0,0 +1,3 @@ +from aw_client import cli + +cli.main() diff --git a/aw_client/classes.py b/aw_client/classes.py new file mode 100644 index 0000000..45038cc --- /dev/null +++ b/aw_client/classes.py @@ -0,0 +1,89 @@ +""" +Default classes + +Taken from default classes in aw-webui +""" + +import logging +import random +from typing import ( + Any, + Dict, + List, + Tuple, +) + +import aw_client + +logger = logging.getLogger(__name__) + +CategoryId = List[str] +CategorySpec = Dict[str, Any] + +default_classes: List[Tuple[CategoryId, CategorySpec]] = [ + (["Work"], {"type": "regex", "regex": "Google Docs|libreoffice|ReText"}), + ( + ["Work", "Programming"], + { + "type": "regex", + "regex": "GitHub|Stack Overflow|BitBucket|Gitlab|vim|Spyder|kate|Ghidra|Scite", + }, + ), + ( + ["Work", "Programming", "ActivityWatch"], + {"type": "regex", "regex": "ActivityWatch|aw-", "ignore_case": True}, + ), + (["Work", "Image"], {"type": "regex", "regex": "Gimp|Inkscape"}), + (["Work", "Video"], {"type": "regex", "regex": "Kdenlive"}), + (["Work", "Audio"], {"type": "regex", "regex": "Audacity"}), + (["Work", "3D"], {"type": "regex", "regex": "Blender"}), + (["Media", "Games"], {"type": "regex", "regex": "Minecraft|RimWorld"}), + (["Media", "Video"], {"type": "regex", "regex": "YouTube|Plex|VLC"}), + ( + ["Media", "Social Media"], + { + "type": "regex", + "regex": "reddit|Facebook|Twitter|Instagram|devRant", + "ignore_case": True, + }, + ), + ( + ["Media", "Music"], + {"type": "regex", "regex": "Spotify|Deezer", "ignore_case": True}, + ), + ( + ["Comms", "IM"], + { + "type": "regex", + "regex": "Messenger|Telegram|Signal|WhatsApp|Rambox|Slack|Riot|Discord|Nheko", + }, + ), + ( + ["Comms", "Email"], + {"type": "regex", "regex": "Gmail|Thunderbird|mutt|alpine"}, + ), +] + + +def get_classes() -> List[Tuple[List[str], dict]]: + """ + Get classes from server-side settings. + Might throw a 404 if not set yet, in which case we use the default classes as a fallback. + """ + # NOTE: Always tries to fetch from prod server, + # which is potentially wrong if testing server is being used. + awc = aw_client.ActivityWatchClient(f"get-setting-{random.randint(0, 10000)}") + try: + classes = awc.get_setting("classes") + except Exception: + logger.warning( + "Failed to get classes from server, using default classes as fallback" + ) + return default_classes + if not classes: + logger.warning( + "Classes setting is empty/unset, using default classes as fallback" + ) + return default_classes + # map into list of tuples + return [(v["name"], v["rule"]) for v in classes] diff --git a/aw_client/cli.py b/aw_client/cli.py new file mode 100755 index 0000000..62e4089 --- /dev/null +++ b/aw_client/cli.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 + +import json +import logging +import textwrap +import time +from datetime import datetime, timedelta, timezone +from typing import List, Optional +from zoneinfo import ZoneInfo + +import click +from aw_core import Event +from tabulate import tabulate + +import aw_client + +from . import queries +from .classes import default_classes, get_classes + +now = datetime.now(timezone.utc) +td1day = timedelta(days=1) +td1yr = timedelta(days=365) + +logger = logging.getLogger(__name__) + + +class _Context: + client: aw_client.ActivityWatchClient + + +@click.group( + help="CLI utility for aw-client to aid in interacting with the ActivityWatch server" +) +@click.option( + "--host", + default="127.0.0.1", + help="Address of host", +) +@click.option( + "--port", + default=None, + type=int, + help="Port to use (default: profile config, 5600 / 5666)", +) +@click.option( + "-v", + "--verbose", + is_flag=True, + help="Verbosity", +) +@click.option("--testing", is_flag=True, help="Set to use testing ports by default") +@click.option( + "--profile", + default=None, + help="Named instance profile. --testing is an alias for --profile testing.", +) +@click.pass_context +def main( + ctx, testing: bool, verbose: bool, host: str, port: int, profile: Optional[str] +): + ctx.obj = _Context() + # default=None so `--port 5600` is a real override, not discarded as + # "the Click default". None lets ActivityWatchClient read the profile + # config (5600 / 5666 / baked research port). + ctx.obj.client = aw_client.ActivityWatchClient( + host=host, + port=port, + testing=testing, + profile=profile, + ) + logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO) + + +@main.command(help="Send a heartbeat to bucket with ID `bucket_id` with JSON `data`") +@click.argument("bucket_id") +@click.argument("data") +@click.option("--pulsetime", default=60, help="pulsetime to use for merging heartbeats") +@click.pass_obj +def heartbeat(obj: _Context, bucket_id: str, data: str, pulsetime: int): + now = datetime.now(timezone.utc) + e = Event(duration=0, data=json.loads(data), timestamp=now) + print(e) + obj.client.heartbeat(bucket_id, e, pulsetime) + + +@main.command(help="List all buckets") +@click.pass_obj +def buckets(obj: _Context): + buckets = obj.client.get_buckets() + print("Buckets:") + for bucket in buckets: + print(f" - {bucket}") + + +@main.command(help="Query events from bucket with ID `bucket_id`") +@click.argument("bucket_id") +@click.pass_obj +def events(obj: _Context, bucket_id: str): + events = obj.client.get_events(bucket_id) + print("events:") + for e in events: + print( + " - {} ({}) {}".format( + e.timestamp.replace(tzinfo=None, microsecond=0), + str(e.duration).split(".")[0], + e.data, + ) + ) + + +@main.command(help="Run a query in file at `path` on the server") +@click.argument("path") +@click.option("--name") +@click.option("--cache", is_flag=True) +@click.option("--json", "_json", is_flag=True) +@click.option("--start", default=now - td1day, type=click.DateTime()) +@click.option("--stop", default=now + td1yr, type=click.DateTime()) +@click.option( + "--timezone", + help="Time zone for start and stop options." + " Must be a valid IANA identifier like e.g. 'Europe/Warsaw'.", +) +@click.pass_obj +def query( + obj: _Context, + path: str, + cache: bool, + _json: bool, + start: datetime, + stop: datetime, + timezone: str, + name: Optional[str] = None, +): + with open(path) as f: + query = f.read() + + if timezone: + zone_info = ZoneInfo(timezone) + start = start.replace(tzinfo=zone_info) + stop = stop.replace(tzinfo=zone_info) + + result = obj.client.query(query, [(start, stop)], cache=cache, name=name) + if _json: + print(json.dumps(result)) + else: + for period in result: + print(f"Showing 10 out of {len(period)} events:") + for event in period[:10]: + event.pop("id") + event.pop("timestamp") + print( + " - Duration: {} \tData: {}".format( + str(timedelta(seconds=event["duration"])).split(".")[0], + event["data"], + ) + ) + print( + "Total duration:\t", + timedelta(seconds=sum(e["duration"] for e in period)), + ) + + +@main.command(help="Generate an activity report") +@click.argument("hostname") +@click.option("--cache", is_flag=True) +@click.option("--start", default=now - td1day, type=click.DateTime()) +@click.option("--stop", default=now + td1yr, type=click.DateTime()) +@click.option("--limit", default=10) +@click.pass_obj +def report( + obj: _Context, + hostname: str, + cache: bool, + start: datetime, + stop: datetime, + name: Optional[str] = None, + limit: int = 10, +): + logger.info(f"Querying between {start} and {stop}") + bid_window = f"aw-watcher-window_{hostname}" + bid_afk = f"aw-watcher-afk_{hostname}" + + if not start.tzinfo: + start = start.astimezone() + if not stop.tzinfo: + stop = stop.astimezone() + + bid_browsers: List[str] = [] + + classes = get_classes() + params = queries.DesktopQueryParams( + bid_browsers=bid_browsers, + classes=classes, + filter_classes=[], + filter_afk=True, + include_audible=True, + bid_window=bid_window, + bid_afk=bid_afk, + ) + query = queries.fullDesktopQuery(params) + logger.debug("Query: \n" + queries.pretty_query(query)) + + result = obj.client.query(query, [(start, stop)], cache=cache, name=name) + + # TODO: Print titles, apps, categories, with most time + for period in result: + print() + # print(period["window"]["cat_events"]) + + cat_events = _parse_events(period["window"]["cat_events"]) + print_top( + cat_events, + lambda e: " > ".join(e.data["$category"]), + title="Categories", + n=limit, + ) + + title_events = _parse_events(period["window"]["title_events"]) + print_top(title_events, lambda e: e.data["title"], title="Titles", n=limit) + + active_events = _parse_events(period["window"]["title_events"]) + print( + "Total duration:\t", + sum((e.duration for e in active_events), timedelta()), + ) + + +def _parse_events(events: List[dict]) -> List[Event]: + return [Event(**event) for event in events] + + +def print_top(events: List[Event], key=lambda e: e.data, title="Events", n=10): + print(f"Top {n} {title}" + (f" (out of {len(events)})" if len(events) > 10 else "")) + print( + tabulate( + [ + (event.duration, key(event)) + for event in sorted(events, key=lambda e: e.duration, reverse=True)[:10] + ], + headers=["Duration", "Key"], + ) + ) + print() + + +@main.command(help="Query 'canonical events' for a single host (filtered, classified)") +@click.argument("hostname") +@click.option("--cache", is_flag=True) +@click.option("--start", default=now - td1day, type=click.DateTime()) +@click.option("--stop", default=now + td1yr, type=click.DateTime()) +@click.option( + "--format", + type=click.Choice(["table", "json"]), + default="table", + help="Output format (table or json)", +) +@click.pass_obj +def canonical( + obj: _Context, + hostname: str, + cache: bool, + start: datetime, + stop: datetime, + format: str, + name: Optional[str] = None, +): + logger.info(f"Querying between {start} and {stop}") + bid_window = f"aw-watcher-window_{hostname}" + bid_afk = f"aw-watcher-afk_{hostname}" + + if not start.tzinfo: + start = start.astimezone() + if not stop.tzinfo: + stop = stop.astimezone() + + classes = default_classes + + query = queries.canonicalEvents( + queries.DesktopQueryParams( + bid_window=bid_window, + bid_afk=bid_afk, + classes=classes, + ) + ) + query = f"""{query}\n RETURN = events;""" + logger.debug("Query: \n" + queries.pretty_query(query)) + + result = obj.client.query(query, [(start, stop)], cache=cache, name=name) + + # TODO: Print titles, apps, categories, with most time + for period in result: + events = _parse_events(period) + + if format == "json": + # Output as JSON array with ISO-8601 timestamps + json_events = [ + { + "timestamp": e.timestamp.isoformat(), + "duration": e.duration.total_seconds(), + "data": e.data, + } + for e in events + ] + print(json.dumps(json_events)) + else: + # Table format (original behavior) + print() + print(f"Showing last 10 out of {len(events)} events:") + + print( + tabulate( + [ + ( + str(e.timestamp).split(".")[0], + str(e.duration).split(".")[0], + f'[{e.data["app"]}] {textwrap.shorten(e.data["title"], 60, placeholder="...")}', + ) + for e in events[-10:] + ], + headers=["Timestamp", "Duration", "Data"], + ) + ) + + print() + print( + "Total duration:\t", + timedelta(seconds=sum(e["duration"] for e in period)), + ) + + +if __name__ == "__main__": + main() diff --git a/aw_client/client.py b/aw_client/client.py new file mode 100644 index 0000000..8cd7750 --- /dev/null +++ b/aw_client/client.py @@ -0,0 +1,636 @@ +import functools +import json +import logging +import os +import socket +import threading +import warnings +from collections import namedtuple +from datetime import datetime +from time import sleep +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Union, +) + +import persistqueue +import requests as req +from aw_core.dirs import get_data_dir +from aw_core.models import Event +from aw_transform.heartbeats import heartbeat_merge + +from .config import load_config, load_local_server_api_key +from .profile import ( + DEFAULT_PROFILE, + export_profile, + is_testing, + profile_from_env, + profile_suffix, + resolve_profile, +) +from .singleinstance import SingleInstance + +# FIXME: This line is probably badly placed +logging.getLogger("requests").setLevel(logging.WARNING) +logger = logging.getLogger(__name__) + + +def _log_request_exception(e: req.RequestException): + logger.warning(str(e)) + try: + d = e.response.json() if e.response else None + logger.warning(f"Error message received: {d}") + except json.JSONDecodeError: + pass + + +def _dt_is_tzaware(dt: datetime) -> bool: + return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None + + +def always_raise_for_request_errors(f: Callable[..., req.Response]): + @functools.wraps(f) + def g(*args, **kwargs): + r = f(*args, **kwargs) + try: + r.raise_for_status() + except req.RequestException as e: + _log_request_exception(e) + raise e + return r + + return g + + +class ActivityWatchClient: + def __init__( + self, + client_name: str = "unknown", + testing=False, + host=None, + port=None, + protocol="http", + profile: Optional[str] = None, + ) -> None: + """ + A handy wrapper around the aw-server REST API. The recommended way of interacting with the server. + + Can be used with a `with`-statement as an alternative to manually calling connect and disconnect in a try-finally clause. + + :Example: + + .. literalinclude:: examples/client.py + :lines: 7- + + ``profile`` selects an isolated instance (config, port, queue file). + ``testing=True`` is the compat shim for ``profile="testing"``. If + neither is set, ``AW_PROFILE`` from the launcher is used. + """ + if profile is not None or testing: + resolved = resolve_profile(profile, testing) + else: + resolved = profile_from_env(False) + export_profile(resolved) + self.profile = resolved + self.testing = is_testing(resolved) + + self.client_name = client_name + self.client_hostname = socket.gethostname() + + _config = load_config() + server_key = "server" if resolved == DEFAULT_PROFILE else f"server-{resolved}" + client_key = "client" if resolved == DEFAULT_PROFILE else f"client-{resolved}" + if server_key not in _config: + if resolved != DEFAULT_PROFILE: + logger.warning( + "Profile %s has no [%s] section, falling back to [server] " + "(port %s may collide with the default instance)", + resolved, + server_key, + 5666 if is_testing(resolved) else 5600, + ) + server_key = "server" + if client_key not in _config: + client_key = "client" + server_config = _config[server_key] + client_config = _config[client_key] + + server_host = host or server_config["hostname"] + server_port = port or server_config["port"] + self.server_api_key = load_local_server_api_key( + str(server_host), server_port, profile=resolved + ) + self.server_address = f"{protocol}://{server_host}:{server_port}" + + self.instance = SingleInstance( + f"{self.client_name}-at-{server_host}-on-{server_port}" + ) + + self.commit_interval = client_config["commit_interval"] + + self.request_queue = RequestQueue(self) + # Dict of each last heartbeat in each bucket + self.last_heartbeat = {} # type: Dict[str, Event] + self._warned_queue_before_connect = False + + # + # Get/Post base requests + # + + def _url(self, endpoint: str): + return f"{self.server_address}/api/0/{endpoint}" + + def _headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: + request_headers = dict(headers or {}) + if self.server_api_key: + request_headers.setdefault("Authorization", f"Bearer {self.server_api_key}") + return request_headers + + @always_raise_for_request_errors + def _get(self, endpoint: str, params: Optional[dict] = None) -> req.Response: + return req.get(self._url(endpoint), params=params, headers=self._headers()) + + @always_raise_for_request_errors + def _post( + self, + endpoint: str, + data: Union[List[Any], Dict[str, Any]], + params: Optional[dict] = None, + ) -> req.Response: + headers = self._headers( + {"Content-type": "application/json", "charset": "utf-8"} + ) + return req.post( + self._url(endpoint), + data=bytes(json.dumps(data), "utf8"), + headers=headers, + params=params, + ) + + @always_raise_for_request_errors + def _delete(self, endpoint: str, data: Any = None) -> req.Response: + if data is None: + data = {} + headers = self._headers({"Content-type": "application/json"}) + return req.delete(self._url(endpoint), data=json.dumps(data), headers=headers) + + def get_info(self): + """Returns a dict currently containing the keys 'hostname' and 'testing'.""" + endpoint = "info" + return self._get(endpoint).json() + + # + # Event get/post requests + # + + def get_event( + self, + bucket_id: str, + event_id: int, + ) -> Optional[Event]: + endpoint = f"buckets/{bucket_id}/events/{event_id}" + try: + event = self._get(endpoint).json() + return Event(**event) + except req.exceptions.HTTPError as e: + if e.response and e.response.status_code == 404: + return None + else: + raise + + def get_events( + self, + bucket_id: str, + limit: int = -1, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + ) -> List[Event]: + endpoint = f"buckets/{bucket_id}/events" + + params = dict() # type: Dict[str, str] + if limit is not None: + params["limit"] = str(limit) + if start is not None: + params["start"] = start.isoformat() + if end is not None: + params["end"] = end.isoformat() + + events = self._get(endpoint, params=params).json() + return [Event(**event) for event in events] + + def insert_event(self, bucket_id: str, event: Event) -> Optional[Event]: + endpoint = f"buckets/{bucket_id}/events" + data = [event.to_json_dict()] + response = self._post(endpoint, data) + if response.json(): + return Event(**response.json()[0]) + return None + + def insert_events( + self, bucket_id: str, events: List[Event] + ) -> Optional[List[Event]]: + endpoint = f"buckets/{bucket_id}/events" + data = [event.to_json_dict() for event in events] + response = self._post(endpoint, data) + if response.json(): + return [Event(**e) for e in response.json()] + return None + + def delete_event(self, bucket_id: str, event_id: int) -> None: + endpoint = f"buckets/{bucket_id}/events/{event_id}" + self._delete(endpoint) + + def get_eventcount( + self, + bucket_id: str, + limit: int = -1, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + ) -> int: + endpoint = f"buckets/{bucket_id}/events/count" + + params = dict() # type: Dict[str, str] + if start is not None: + params["start"] = start.isoformat() + if end is not None: + params["end"] = end.isoformat() + + response = self._get(endpoint, params=params) + return int(response.text) + + def heartbeat( + self, + bucket_id: str, + event: Event, + pulsetime: float, + queued: bool = False, + commit_interval: Optional[float] = None, + ) -> None: + """ + Args: + bucket_id: The bucket_id of the bucket to send the heartbeat to + event: The actual heartbeat event + pulsetime: The maximum amount of time in seconds since the last heartbeat to be merged with the previous heartbeat in aw-server + queued: Use the aw-client queue feature to queue events if client loses connection with the server + commit_interval: Override default pre-merge commit interval + + NOTE: This endpoint can use the failed requests retry queue. + This makes the request itself non-blocking and therefore + the function will in that case always returns None. + """ + + endpoint = f"buckets/{bucket_id}/heartbeat?pulsetime={pulsetime}" + _commit_interval = commit_interval or self.commit_interval + + if queued: + self._warn_queue_before_connect() + # Pre-merge heartbeats + if bucket_id not in self.last_heartbeat: + self.last_heartbeat[bucket_id] = event + return None + + last_heartbeat = self.last_heartbeat[bucket_id] + + merge = heartbeat_merge(last_heartbeat, event, pulsetime) + + if merge: + # If last_heartbeat becomes longer than commit_interval + # then commit, else cache merged. + diff = (last_heartbeat.duration).total_seconds() + if diff >= _commit_interval: + data = merge.to_json_dict() + self.request_queue.add_request(endpoint, data) + self.last_heartbeat[bucket_id] = event + else: + self.last_heartbeat[bucket_id] = merge + else: + data = last_heartbeat.to_json_dict() + self.request_queue.add_request(endpoint, data) + self.last_heartbeat[bucket_id] = event + else: + self._post(endpoint, event.to_json_dict()) + + # + # Bucket get/post requests + # + + def get_buckets(self) -> dict: + return self._get("buckets/").json() + + def create_bucket(self, bucket_id: str, event_type: str, queued=False): + if queued: + self._warn_queue_before_connect() + self.request_queue.register_bucket(bucket_id, event_type) + else: + endpoint = f"buckets/{bucket_id}" + data = { + "client": self.client_name, + "hostname": self.client_hostname, + "type": event_type, + } + self._post(endpoint, data) + + def delete_bucket(self, bucket_id: str, force: bool = False): + self._delete(f"buckets/{bucket_id}" + ("?force=1" if force else "")) + + # @deprecated + def setup_bucket(self, bucket_id: str, event_type: str): + self.create_bucket(bucket_id, event_type, queued=True) + + # Import & export + + def export_all(self) -> dict: + return self._get("export").json() + + def export_bucket(self, bucket_id) -> dict: + return self._get(f"buckets/{bucket_id}/export").json() + + def import_bucket(self, bucket: dict) -> None: + endpoint = "import" + self._post(endpoint, {"buckets": {bucket["id"]: bucket}}) + + # + # Query (server-side transformation) + # + + def query( + self, + query: str, + timeperiods: List[Tuple[datetime, datetime]], + name: Optional[str] = None, + cache: bool = False, + ) -> List[Any]: + endpoint = "query/" + params = {} # type: Dict[str, Any] + if cache: + if not name: + raise Exception( + "You are not allowed to do caching without a query name" + ) + params["name"] = name + params["cache"] = int(cache) + + # Check that datetimes have timezone information + for start, stop in timeperiods: + try: + assert _dt_is_tzaware(start) + assert _dt_is_tzaware(stop) + except AssertionError: + raise ValueError("start/stop needs to have a timezone set") from None + + data = { + "timeperiods": [ + "/".join([start.isoformat(), end.isoformat()]) + for start, end in timeperiods + ], + "query": query.split("\n"), + } + response = self._post(endpoint, data, params=params) + return response.json() + + # + # Settings + # + + def get_setting(self, key: Optional[str] = None) -> dict: + if key: + return self._get(f"settings/{key}").json() + else: + return self._get("settings").json() + + def set_setting(self, key: str, value: str) -> None: + self._post(f"settings/{key}", value) + + # + # Connect and disconnect + # + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.disconnect() + + def connect(self): + if not self.request_queue.is_alive(): + self.request_queue.start() + + def disconnect(self): + self.request_queue.stop() + self.request_queue.join() + + # Throw away old thread object, create new one since same thread cannot be started twice + self.request_queue = RequestQueue( + self, persistqueue_path=self.request_queue.persistqueue_path + ) + # Reset so warn-before-connect fires again if user calls queued ops before reconnecting + self._warned_queue_before_connect = False + + def wait_for_start(self, timeout: int = 10) -> None: + """Wait for the server to start by trying to get the server info.""" + start_time = datetime.now() + sleep_time = 0.1 + while (datetime.now() - start_time).seconds < timeout: + try: + self.get_info() + break + except req.exceptions.ConnectionError: + sleep(sleep_time) + sleep_time *= 2 + else: + raise Exception(f"Server at {self.server_address} did not start in time") + + def _warn_queue_before_connect(self) -> None: + if self._warned_queue_before_connect or self.request_queue.is_alive(): + return + + warnings.warn( + "Queued requests require calling connect() or using `with client:` " + "before buckets can be created and queued events can flush.", + UserWarning, + stacklevel=3, + ) + self._warned_queue_before_connect = True + + +QueuedRequest = namedtuple("QueuedRequest", ["endpoint", "data"]) +Bucket = namedtuple("Bucket", ["id", "type"]) + + +class RequestQueue(threading.Thread): + """Used to asynchronously send heartbeats. + + Handles: + - Cases where the server is temporarily unavailable + - Saves all queued requests to file in case of a server crash + """ + + VERSION = 1 # update this whenever the queue-file format changes + + def __init__( + self, + client: ActivityWatchClient, + persistqueue_path: Optional[str] = None, + ) -> None: + threading.Thread.__init__(self, daemon=True) + + self.client = client + + self.connected = False + self._stop_event = threading.Event() + + # Buckets that will have events queued to them, will be created if they don't exist + self._registered_buckets = [] # type: List[Bucket] + + self._attempt_reconnect_interval = 10 + + if persistqueue_path is None: + data_dir = get_data_dir("aw-client") + queued_dir = os.path.join(data_dir, "queued") + if not os.path.exists(queued_dir): + os.makedirs(queued_dir) + + profile = getattr(client, "profile", None) + suffix = ( + profile_suffix(profile) + if profile is not None + else ("-testing" if client.testing else "") + ) + persistqueue_path = os.path.join( + queued_dir, + f"{self.client.client_name}{suffix}.v{self.VERSION}.persistqueue", + ) + + logger.debug(f"queue path '{persistqueue_path}'") + self.persistqueue_path = persistqueue_path + + self._persistqueue = persistqueue.FIFOSQLiteQueue( + persistqueue_path, multithreading=True, auto_commit=False + ) + self._current = None # type: Optional[QueuedRequest] + + def _get_next(self) -> Optional[QueuedRequest]: + # self._current will always hold the next not-yet-sent event, + # until self._task_done() is called. + if not self._current: + try: + self._current = self._persistqueue.get(block=False) + except persistqueue.exceptions.Empty: + return None + return self._current + + def _task_done(self) -> None: + self._current = None + self._persistqueue.task_done() + + def _create_buckets(self) -> None: + for bucket in self._registered_buckets: + self.client.create_bucket(bucket.id, bucket.type) + + def _try_connect(self) -> bool: + try: # Try to connect + self._create_buckets() + self.connected = True + logger.info( + f"Connection to aw-server established by {self.client.client_name}" + ) + except req.RequestException: + self.connected = False + + return self.connected + + def wait(self, seconds) -> bool: + return self._stop_event.wait(seconds) + + def should_stop(self) -> bool: + return self._stop_event.is_set() + + def _dispatch_request(self) -> None: + request = self._get_next() + if not request: + self.wait(0.2) # seconds to wait before re-polling the empty queue + return + + try: + self.client._post(request.endpoint, request.data) + except req.exceptions.ConnectTimeout: + # Triggered by: + # - server not running (connection refused) + # - server not responding (timeout) + # Safe to retry according to requests docs: + # https://requests.readthedocs.io/en/latest/api/#requests.ConnectTimeout + + self.connected = False + logger.warning( + "Connection refused or timeout, will queue requests until connection is available." + ) + # wait a bit before retrying, so we don't spam the server (or logs), see: + # - https://github.com/ActivityWatch/activitywatch/issues/815 + # - https://github.com/ActivityWatch/activitywatch/issues/756#issuecomment-1266662861 + sleep(0.5) + return + except req.RequestException as e: + if e.response and e.response.status_code == 400: + # HTTP 400 - Bad request + # Example case: https://github.com/ActivityWatch/activitywatch/issues/815 + # We don't want to retry, because a bad payload is likely to fail forever. + logger.error(f"Bad request, not retrying: {request.data}") + elif e.response and e.response.status_code == 500: + # HTTP 500 - Internal server error + # It is possible that the server is in a bad state (and will recover on restart), + # in which case we want to retry. I hope this can never caused by a bad payload. + logger.error(f"Internal server error, retrying: {request.data}") + sleep(0.5) + return + else: + logger.exception(f"Unknown error, not retrying: {request.data}") + except Exception: + logger.exception(f"Unknown error, not retrying: {request.data}") + + # Mark the request as done + self._task_done() + + def run(self) -> None: + self._stop_event.clear() + while not self.should_stop(): + # Connect + while not self._try_connect(): + logger.warning( + f"Not connected to server, {self._persistqueue.qsize()} requests in queue" + ) + if self.wait(self._attempt_reconnect_interval): + break + + # Dispatch requests until connection is lost or thread should stop + while self.connected and not self.should_stop(): + self._dispatch_request() + + def stop(self) -> None: + self._stop_event.set() + + def add_request(self, endpoint: str, data: dict) -> None: + """ + Add a request to the queue. + NOTE: Only supports heartbeats + """ + assert "/heartbeat" in endpoint + assert isinstance(data, dict) + self._persistqueue.put(QueuedRequest(endpoint, data)) + + def register_bucket(self, bucket_id: str, event_type: str) -> None: + bucket = Bucket(bucket_id, event_type) + self._registered_buckets.append(bucket) + + if not self.connected: + return + + try: + self.client.create_bucket(bucket_id, event_type) + except req.RequestException: + self.connected = False diff --git a/aw_client/config.py b/aw_client/config.py new file mode 100644 index 0000000..b87851f --- /dev/null +++ b/aw_client/config.py @@ -0,0 +1,206 @@ +import logging +import os +from typing import List, Optional, Tuple, Union + +import platformdirs +import tomlkit +from aw_core.config import load_config_toml + +from .profile import DEFAULT_PROFILE, TESTING_PROFILE, profile_from_env + +logger = logging.getLogger(__name__) + +_DEFAULT_APPNAME = "activitywatch" +_TESTING_APPNAME = "activitywatch-testing" + +# Identical to aw-core#152 / aw-server-rust#652 so python and rust agree on +# the same on-disk state (ActivityWatch/activitywatch#1399). Keep this list +# specific: a false positive would pin a fresh install to legacy forever. +_LEGACY_TESTING_FILENAME_MARKERS = ( + "peewee-sqlite-testing", + "sqlite-testing", + "settings-testing", + "config-testing", + "-testing.db", + "-testing.toml", + "-testing.json", + "_testing_", +) + +default_config = """ +[server] +hostname = "127.0.0.1" +port = "5600" + +[client] +commit_interval = 10 + +[server-testing] +hostname = "127.0.0.1" +port = "5666" + +[client-testing] +commit_interval = 5 +""".strip() + + +def load_config(): + return load_config_toml("aw-client", default_config) + + +def _user_data_dir(appname: str) -> str: + return platformdirs.user_data_dir(appname) + + +def _user_config_dir(appname: str) -> str: + return platformdirs.user_config_dir(appname) + + +def _user_cache_dir(appname: str) -> str: + return platformdirs.user_cache_dir(appname) + + +def _is_legacy_testing_filename(name: str) -> bool: + lower = name.lower() + return any(marker in lower for marker in _LEGACY_TESTING_FILENAME_MARKERS) + + +def _new_testing_root_exists() -> bool: + """True if any platform dir for ``activitywatch-testing`` already exists. + + Must not create directories: existence is the signal that a previous run + already adopted the isolated testing root. + """ + for getter in (_user_data_dir, _user_config_dir, _user_cache_dir): + if os.path.isdir(getter(_TESTING_APPNAME)): + return True + return False + + +def _legacy_testing_artifacts_exist() -> bool: + """True if testing data still lives under the shared ``activitywatch`` root.""" + roots = ( + _user_data_dir(_DEFAULT_APPNAME), + _user_config_dir(_DEFAULT_APPNAME), + _user_cache_dir(_DEFAULT_APPNAME), + ) + for root in roots: + if not os.path.isdir(root): + continue + for dirpath, dirnames, filenames in os.walk(root): + for filename in filenames: + if _is_legacy_testing_filename(filename): + return True + # Descend one level (activitywatch/aw-server-rust/...) not further. + if os.path.relpath(dirpath, root) != ".": + dirnames.clear() + return False + + +def using_legacy_testing_root() -> bool: + """Whether ``profile=testing`` should stay on the shared ``activitywatch`` root. + + Resolution rule (ActivityWatch/activitywatch#1399), identical on python + and rust: + + 1. If ``activitywatch-testing/`` already exists: use it (new layout). + 2. Else if legacy testing artifacts exist in the bare ``activitywatch/`` + root: stay in legacy mode (old paths, old filenames). + 3. Else (fresh setup): create and use ``activitywatch-testing/``. + """ + if _new_testing_root_exists(): + return False + return _legacy_testing_artifacts_exist() + + +def rust_server_config_candidates(profile: str) -> List[Tuple[str, int]]: + """Ordered rust config files to read for this profile. + + Isolated profile roots (including new-style ``activitywatch-testing/``) + use bare ``config.toml`` — the directory already isolates. Suffixed + ``config-testing.toml`` remains only in the legacy shared-root layout. + + Lookup does **not** create directories (``get_config_dir`` would, and + creating ``activitywatch-testing/`` would flip the fallback). Testing + tries the other layout second so a python client that already created + the new root still finds a rust server that wrote the key on the + shared root — the regression Erik named on #1399. + """ + testing_new = ( + os.path.join( + _user_config_dir(_TESTING_APPNAME), "aw-server-rust", "config.toml" + ), + 5666, + ) + testing_legacy = ( + os.path.join( + _user_config_dir(_DEFAULT_APPNAME), + "aw-server-rust", + "config-testing.toml", + ), + 5666, + ) + if profile == TESTING_PROFILE: + if using_legacy_testing_root(): + return [testing_legacy, testing_new] + return [testing_new, testing_legacy] + if profile == DEFAULT_PROFILE or not profile: + return [ + ( + os.path.join( + _user_config_dir(_DEFAULT_APPNAME), + "aw-server-rust", + "config.toml", + ), + 5600, + ) + ] + return [ + ( + os.path.join( + _user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"), + "aw-server-rust", + "config.toml", + ), + 5600, + ) + ] + + +def load_local_server_api_key( + host: str, + port: Union[int, str], + profile: Optional[str] = None, +) -> Optional[str]: + if host not in {"127.0.0.1", "localhost", "::1"}: + return None + + try: + requested_port = int(str(port)) + except (TypeError, ValueError): + return None + + if profile is None: + profile = profile_from_env(False) + + for config_path, default_port in rust_server_config_candidates(profile): + if not os.path.isfile(config_path): + continue + + try: + with open(config_path, encoding="utf-8") as f: + config = tomlkit.parse(f.read()) + configured_port = int(str(config.get("port", default_port))) + if configured_port != requested_port: + continue + + auth_config = config.get("auth", {}) + api_key = auth_config.get("api_key") + if api_key: + return str(api_key) + except Exception as e: + logger.warning( + "Failed to read aw-server-rust config %s: %s", config_path, e + ) + + return None diff --git a/aw_client/profile.py b/aw_client/profile.py new file mode 100644 index 0000000..e0efad7 --- /dev/null +++ b/aw_client/profile.py @@ -0,0 +1,111 @@ +"""Profile resolution for aw-client. + +A *profile* names an isolated ActivityWatch instance (data, config, port, +queue file). `default` is the ordinary install, `testing` is what +`--testing` has always meant, and any other name (for example `research`) +is a sibling instance that can run at the same time as the others. + +The carrier is the ``AW_PROFILE`` environment variable. aw-core's +``_get_appname()`` suffixes the platformdirs root when it is set to a +non-empty value, so exporting the profile here isolates dirs for this +process — without threading a flag through the request queue or REST +client internals. + +Kept in sync with aw-qt's ``aw_qt/profile.py`` and aw-server's +``aw_server/profile.py`` (same validation rule as aw-server-rust). One +intentional difference: the default profile *unsets* ``AW_PROFILE`` +instead of setting it to ``"default"``. aw-core treats any non-empty +value as a suffix, so ``AW_PROFILE=default`` would resolve to +``activitywatch-default`` and orphan an existing install. + +Testing-root note (ActivityWatch/activitywatch#1399): python and rust +resolve ``testing`` identically — new-root-plus-legacy-fallback: + +1. If ``activitywatch-testing/`` exists: use it. +2. Else if legacy testing artifacts exist in the bare ``activitywatch/`` + root: stay in legacy mode. +3. Else (fresh setup): create and use ``activitywatch-testing/``. + +Isolated roots use bare filenames (``config.toml``). Suffixed names +(``config-testing.toml``) stay legacy-only. The rust API-key lookup in +``config.py`` follows this rule so a ``--testing`` client finds the key +where aw-server-rust actually wrote it. +""" + +import os +import re +from typing import Optional + +DEFAULT_PROFILE = "default" +TESTING_PROFILE = "testing" + +#: Same rule as aw-server-rust's `validate_profile`: lowercase alphanumeric +#: plus `-`/`_`, at most 32 chars, so a profile is always a safe path segment. +PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") + +ENV_VAR = "AW_PROFILE" + + +def validate_profile(profile: str) -> str: + """Return the profile unchanged, or raise ValueError if it is not usable.""" + if not PROFILE_RE.match(profile): + raise ValueError( + f"Invalid profile name {profile!r}: expected lowercase alphanumeric " + "with '-' or '_', at most 32 characters" + ) + return profile + + +def resolve_profile(profile: Optional[str], testing: bool) -> str: + """Resolve the effective profile from the CLI flags. + + ``--testing`` is an alias for ``--profile testing``; passing both is only + an error if they disagree. + """ + if profile is None: + return TESTING_PROFILE if testing else DEFAULT_PROFILE + + profile = validate_profile(profile) + if testing and profile != TESTING_PROFILE: + raise ValueError( + f"--testing conflicts with --profile {profile}: --testing is an " + f"alias for --profile {TESTING_PROFILE}" + ) + return profile + + +def is_testing(profile: str) -> bool: + return profile == TESTING_PROFILE + + +def profile_suffix(profile: str) -> str: + """Filename suffix for a profile (``""``, ``"-testing"``, ``"-research"``).""" + return "" if profile == DEFAULT_PROFILE else f"-{profile}" + + +def profile_from_env(testing: bool = False) -> str: + """Read the profile the process was started with. + + Falls back to the `--testing` bool for callers that only track that, so + behaviour is unchanged when no profile was set. + """ + profile = os.environ.get(ENV_VAR) + if not profile: + return TESTING_PROFILE if testing else DEFAULT_PROFILE + try: + return validate_profile(profile) + except ValueError: + return TESTING_PROFILE if testing else DEFAULT_PROFILE + + +def export_profile(profile: str) -> None: + """Publish the profile to this process and its children. + + The default profile leaves ``AW_PROFILE`` unset so aw-core keeps the + bare ``activitywatch`` root. Named profiles (including ``testing``) + set the env var; children inherit it without a CLI flag. + """ + if profile == DEFAULT_PROFILE: + os.environ.pop(ENV_VAR, None) + else: + os.environ[ENV_VAR] = profile diff --git a/aw_client/py.typed b/aw_client/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/aw_client/py.typed @@ -0,0 +1 @@ + diff --git a/aw_client/queries.py b/aw_client/queries.py new file mode 100644 index 0000000..4b8d761 --- /dev/null +++ b/aw_client/queries.py @@ -0,0 +1,334 @@ +""" +Common queries. + +Most of these are from: https://github.com/ActivityWatch/aw-webui/blob/master/src/queries.ts +""" + +import dataclasses +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import ( + List, + Optional, + Tuple, + Union, +) + +from typing_extensions import TypeGuard + +import aw_client + +from .classes import get_classes + + +class EnhancedJSONEncoder(json.JSONEncoder): + """For encoding dataclasses into JSON""" + + def default(self, o): + if dataclasses.is_dataclass(o): + return dataclasses.asdict(o) # type: ignore + return super().default(o) + + +""" +Do these dataclasses look confusing? +Read up on dataclass inheritance: https://stackoverflow.com/a/53085935/965332 +""" + + +@dataclass +class _QueryParamsDefaultsBase: + bid_browsers: List[str] = field(default_factory=list) + classes: List[Tuple[List[str], dict]] = field(default_factory=list) + filter_classes: List[List[str]] = field(default_factory=list) + filter_afk: bool = True + include_audible: bool = True + + +@dataclass +class QueryParams(_QueryParamsDefaultsBase): + pass + + +@dataclass +class _DesktopQueryParamsBase: + bid_window: str + bid_afk: str + always_active_pattern: Optional[str] = None + + +@dataclass +class DesktopQueryParams(QueryParams, _DesktopQueryParamsBase): + pass + + +@dataclass +class _AndroidQueryParamsBase: + bid_android: str + + +@dataclass +class AndroidQueryParams(QueryParams, _AndroidQueryParamsBase): + pass + + +def isDesktopParams(params: QueryParams) -> TypeGuard[DesktopQueryParams]: + return isinstance(params, DesktopQueryParams) + + +def isAndroidParams(params: QueryParams) -> TypeGuard[AndroidQueryParams]: + return isinstance(params, AndroidQueryParams) + + +def canonicalEvents(params: Union[DesktopQueryParams, AndroidQueryParams]) -> str: + if not params.classes: + # if categories not explicitly set, + # get categories from server settings + params.classes = get_classes() + + # Needs escaping for regex patterns like '\w' to work (JSON.stringify adds extra unnecessary escaping) + classes_str = json.dumps(params.classes, cls=EnhancedJSONEncoder) + classes_str = re.sub(r"\\\\", r"\\", classes_str) + + cat_filter_str = json.dumps(params.filter_classes) + + # For simplicity, we assume that bid_window and bid_android are exchangeable (note however it needs special treatment) + bid_window = ( + params.bid_window + if isinstance(params, DesktopQueryParams) + else params.bid_android + ) + + return "\n".join( + [ + # Fetch window/app events + f'events = flood(query_bucket(find_bucket("{bid_window}")));', + # On Android, merge events to avoid overload of events + ( + 'events = merge_events_by_keys(events, ["app"]);' + if isAndroidParams(params) + else "" + ), + # Fetch not-afk events + ( + f""" + not_afk = flood(query_bucket(find_bucket("{params.bid_afk}"))); + not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);""" + + ( + """ + not_treat_as_afk = filter_keyvals_regex(events, "app", "%s"); + not_afk = period_union(not_afk, not_treat_as_afk); + not_treat_as_afk = filter_keyvals_regex(events, "title", "%s"); + not_afk = period_union(not_afk, not_treat_as_afk);""" + % ( + params.always_active_pattern.replace('"', '\\"'), + params.always_active_pattern.replace('"', '\\"'), + ) + if params.always_active_pattern + else "" + ) + if isDesktopParams(params) + else "" + ), + # Fetch browser events + ( + (browserEvents(params) if isDesktopParams(params) else "") + + ( # Include focused and audible browser events as indications of not-afk + """ + audible_events = filter_keyvals(browser_events, "audible", [true]); + not_afk = period_union(not_afk, audible_events); + """ + if params.include_audible + else "" + ) + if params.bid_browsers + else "" + ), + # Filter out window events when the user was afk + ( + "events = filter_period_intersect(events, not_afk);" + if isDesktopParams(params) and params.filter_afk + else "" + ), + # Categorize + f"events = categorize(events, {classes_str});" if params.classes else "", + # Filter out selected categories + ( + f'events = filter_keyvals(events, "$category", {cat_filter_str});' + if params.filter_classes + else "" + ), + ] + ) + + +def pretty_query(query: str) -> str: + return "\n".join([line.strip() for line in query.split("\n") if line.strip()]) + + +def _browser_in_buckets(browser: str, browserbuckets: List[str]) -> Optional[str]: + for bucket in browserbuckets: + if browser in bucket: + return bucket + return None + + +def browsersWithBuckets(browserbuckets: List[str]) -> List[Tuple[str, str]]: + """Returns a list of (browserName, bucketId) pairs for found browser buckets""" + browsername_to_bucketid: List[Tuple[str, Optional[str]]] = [ + (browserName, _browser_in_buckets(browserName, browserbuckets)) + for browserName in browser_appnames + ] + + # Only return browsers for which a bucket could be found + return [t for t in browsername_to_bucketid if t[1]] # type: ignore + + +def browserEvents(params: DesktopQueryParams) -> str: + """Returns a list of active browser events (where the browser was the active window) from all browser buckets""" + code = "browser_events = [];" + + for browserName, bucketId in browsersWithBuckets(params.bid_browsers): + browser_appnames_str = json.dumps(browser_appnames[browserName]) + code += f""" + events_{browserName} = flood(query_bucket("{bucketId}")); + window_{browserName} = filter_keyvals(events, "app", {browser_appnames_str}); + events_{browserName} = filter_period_intersect(events_{browserName}, window_{browserName}); + events_{browserName} = split_url_events(events_{browserName}); + browser_events = concat(browser_events, events_{browserName}); + browser_events = sort_by_timestamp(browser_events); + """ + return code + + +browser_appnames = { + "chrome": [ + # Chrome + "Google Chrome", + "Google-chrome", + "chrome.exe", + "google-chrome-stable", + # Chromium + "Chromium", + "Chromium-browser", + "Chromium-browser-chromium", + "chromium.exe", + # Pre-releases + "Google-chrome-beta", + "Google-chrome-unstable", + # Brave (should this be merged with the brave entry?) + "Brave-browser", + ], + "firefox": [ + "Firefox", + "Firefox.exe", + "firefox", + "firefox.exe", + "Firefox Developer Edition", + "firefoxdeveloperedition", + "Firefox-esr", + "Firefox Beta", + "Nightly", + "org.mozilla.firefox", + ], + "opera": ["opera.exe", "Opera"], + "brave": ["brave.exe"], + "edge": [ + "msedge.exe", # Windows + "Microsoft Edge", # macOS + ], + "vivaldi": ["Vivaldi-stable", "Vivaldi-snapshot", "vivaldi.exe"], +} + +default_limit = 100 + + +def querystr_to_array(querystr: str) -> List[str]: + return [line + ";" for line in querystr.split(";") if line] + + +def escape_doublequote(s: str) -> str: + return s.replace('"', '\\"') + + +def fullDesktopQuery( + params: DesktopQueryParams, +) -> str: + # Escape `"` + params.bid_window = escape_doublequote(params.bid_window) + params.bid_afk = escape_doublequote(params.bid_afk) + params.bid_browsers = [escape_doublequote(bucket) for bucket in params.bid_browsers] + + # Build the base query + query = f""" + {canonicalEvents(params)} + title_events = sort_by_duration(merge_events_by_keys(events, ["app", "title"])); + app_events = sort_by_duration(merge_events_by_keys(title_events, ["app"])); + cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"])); + app_events = limit_events(app_events, {default_limit}); + title_events = limit_events(title_events, {default_limit}); + duration = sum_durations(events); + """ + + # Add browser-related query parts if browser buckets exist + if params.bid_browsers: + query += f""" + browser_events = split_url_events(browser_events); + browser_urls = merge_events_by_keys(browser_events, ["url"]); + browser_urls = sort_by_duration(browser_urls); + browser_urls = limit_events(browser_urls, {default_limit}); + browser_domains = merge_events_by_keys(browser_events, ["$domain"]); + browser_domains = sort_by_duration(browser_domains); + browser_domains = limit_events(browser_domains, {default_limit}); + browser_duration = sum_durations(browser_events); + """ + else: + query += """ + browser_events = []; + browser_urls = []; + browser_domains = []; + browser_duration = 0; + """ + + # Add the return statement + query += """ + RETURN = { + "events": events, + "window": { + "app_events": app_events, + "title_events": title_events, + "cat_events": cat_events, + "active_events": not_afk, + "duration": duration + }, + "browser": { + "domains": browser_domains, + "urls": browser_urls, + "duration": browser_duration + } + }; + """ + return query + + +def test_fullDesktopQuery(): + params = DesktopQueryParams( + bid_window="aw-watcher-window_", + bid_afk="aw-watcher-afk_", + ) + now = datetime.now(tz=timezone.utc) + start = now - timedelta(days=7) + end = now + timeperiods = [(start, end)] + query = fullDesktopQuery(params) + + awc = aw_client.ActivityWatchClient("test") + res = awc.query(query, timeperiods)[0] + events = res["events"] + print(len(events)) + + +if __name__ == "__main__": + test_fullDesktopQuery() diff --git a/aw_client/singleinstance.py b/aw_client/singleinstance.py new file mode 100644 index 0000000..9735bae --- /dev/null +++ b/aw_client/singleinstance.py @@ -0,0 +1,47 @@ +import sys +import os +import logging + +if sys.platform != "win32": + import fcntl + +from aw_core.dirs import get_cache_dir + +logger = logging.getLogger(__name__) + + +class SingleInstance: + """ + This code is taken and modified form the tendo python package + (Python Software Foundation License Version 2) + http://pythonhosted.org/tendo/_modules/tendo/singleton.html + """ + + def __init__(self, client_name): + self.lockfile = os.path.join(get_cache_dir("client_locks"), client_name) + logger.debug("SingleInstance lockfile: " + self.lockfile) + if sys.platform == "win32": + try: + # file already exists, we try to remove (in case previous execution was interrupted) + if os.path.exists(self.lockfile): + os.unlink(self.lockfile) + self.fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR) + except OSError as e: + if e.errno == 13: + logger.error("Another instance is already running, quitting.") + sys.exit(-1) + else: + raise e + else: # non Windows + self.fp = open(self.lockfile, "w") + try: + fcntl.lockf(self.fp, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + logger.error("Another instance is already running, quitting.") + sys.exit(-1) + + def __del__(self): + if sys.platform == "win32": + if hasattr(self, "fd"): + os.close(self.fd) + os.unlink(self.lockfile) diff --git a/examples/email_report.py b/examples/email_report.py new file mode 100644 index 0000000..7a630ba --- /dev/null +++ b/examples/email_report.py @@ -0,0 +1,97 @@ +""" +Sends an email with a summary report (WIP) + +For now, it just sends stdin. +In the future, it will generate a pretty email and send that. + +Requires that an SMTP server is configured through environment variables. + +Example usage: + + $ env OUTPUT_HTML=true python3 examples/working_hours.py | python3 examples/email_report.py +""" + +import smtplib +import os +import sys +from dataclasses import dataclass +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + + +@dataclass +class Recipient: + name: str + email: str + + +def create_msg( + sender: Recipient, + receiver: Recipient, + subject: str, + text: str, + html=None, +) -> MIMEMultipart: + """Based on https://stackoverflow.com/a/882770/965332""" + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = sender.email + msg["To"] = receiver.email + + # Record the MIME types of both parts - text/plain and text/html. + # Also attach parts into message container. + # According to RFC 2046, the last part of a multipart message, in this case + # the HTML message, is best and preferred. + msg.attach(MIMEText(text, "plain")) + if html: + msg.attach(MIMEText(html, "html")) + + return msg + + +def main(read_stdin=True) -> None: + smtp_server = os.environ["SMTP_SERVER"].strip() + smtp_username = os.environ["SMTP_USERNAME"].strip() + smtp_password = os.environ["SMTP_PASSWORD"].strip() + + assert smtp_server, "Environment variable SMTP_SERVER not set" + assert smtp_username, "Environment variable SMTP_USERNAME not set" + assert smtp_password, "Environment variable SMTP_PASSWORD not set" + + sender = Recipient("ActivityWatch (automated script)", "noreply@activitywatch.net") + receiver = Recipient("Erik Bjäreholt", "erik.bjareholt@gmail.com") + + if read_stdin: + # Accepts input from stdin + text = sys.stdin.read() + else: + text = "Just a test. ActivityWatch stats will go here." + + text = text.replace("\n\n", "
") + # text = text.replace("\n\n", "
") + # Create the body of the message (a plain-text and an HTML version). + html = f"""\ + + + +

{text}

+ + + """ + + subject = "Example report from aw-client" + msg = create_msg(sender, receiver, subject, text, html) + + try: + with smtplib.SMTP_SSL(smtp_server, 465) as smtp: + smtp.login(smtp_username, smtp_password) + smtp.send_message(msg) + smtp.quit() + print("Successfully sent email") + except smtplib.SMTPException as e: + print("Error: unable to send email") + print(e) + + +if __name__ == "__main__": + main() diff --git a/examples/load_dataframe.py b/examples/load_dataframe.py new file mode 100644 index 0000000..81ecef8 --- /dev/null +++ b/examples/load_dataframe.py @@ -0,0 +1,66 @@ +""" +Load ActivityWatch data into a dataframe, and export as CSV. +""" + +import os +import socket +from datetime import datetime, timedelta, timezone + +import iso8601 +import pandas as pd +from aw_client import ActivityWatchClient +from aw_client.classes import default_classes +from aw_client.queries import DesktopQueryParams, canonicalEvents + + +def build_query() -> str: + hostname = "fakedata" if os.getenv("CI") else socket.gethostname() + canonicalQuery = canonicalEvents( + DesktopQueryParams( + bid_window=f"aw-watcher-window_{hostname}", + bid_afk=f"aw-watcher-afk_{hostname}", + classes=default_classes, + ) + ) + return f""" + {canonicalQuery} + RETURN = {{"events": events}}; + """ + + +def main() -> None: + now = datetime.now(tz=timezone.utc) + td30d = timedelta(days=30) + + aw = ActivityWatchClient() + print("Querying...") + query = build_query() + data = aw.query(query, [(now - td30d, now)]) + + events = [ + { + "timestamp": e["timestamp"], + "duration": timedelta(seconds=e["duration"]), + **e["data"], + } + for e in data[0]["events"] + ] + + for e in events: + e["$category"] = " > ".join(e["$category"]) + + df = pd.json_normalize(events) + df["timestamp"] = pd.to_datetime(df["timestamp"].apply(iso8601.parse_date)) + df.set_index("timestamp", inplace=True) + + print(df) + + answer = input("Do you want to export to CSV? (y/N): ") + if answer == "y": + filename = "output.csv" + df.to_csv(filename) + print(f"Wrote to {filename}") + + +if __name__ == "__main__": + main() diff --git a/examples/merge_buckets.py b/examples/merge_buckets.py new file mode 100644 index 0000000..7fda79a --- /dev/null +++ b/examples/merge_buckets.py @@ -0,0 +1,67 @@ +from datetime import timedelta + +import aw_client +from aw_transform.filter_period_intersect import _intersecting_eventpairs + + +def main(): + """ + Inserts all events from one bucket into another bucket, after checking for + overlap (which you shouldn't have if it was caused by a changing hostname). + + Useful to fix duplicate buckets caused by a changing hostname, as in this issue: + https://github.com/ActivityWatch/activitywatch/issues/454 + """ + + # You need to set testing=False if you're going to run this on your normal instance + aw = aw_client.ActivityWatchClient(testing=True) + + buckets = aw.get_buckets() + print("Available bucket IDs:") + print() + for id in buckets.keys(): + print(id) + print() + + src_id = input("Source bucket ID: ") + dest_id = input("Destination bucket ID: ") + + src_events = aw.get_events(src_id) + print(f"✓ src events: {len(src_events)}") + dest_events = aw.get_events(dest_id) + print(f"✓ dest events: {len(dest_events)}") + + print("Checking overlap...") + overlaps = list(_intersecting_eventpairs(src_events, dest_events)) + if overlaps: + total_duration_src = sum((e.duration for e in src_events), timedelta()) + total_overlap = sum((tp.duration for _, _, tp in overlaps), timedelta()) + print( + f"Buckets had overlap ({total_overlap} out of {total_duration_src}), can't safely merge, exiting." + ) + exit(1) + print("No overlap detected, continuing...") + + print("You want to merge these two buckets:") + print(f" - {src_id}") + print(f" - {dest_id}") + print( + "WARNING: you should backup/export the buckets before attempting this operation" + ) + if input("Does that look right? (y/n): ") != "y": + print("Aborting") + exit(1) + + print("Inserting source events into destination bucket...") + aw.insert_events(dest_id, src_events) + + print("Operation complete") + if input("Do you want to delete the source bucket? (y/n): ") == "y": + aw.delete_bucket(src_id, force=True) + print("Bucket deleted") + + print("Exiting") + + +if __name__ == "__main__": + main() diff --git a/examples/redact_sensitive.py b/examples/redact_sensitive.py new file mode 100644 index 0000000..a7e3a80 --- /dev/null +++ b/examples/redact_sensitive.py @@ -0,0 +1,147 @@ +""" +Interactive script to redact sensitive data. +Be careful to not delete stuff you want to keep! + +Issues/improvements: + - If an event matches the sensitive string, only the sensitive field will be redacted (so if the title matches but not the URL, the URL will remain unredacted) + - One might not want to redact to the non-informative 'REDACTED', but instead to a string with secret meaning. + - No preview of the events/strings to be redacted. +""" + +import re +import sys +from copy import deepcopy +from typing import ( + List, + Pattern, + Set, + Union, + cast, +) + +from aw_client import ActivityWatchClient +from aw_core import Event + +aw: ActivityWatchClient + +REDACTED = "REDACTED" +DRYRUN = True + + +def main(): + global DRYRUN + if "--wet" in sys.argv: + DRYRUN = False + + global aw + aw = ActivityWatchClient(testing=True) + + buckets = aw.get_buckets() + print("Buckets: ") + print("\n".join([" - " + bid for bid in buckets.keys()]) + "\n") + + bid_to_redact = input( + "In which bucket are the events you want to redact? (* for all): " + ) + assert bid_to_redact == "*" or bid_to_redact in buckets, "Not a valid option" + + regex_or_string = input( + "Do you want to search by regex or string? (regex/string): " + ) + assert regex_or_string in ["regex", "string"], "Not a valid option" + + print("\nNOTE: Matching is not case sensitive!") + pattern: Union[str, Pattern] + if regex_or_string == "string": + pattern = input("Enter a string indicating sensitive content: ").lower() + else: + pattern = re.compile( + input("Enter a regex indicating sensitive content: ").lower() + ) + + print("") + if DRYRUN: + print( + "NOTE: Performing a dry run, no events will be modified. Run with --wet to modify events." + ) + else: + print( + "WARNING: Note that this performs an operation that cannot be undone. We strongly recommend that you backup/export your data before proceeding." + ) + input("Press ENTER to continue, or Ctrl-C to abort") + + if bid_to_redact == "*": + for bucket_id in buckets.keys(): + if bucket_id.startswith("aw-watcher-afk"): + continue + _redact_bucket(bucket_id, pattern) + else: + _redact_bucket(bid_to_redact, pattern) + + +def _redact_bucket(bucket_id: str, pattern: Union[str, Pattern]): + print(f"\nChecking bucket: {bucket_id}") + + global aw + events = aw.get_events(bucket_id, limit=-1) + sensitive_ids = _find_sensitive(events, pattern) + print(f"Found {len(sensitive_ids)} sensitive events") + + if not sensitive_ids: + return + + yes_redact = input( + f"Do you want to replace all the matching strings with '{REDACTED}'? (y/N): " + ) + if yes_redact == "y": + for e in events: + if e.id in sensitive_ids: + e_before = e + e = _redact_event(e, pattern) + print(f"\nData before: {e_before.data}") + print(f"Data after: {e.data}") + + if DRYRUN: + print("DRYRUN, would do: aw.insert_event(bucket_id, e)") + else: + aw.delete_event(bucket_id, cast(int, e_before.id)) + aw.insert_event(bucket_id, e) + print("Redacted event") + + +def _check_event(e: Event, pattern: Union[str, Pattern]) -> bool: + for v in e.data.values(): + if isinstance(v, str): + if isinstance(pattern, str): + if pattern in v.lower(): + return True + else: + if pattern.findall(v.lower()): + return True + return False + + +def _redact_event(e: Event, pattern: Union[str, Pattern]) -> Event: + e = deepcopy(e) + for k, v in e.data.items(): + if isinstance(v, str): + if isinstance(pattern, str): + if pattern in v.lower(): + e.data[k] = REDACTED + else: + if pattern.findall(v.lower()): + e.data[k] = REDACTED + return e + + +def _find_sensitive(el: List[Event], pattern: Union[str, Pattern]) -> Set: + sensitive_ids = set() + for e in el: + if _check_event(e, pattern): + sensitive_ids.add(e.id) + + return sensitive_ids + + +if __name__ == "__main__": + main() diff --git a/examples/suggest_categories.py b/examples/suggest_categories.py new file mode 100644 index 0000000..d6fbf46 --- /dev/null +++ b/examples/suggest_categories.py @@ -0,0 +1,88 @@ +""" +Lists the most common words among uncategorized events, by duration, to help in creating categories. + +This might make more sense as a notebook. +""" + +from collections import Counter +from datetime import datetime, timedelta, timezone +from tabulate import tabulate +from typing import Dict, List, Tuple, Any + +from aw_core import Event +import aw_client +from aw_client import queries + +# set up client +awc = aw_client.ActivityWatchClient("test") + + +def example_categories(): + # TODO: Use tools in aw-research to load categories from toml file + return [ + ( + ("Work", "ActivityWatch"), + {"type": "regex", "regex": "aw-|activitywatch", "ignore_case": True}, + ), + ] + + +def get_events(categories=List[Tuple[Tuple[str], Dict[str, Any]]]): + """ + Retrieves AFK-filtered events, only returns events which are Uncategorized. + """ + + start = datetime(2022, 1, 1, tzinfo=timezone.utc) + now = datetime.now(tz=timezone.utc) + timeperiods = [(start, now)] + + canonicalQuery = queries.canonicalEvents( + queries.DesktopQueryParams( + bid_window="aw-watcher-window_", + bid_afk="aw-watcher-afk_", + classes=categories, + ) + ) + res = awc.query( + f""" + {canonicalQuery} + events = filter_keyvals(events, "$category", [["Uncategorized"]]); + duration = sum_durations(events); + RETURN = {{"events": events, "duration": duration}}; + """, + timeperiods, + ) + events = res[0]["events"] + print(f"Fetched {len(events)} events") + return [Event(**e) for e in events] + + +def events2words(events): + for e in events: + for v in e.data.values(): + if isinstance(v, str): + for word in v.split(): + if len(word) >= 3: + # normalize + word = word.lower() + yield (word, e.duration) + + +def main(): + categories = example_categories() + events = get_events(categories) + + # find most common words, by duration + corpus: Dict[str, timedelta] = Counter() # type: ignore + for word, duration in events2words(events): + if word not in corpus: + corpus[word] = timedelta(0) + corpus[word] += duration + + # The top words are rarely useful for categorization, as they are usually browsers and other categories + # of activity which are too broad for it to make sense as a rule (except as a fallback). + print(tabulate(corpus.most_common(50), headers=["word", "duration"])) # type: ignore + + +if __name__ == "__main__": + main() diff --git a/examples/suggest_categories_gpt.py b/examples/suggest_categories_gpt.py new file mode 100644 index 0000000..6112016 --- /dev/null +++ b/examples/suggest_categories_gpt.py @@ -0,0 +1,213 @@ +""" +Uses GPT-3 to suggest new categories. + +Builds on suggest_categories.py for basic operations, like getting events. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, List, Tuple + +from aw_core import Event +from aw_transform.classify import Rule, categorize + +from suggest_categories import example_categories, get_events + +Category = Tuple[List[str], Dict[str, Any]] + + +def prompt_preamble(categories: List[Category]) -> str: + categories_str = "\n\n".join( + [ + f" - Category: {' > '.join(name)}\n Regex: {rule['regex']}" + for name, rule in categories + ] + ) + + prompt = f"""We will classify window titles into user-defined categories defined by regular expressions. +If a suitable one doesn't exists, we will create one with a suitable regex. + +Existing categories: + +{categories_str} + +--- + +What category should "ActivityWatch - wwww.github.com" be in? +Category: Work > ActivityWatch + +What category should "reddit: the front page of the internet" be in? +New Category: Media > Social > Reddit +Regex: reddit + +What category should "Twitter" be in? +New Category: Media > Social > Twitter +Regex: Twitter + +What category should "Demis Hassabis: DeepMind - AI, Superintelligence & the Future of Humanity | Lex Fridman Podcast - YouTube - Mozilla Firefox" be in? +New Category: Media > Video > YouTube +Regex: YouTube + +What category should "Tweetdeck" be in? +Modify Category: Media > Social > Twitter +Append Regex: Tweetdeck + +What category should "cloudflare-ipfs.com | 524: A timeout occurred - cloudflare-ipfs.com - Mozilla Firefox" be in? +Skip: No suitable category found or to suggest, best left as uncategorized. + +What category should "Mozilla Firefox" be in? +Skip: No suitable category found or to suggest, best left as uncategorized. + +What category should "RimWorld" be in? +New Category: Games > RimWorld +Regex: RimWorld + +What category should "Minecraft" be in? +New Category: Games > Minecraft +Regex: Minecraft + +What category should "Free Porn Videos & Sex Movies - Porno, XXX, Porn Tube | Pornhub — Mozilla Firefox" be in? +New Category: Media > Porn +Regex: Pornhub""" + + return prompt + + +def process_prompt(prompt, categories, quiet=False) -> List[Category]: + """processes the prompt preamble for categories created/modified in the prompt""" + for entry in prompt.split("---", 1)[1].split("\n\n"): + if not entry.strip(): + continue + # FIXME: Will break if string contains double-quotes + title = entry.split("\n", 1)[0].split('"', 2)[1] + response = entry.strip().split("\n", 1)[1] + categories = parse_gpt_response(response, categories, title=title, quiet=quiet) + return categories + + +def gpt_suggest(event: Event, categories: List[Category]) -> List[Category]: + """ + Use OpenAI GPT-3 to suggest new categories. + + Takes an uncategorized event and the current categories, and returns a list of suggested categories. + """ + import os + import openai + from copy import deepcopy + + openai.api_key = os.getenv("OPENAI_API_KEY") + + title = event.data["title"] + categories = deepcopy(categories) + + prompt = prompt_preamble(categories) + categories = process_prompt(prompt, categories, quiet=True) + + prompt = f"""{prompt} + +What category should "{title}" be in?""" + + response = openai.Completion.create( + model="text-davinci-002", + prompt=prompt, + temperature=0, + max_tokens=64, + top_p=1.0, + frequency_penalty=0.0, + presence_penalty=0.0, + ) + text = response["choices"][0]["text"] + print("-" * 80) + # print(prompt + text) + # print("> " + text.strip().replace("\n", "\n> ")) + + return parse_gpt_response(text, categories, title) + + +def check_is_category(text: str, category: Category) -> bool: + name, rule = category + event = categorize( + [Event(timestamp=datetime.now(tz=timezone.utc), data={"title": text})], + [(list(name), Rule(rule))], + )[0] + return event.data["$category"] == list(category[0]) + + +def parse_gpt_response(text: str, categories: List[Category], title=None, quiet=False): + category_names = [tuple(name) for name, _ in categories] + + line1, *lines = text.strip().split("\n") + if line1.startswith("Category:"): + # chose existing category + cat_name = tuple(line1.split(":", 1)[1].strip().split(" > ")) + if not quiet: + print(f"Chose existing category {cat_name} (title: {title})") + if cat_name not in [tuple(name[: len(cat_name)]) for name in category_names]: + print(f"No category named {cat_name} found, skipping") + elif line1.startswith("New Category:"): + line2 = lines[0] + category = line1.split(":", 1)[1].strip().split(" > ") + regex = line2.strip().split(":", 1)[1].strip() + if not quiet: + print(f"Added category {category} with regex {regex} (title: {title})") + cat: Category = ( + category, + {"type": "regex", "regex": regex, "ignore_case": True}, + ) + if title and not check_is_category(title, cat): + print( + f"Bad suggested regex '{cat[1]['regex']}'. Title '{title}' does not match category {category}." + ) + else: + categories.append(cat) + elif line1.startswith("Modify Category:"): + line2 = lines[0] + category = line1.split(":", 1)[1].strip().split(" > ") + assert line2.startswith("Append Regex:") + regex = line2.strip().split(":", 1)[1].strip() + # get existing category + for name, rule in categories: + if name == category: + rule["regex"] += "|" + regex + if not quiet: + print(f"Appended {regex} to {category}") + break + elif line1.startswith("Skip:"): + pass + else: + print(f"Unknown response: '{text.strip()}'") + + return categories + + +def main(): + categories = example_categories() + events = get_events(categories) + + events_by_dur = sorted(events, key=lambda e: e.duration, reverse=True) + for event in events_by_dur[:100]: + # re-categorize and skip event if it is already categorized by a new rule + event, *_ = categorize( + [event], [(list(name), Rule(rule)) for name, rule in categories] + ) + if list(event.data["$category"]) != ["Uncategorized"]: + continue + + categories = gpt_suggest(event, categories) + + +def test_parse_gpt_response(): + categories = example_categories() + prompt = prompt_preamble(categories) + categories = process_prompt(prompt, categories) + + cat_twitter = list( + (name, rule) + for name, rule in categories + if tuple(name) == ("Media", "Social", "Twitter") + ) + assert len(cat_twitter) == 1 + assert cat_twitter[0][1]["regex"] == "Twitter|Tweetdeck" + + +if __name__ == "__main__": + main() diff --git a/examples/time_spent_today.py b/examples/time_spent_today.py new file mode 100644 index 0000000..17a966a --- /dev/null +++ b/examples/time_spent_today.py @@ -0,0 +1,16 @@ +from datetime import datetime, time, timedelta, timezone +import socket + +import aw_client + +if __name__ == "__main__": + bucket_id = f"aw-watcher-afk_{socket.gethostname()}" + + daystart = datetime.combine(datetime.now().date(), time()).astimezone(timezone.utc) + dayend = daystart + timedelta(days=1) + + awc = aw_client.ActivityWatchClient("testclient") + events = awc.get_events(bucket_id, start=daystart, end=dayend) + events = [e for e in events if e.data["status"] == "not-afk"] + total_duration = sum((e.duration for e in events), timedelta()) + print(f"Total time spent on computer today: {total_duration}") diff --git a/examples/working_hours.py b/examples/working_hours.py new file mode 100644 index 0000000..a6d10c5 --- /dev/null +++ b/examples/working_hours.py @@ -0,0 +1,157 @@ +""" +Script that computes how many hours was spent in a regex-specified "work" category for each day in a given month. + +Also saves the matching work-events to a JSON file (for auditing purposes). +""" + +import json +import logging +import os +import re +import socket +import sys +from datetime import datetime, time, timedelta +from typing import Dict, List, Tuple + +import aw_client +from aw_client import queries +from aw_core import Event +from aw_transform import flood +from tabulate import tabulate + +OUTPUT_HTML = os.environ.get("OUTPUT_HTML", "").lower() == "true" + +td1d = timedelta(days=1) +day_offset = timedelta(hours=4) + + +def _pretty_timedelta(td: timedelta) -> str: + s = str(td) + s = re.sub(r"^(0+[:]?)+", "", s) + s = s.rjust(len(str(td)), " ") + s = re.sub(r"[.]\d+", "", s) + return s + + +assert _pretty_timedelta(timedelta(seconds=120)) == " 2:00" +assert _pretty_timedelta(timedelta(hours=9, minutes=5)) == "9:05:00" + + +def generous_approx(events: List[dict], max_break: float) -> timedelta: + """ + Returns a generous approximation of worked time by including non-categorized time when shorter than a specific duration + + max_break: Max time (in seconds) to flood when there's an empty slot between events + """ + events_e: List[Event] = [Event(**e) for e in events] + return sum( + map(lambda e: e.duration, flood(events_e, max_break)), + timedelta(), + ) + + +def query(regex: str, timeperiods, hostname: str): + print("Querying events...") + print(f" Day offset: {day_offset}") + print("") + + categories: List[Tuple[List[str], Dict]] = [ + ( + ["Work"], + { + "type": "regex", + "regex": regex, + "ignore_case": True, + }, + ) + ] + + aw = aw_client.ActivityWatchClient(client_name="working_hours") + + canonicalQuery = queries.canonicalEvents( + queries.DesktopQueryParams( + bid_window=f"aw-watcher-window_{hostname}", + bid_afk=f"aw-watcher-afk_{hostname}", + classes=categories, + filter_classes=[["Work"]], + ) + ) + query = f""" + {canonicalQuery} + duration = sum_durations(events); + RETURN = {{"events": events, "duration": duration}}; + """ + + res = aw.query(query, timeperiods) + + return res + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 working_hours.py [hostname]") + exit(1) + + regex = sys.argv[1] + print(f"Using regex: {regex}") + + if len(sys.argv) > 2: + hostname = sys.argv[2] + print(f"Using hostname: {hostname}") + else: + hostname = socket.gethostname() + + now = datetime.now().astimezone() + today = (datetime.combine(now.date(), time()) + day_offset).astimezone() + + timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(5)] + timeperiods.reverse() + + res = query(regex, timeperiods, hostname) + + for break_time in [0, 5 * 60, 15 * 60]: + _print(timeperiods, res, break_time, {"regex": regex}) + + fn = "working_hours_events.json" + with open(fn, "w") as f: + print(f"Saving to {fn}...") + json.dump(res, f, indent=2) + + +def _print(timeperiods, res, break_time, params: dict): + print("Using:") + print(f" break_time={break_time}") + print("\n".join(f" {key}={val}" for key, val in params.items())) + print( + tabulate( + [ + [ + start.date(), + # Without flooding: + # _pretty_timedelta(timedelta(seconds=res[i]["duration"])), + # With flooding: + _pretty_timedelta(generous_approx(res[i]["events"], break_time)), + len(res[i]["events"]), + ] + for i, (start, stop) in enumerate(timeperiods) + ], + headers=["Date", "Duration", "Events"], + colalign=( + "left", + "right", + ), + tablefmt="html" if OUTPUT_HTML else "simple", + ) + ) + + print( + f"Total: {sum((generous_approx(res[i]['events'], break_time) for i in range(len(timeperiods))), timedelta())}" + ) + print("") + + +if __name__ == "__main__": + # ignore log warnings in aw_transform + logging.getLogger("aw_transform").setLevel(logging.ERROR) + + main() diff --git a/examples/working_hours_gspread.py b/examples/working_hours_gspread.py new file mode 100644 index 0000000..b28abdc --- /dev/null +++ b/examples/working_hours_gspread.py @@ -0,0 +1,129 @@ +""" +This script uses ActivityWatch events to updates a Google Sheet +with the any events matching a regex for the last `days_back` days. + +It uses the `working_hours.py` example to calculate the working hours +and the `gspread` library to interact with Google Sheets. + +The Google Sheet is identified by its key, which is hardcoded in the script. +The script uses a service account for authentication with the Google Sheets API. + +The script assumes that the Google Sheet has a worksheet for each hostname, named "worked-{hostname}". +If such a worksheet does not exist, the script will fail. + +The working hours are calculated generously, meaning that if the time between two consecutive +events is less than `break_time` (10 minutes by default), it is considered as working time. + +Usage: + python3 working_hours_gspread.py +""" + +import socket +import sys +from datetime import datetime, time, timedelta +from typing import Union + +import gspread +from gspread.utils import ValueInputOption + +import working_hours + +td1d = timedelta(days=1) +break_time = 10 * 60 + + +def update_sheet(sheet_key: str, regex: str): + """ + Update the Google Sheet with the working hours for the last `days_back` days. + + 1. Open the sheet and get the last entry + 2. Query the working hours for the days since the last entry + 3. Update the last entry in the Google Sheet (if any) + 4. Append any new entries + """ + + hostname = socket.gethostname() + hostname_display = hostname.replace(".localdomain", "").replace(".local", "") + + try: + gc = gspread.service_account() + except Exception as e: + print(e) + print( + "Failed to authenticate with Google Sheets API.\n" + "Make sure you have a service account key in ~/.config/gspread/service_account.json\n" + "See https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account" + ) + exit(1) + + # Open the sheet + sh = gc.open_by_key(sheet_key) + print(f"Updating document: {sh.title}") + worksheet = sh.worksheet(f"worked-{hostname_display}") + print(f"Updating worksheet: {worksheet.title}") + + # Get the most recent entry from the Google Sheet + values = worksheet.get_all_values() + if values: + last_row = values[-1] + last_date = datetime.strptime(last_row[0], "%Y-%m-%d").date() + else: + last_date = None + + last_datetime = ( + (datetime.combine(last_date, time()) + working_hours.day_offset).astimezone() + if last_date + else None + ) + + if last_datetime: + print(f"Last entry: {last_datetime}") + + now = datetime.now().astimezone() + today = ( + datetime.combine(now.date(), time()) + working_hours.day_offset + ).astimezone() + + # Create a list of time periods to query, from last_date or days_back_on_new back if None + days_back_on_new = 30 + days_back = (today - last_datetime).days + 1 if last_datetime else days_back_on_new + timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(days_back)] + timeperiods.reverse() + + # Run the query function from the original script and get the result + res = working_hours.query(regex, timeperiods, hostname) + + # Iterate over the result and update or append the data to the Google Sheet + for tp, r in zip(timeperiods, res): + # skip if the timeperiod is in the future (might happen between midnight and the day_offset) + if tp[0] > now: + continue + + date = tp[0].date() + duration = ( + working_hours.generous_approx(r["events"], break_time).total_seconds() + / 3600 + ) + row: list[Union[str, float]] = [str(date), duration] + + # If the date is the same as the last entry, update it + if last_date and date == last_date: + print(f"Updating {row}") + worksheet.update_cell(len(worksheet.get_all_values()), 2, duration) + # If the date is later than the last entry, append it + elif not last_date or date > last_date: + print(f"Appending {row}") + worksheet.append_row(row, value_input_option=ValueInputOption.user_entered) + else: + print(f"Skipping {row}") + + +if __name__ == "__main__": + if len(sys.argv) == 3: + sheet_key = sys.argv[1] + regex = sys.argv[2] + else: + print("Usage: python3 working_hours_gspread.py ") + exit(1) + + update_sheet(sheet_key, regex) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..adec5a2 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1112 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +groups = ["main"] +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 ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] + +[[package]] +name = "aw-core" +version = "0.5.17" +description = "Core library for ActivityWatch" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +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 = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main", "dev"] +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" +groups = ["main", "dev"] +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" +groups = ["main"] +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" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "deprecation" +version = "2.1.0" +description = "A library to handle automated deprecations" +optional = false +python-versions = "*" +groups = ["main"] +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" +groups = ["dev"] +markers = "python_version < \"3.11\"" +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 = "google-auth" +version = "2.35.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "google_auth-2.35.0-py2.py3-none-any.whl", hash = "sha256:25df55f327ef021de8be50bad0dfd4a916ad0de96da86cd05661c9297723ad3f"}, + {file = "google_auth-2.35.0.tar.gz", hash = "sha256:f4c64ed4e01e8e8b646ef34c018f8bf3338df0c8e37d8b3bba40e7f574a3278a"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.1" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "google_auth_oauthlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:2d58a27262d55aa1b87678c3ba7142a080098cbc2024f903c62355deb235d91f"}, + {file = "google_auth_oauthlib-1.2.1.tar.gz", hash = "sha256:afd0cad092a2eaa53cd8e8298557d6de1034c6cb4a740500b5357b648af97263"}, +] + +[package.dependencies] +google-auth = ">=2.15.0" +requests-oauthlib = ">=0.7.0" + +[package.extras] +tool = ["click (>=6.0.0)"] + +[[package]] +name = "gspread" +version = "6.1.3" +description = "Google Spreadsheets Python API" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "gspread-6.1.3-py3-none-any.whl", hash = "sha256:d3b45ea70db9723ac04259bf2650881b0568b943fd04a7f161e88d97ab21bd29"}, + {file = "gspread-6.1.3.tar.gz", hash = "sha256:cf03627f44e9e03a0a3de241d1748709db22af4fc8c11a13aa389d0bce6053fd"}, +] + +[package.dependencies] +google-auth = ">=1.12.0" +google-auth-oauthlib = ">=0.4.1" + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main", "dev"] +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-resources" +version = "6.4.5" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"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) ; sys_platform != \"cygwin\""] +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" +groups = ["dev"] +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" +groups = ["main"] +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" +groups = ["main"] +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.3.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" +groups = ["main"] +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 = "mypy" +version = "1.12.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +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" +groups = ["dev"] +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 = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +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 = "*" +groups = ["main"] +files = [ + {file = "peewee-3.17.7.tar.gz", hash = "sha256:6aefc700bd530fc6ac23fa19c9c5b47041751d92985b799169c8e318e97eabaa"}, +] + +[[package]] +name = "persist-queue" +version = "1.0.0" +description = "A thread-safe disk based persistent queue in Python." +optional = false +python-versions = "*" +groups = ["main"] +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" +groups = ["main"] +markers = "python_version == \"3.8\"" +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" +groups = ["main"] +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" +groups = ["dev"] +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 = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + +[[package]] +name = "pytest" +version = "8.3.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +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 = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "referencing" +version = "0.35.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +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" +groups = ["main", "dev"] +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 = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +groups = ["dev"] +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[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.*" +groups = ["main"] +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" +groups = ["main"] +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 = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +groups = ["dev"] +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "ruff" +version = "0.6.9" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, + {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, + {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, + {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, + {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, + {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, + {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, +] + +[[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.*" +groups = ["main"] +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 = "*" +groups = ["main"] +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" +groups = ["main"] +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" +groups = ["main"] +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" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +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" +groups = ["main"] +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 = "types-requests" +version = "2.32.0.20241016" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, + {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, +] + +[package.dependencies] +urllib3 = ">=2" + +[[package]] +name = "types-tabulate" +version = "0.9.0.20240106" +description = "Typing stubs for tabulate" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "types-tabulate-0.9.0.20240106.tar.gz", hash = "sha256:c9b6db10dd7fcf55bd1712dd3537f86ddce72a08fd62bb1af4338c7096ce947e"}, + {file = "types_tabulate-0.9.0.20240106-py3-none-any.whl", hash = "sha256:0378b7b6fe0ccb4986299496d027a6d4c218298ecad67199bbd0e2d7e9d335a1"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +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" +groups = ["main", "dev"] +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) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "zipp" +version = "3.20.2" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"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) ; sys_platform != \"cygwin\""] +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 ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.8" +content-hash = "74f11d269867cf052fc106619d455dd75283b5b3dd2f1dab67606b5d48916786" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..313b7f5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[tool.poetry] +name = "aw-client" +version = "0.5.15" +description = "Client library for ActivityWatch" +authors = ["Erik Bjäreholt ", "Johan Bjäreholt "] +license = "MPL-2.0" +readme = "README.md" +homepage = "https://activitywatch.net/" +repository = "https://github.com/ActivityWatch/aw-client/" +documentation = "https://docs.activitywatch.net/" +packages = [ + { include = "aw_client" }, +] + +[tool.poetry.scripts] +aw-client = "aw_client.cli:main" + +[tool.poetry.dependencies] +python = "^3.8" +aw-core = "^0.5.16" +requests = "*" +persist-queue = "*" +click = "^8.0" +tabulate = "*" +tomlkit = "*" +typing-extensions = "*" + +[tool.poetry.group.dev.dependencies] +mypy = "*" +ruff = "*" +pytest = "*" +pytest-cov = "*" +types-requests = "*" +types-tabulate = "*" +gspread = "*" # used in examples + +[tool.mypy] +files = ["aw_client", "tests", "examples"] +ignore_missing_imports = true +check_untyped_defs = true + +[tool.ruff] +target-version = "py38" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "B", "UP"] +ignore = ["E402", "E501", "B905"] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..98a8abc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import os + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_aw_profile_after_test(): + """export_profile() writes os.environ directly; monkeypatch.delenv does + not record an undo when the var was already unset, so later tests would + inherit a leftover profile.""" + yield + os.environ.pop("AW_PROFILE", None) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..c71b3ea --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,104 @@ +from typing import Any, Dict, Optional + +from aw_client import ActivityWatchClient +from aw_client.config import load_local_server_api_key +from aw_client import client as client_module + + +class DummyResponse: + def __init__(self, data: Optional[Dict[str, Any]] = None): + self._data = data or {} + + def raise_for_status(self) -> None: + return None + + def json(self) -> Dict[str, Any]: + return self._data + + +def write_server_config(tmp_path, filename: str, content: str) -> None: + config_dir = tmp_path / "activitywatch" / "aw-server-rust" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / filename).write_text(content) + + +def test_load_local_server_api_key_matches_port(tmp_path, monkeypatch): + monkeypatch.setattr( + "aw_client.config._user_config_dir", + lambda appname: str(tmp_path / appname), + ) + monkeypatch.setattr( + "aw_client.config._user_data_dir", + lambda appname: str(tmp_path / "data" / appname), + ) + monkeypatch.setattr( + "aw_client.config._user_cache_dir", + lambda appname: str(tmp_path / "cache" / appname), + ) + write_server_config( + tmp_path, + "config.toml", + 'port = 5601\n\n[auth]\napi_key = "secret123"\n', + ) + write_server_config( + tmp_path, + "config-testing.toml", + 'port = 5666\n\n[auth]\napi_key = "testing-secret"\n', + ) + + assert load_local_server_api_key("127.0.0.1", 5601) == "secret123" + assert ( + load_local_server_api_key("localhost", "5666", profile="testing") + == "testing-secret" + ) + assert load_local_server_api_key("::1", 5601) == "secret123" + assert load_local_server_api_key("127.0.0.1", 5600) is None + assert load_local_server_api_key("example.com", 5601) is None + # Default profile must not pick up the testing server's key. + assert load_local_server_api_key("localhost", "5666") is None + + +def test_client_sends_authorization_header_for_local_server(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + write_server_config( + tmp_path, + "config.toml", + 'port = 5600\n\n[auth]\napi_key = "secret123"\n', + ) + monkeypatch.setattr(client_module, "SingleInstance", lambda name: object()) + + captured = {} + + def fake_get(url, params=None, headers=None): + captured["url"] = url + captured["headers"] = headers + return DummyResponse({"hostname": "test-host", "testing": False}) + + monkeypatch.setattr(client_module.req, "get", fake_get) + + client = ActivityWatchClient("test-client", host="127.0.0.1", port=5600) + assert client.get_info()["hostname"] == "test-host" + assert captured["url"] == "http://127.0.0.1:5600/api/0/info" + assert captured["headers"]["Authorization"] == "Bearer secret123" + + +def test_client_skips_authorization_header_for_remote_server(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + write_server_config( + tmp_path, + "config.toml", + 'port = 5600\n\n[auth]\napi_key = "secret123"\n', + ) + monkeypatch.setattr(client_module, "SingleInstance", lambda name: object()) + + captured = {} + + def fake_get(url, params=None, headers=None): + captured["headers"] = headers + return DummyResponse({"hostname": "remote-host", "testing": False}) + + monkeypatch.setattr(client_module.req, "get", fake_get) + + client = ActivityWatchClient("test-client", host="aw.example.com", port=5600) + assert client.get_info()["hostname"] == "remote-host" + assert "Authorization" not in captured["headers"] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100755 index 0000000..8136a37 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +import time +import warnings +from random import random +from datetime import datetime, timedelta, timezone +from requests.exceptions import HTTPError + +import pytest + +from aw_core.models import Event +from aw_client import ActivityWatchClient + + +def create_unique_event(): + return Event( + timestamp=datetime.now(timezone.utc), + duration=timedelta(), + data={"label": str(random())}, + ) + + +def test_full(): + now = datetime.now(timezone.utc) + + client_name = "aw-test-client" + bucket_name = "test-bucket" + bucket_etype = "test" + + # Test context manager + with ActivityWatchClient(client_name, testing=True) as client: + time.sleep(1) + + # Check that client name is set correctly + assert client.client_name == client_name + + # Delete bucket before creating it, and handle error if it doesn't already exist + try: + client.delete_bucket(bucket_name) + except HTTPError: + pass + + # Create bucket + client.create_bucket(bucket_name, bucket_etype) + + # Check bucket + buckets = client.get_buckets() + assert bucket_name in buckets + assert bucket_name == buckets[bucket_name]["id"] + assert bucket_etype == buckets[bucket_name]["type"] + + # Insert events + e1 = create_unique_event() + e2 = create_unique_event() + e3 = create_unique_event() + events = [e1, e2, e3] + client.insert_events(bucket_name, events) + + # Get events + fetched_events = client.get_events(bucket_name, limit=len(events)) + + # Assert events + assert [(e.timestamp, e.duration, e.data) for e in fetched_events] == [ + (e.timestamp, e.duration, e.data) for e in events + ] + + # Check eventcount + eventcount = client.get_eventcount(bucket_name) + assert eventcount == len(events) + + result = client.query( + f'RETURN = query_bucket("{bucket_name}");', + timeperiods=[(now - timedelta(hours=1), now + timedelta(hours=1))], + ) + assert len(result) == 1 + assert len(result[0]) == 3 + + # Get single event + e = client.get_event(bucket_name, fetched_events[1].id) + assert e.id == fetched_events[1].id + + # Delete single event + client.delete_event(bucket_name, fetched_events[1].id) + assert client.get_event(bucket_name, fetched_events[1].id) is None + + # Test exception raising + with pytest.raises(ValueError): + # timeperiod end time does not have a timezone set + result = client.query( + f'RETURN = query_bucket("{bucket_name}");', + timeperiods=[(now - timedelta(hours=1), datetime.now())], + ) + + # Create and delete an event: check that it no longer exists + e_del = create_unique_event() + client.insert_event(bucket_name, e_del) + fetched_events = client.get_events(bucket_name) + assert (e_del.timestamp, e_del.duration, e_del.data) in [ + (e.timestamp, e.duration, e.data) for e in fetched_events + ] + + e_del_fetched = [e for e in fetched_events if e.data == e_del.data][0] + client.delete_event(bucket_name, e_del_fetched) + fetched_events = client.get_events(bucket_name) + assert (e_del.timestamp, e_del.duration, e_del.data) not in [ + (e.timestamp, e.duration, e.data) for e in fetched_events + ] + + # Delete bucket + client.delete_bucket(bucket_name) + + +def test_queued_usage_warns_once_before_connect(): + client = ActivityWatchClient(f"aw-test-client-{random()}", testing=True) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + client.create_bucket("test-bucket", "test", queued=True) + client.heartbeat( + "test-bucket", + create_unique_event(), + pulsetime=1, + queued=True, + ) + + queue_warnings = [ + warning + for warning in caught + if "connect()" in str(warning.message) or "with client:" in str(warning.message) + ] + assert len(queue_warnings) == 1 diff --git a/tests/test_failqueue.py b/tests/test_failqueue.py new file mode 100755 index 0000000..bc7f3bc --- /dev/null +++ b/tests/test_failqueue.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import time +from pprint import pprint +from random import randint +from datetime import datetime, timedelta, timezone + +from aw_core.models import Event +from aw_client import ActivityWatchClient + +now = datetime.now(timezone.utc) + + +def create_unique_event(): + return Event(timestamp=now, data={"label": str(randint(0, 10000))}) + + +def test_failqueue(): + client_name = "aw-test-client-" + str(randint(0, 10000)) + bucket_id = "test-bucket-" + str(randint(0, 10000)) + bucket_etype = "test" + + input("Make sure aw-server isn't running, then press enter > ") + client1 = ActivityWatchClient(client_name, testing=True) + client1.create_bucket(bucket_id, bucket_etype, queued=True) + + print("Creating events") + events = [create_unique_event() for _ in range(3)] + for i, e in enumerate(events): + e.timestamp += timedelta(seconds=i) + client1.heartbeat(bucket_id, e, pulsetime=1, queued=True) + + print("Trying to send events (should fail)") + with client1: + time.sleep(1) + + input("Start aw-server with --testing, then press enter > ") + + client2 = ActivityWatchClient(client_name, testing=True) + client2.create_bucket(bucket_id, bucket_etype, queued=True) + + # Here the previously queued events should be sent + with client2: + time.sleep(1) + + print("Getting events") + recv_events = client2.get_events(bucket_id) + + print("Asserting latest event") + pprint(recv_events) + pprint(recv_events[0].data["label"]) + pprint(events[2].data["label"]) + assert recv_events[0].data["label"] == events[2].data["label"] + + +if __name__ == "__main__": + test_failqueue() diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..a52ebc8 --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,94 @@ +"""Unit tests for profile resolution.""" + +import os + +import pytest + +from aw_client.profile import ( + DEFAULT_PROFILE, + TESTING_PROFILE, + export_profile, + is_testing, + profile_from_env, + profile_suffix, + resolve_profile, + validate_profile, +) + + +class TestResolveProfile: + def test_no_flags_gives_default(self): + assert resolve_profile(None, False) == DEFAULT_PROFILE + + def test_testing_flag_is_an_alias(self): + assert resolve_profile(None, True) == TESTING_PROFILE + + def test_explicit_profile_wins(self): + assert resolve_profile("research", False) == "research" + + def test_testing_with_matching_profile_is_allowed(self): + assert resolve_profile("testing", True) == TESTING_PROFILE + + def test_testing_with_conflicting_profile_raises(self): + with pytest.raises(ValueError, match="conflicts"): + resolve_profile("research", True) + + @pytest.mark.parametrize( + "name", ["Research", "with space", "a" * 33, "", "../escape", "-leading"] + ) + def test_invalid_names_rejected(self, name): + with pytest.raises(ValueError): + validate_profile(name) + + @pytest.mark.parametrize("name", ["research", "aw2", "my_profile", "my-profile"]) + def test_valid_names_accepted(self, name): + assert validate_profile(name) == name + + +class TestProfileSuffix: + def test_default_has_no_suffix(self): + assert profile_suffix(DEFAULT_PROFILE) == "" + + def test_testing_keeps_legacy_suffix(self): + assert profile_suffix(TESTING_PROFILE) == "-testing" + + def test_custom_profile_suffix(self): + assert profile_suffix("research") == "-research" + + def test_suffixes_are_disjoint(self): + suffixes = {profile_suffix(p) for p in ("default", "testing", "research")} + assert len(suffixes) == 3 + + +class TestIsTesting: + def test_only_testing_profile_is_testing(self): + assert is_testing(TESTING_PROFILE) + assert not is_testing(DEFAULT_PROFILE) + assert not is_testing("research") + + +class TestExportProfile: + def test_exported_for_child_processes(self, monkeypatch): + monkeypatch.delenv("AW_PROFILE", raising=False) + export_profile("research") + assert os.environ["AW_PROFILE"] == "research" + + def test_default_unsets_env_so_aw_core_keeps_bare_root(self, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + export_profile(DEFAULT_PROFILE) + assert "AW_PROFILE" not in os.environ + + +class TestProfileFromEnv: + def test_defaults_when_unset(self, monkeypatch): + monkeypatch.delenv("AW_PROFILE", raising=False) + assert profile_from_env(False) == DEFAULT_PROFILE + assert profile_from_env(True) == TESTING_PROFILE + + def test_reads_exported_profile(self, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + assert profile_from_env(False) == "research" + + def test_ignores_invalid_env_value(self, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "Not A Profile") + assert profile_from_env(False) == DEFAULT_PROFILE diff --git a/tests/test_profile_config.py b/tests/test_profile_config.py new file mode 100644 index 0000000..045368b --- /dev/null +++ b/tests/test_profile_config.py @@ -0,0 +1,294 @@ +"""Client constructor, config sections, persistqueue, and rust api-key lookup.""" + +import logging +import os +from pathlib import Path + +import pytest + +from aw_client import ActivityWatchClient +from aw_client import client as client_module +from aw_client.config import load_local_server_api_key, rust_server_config_candidates +from aw_client.profile import DEFAULT_PROFILE, TESTING_PROFILE + + +@pytest.fixture +def isolated_dirs(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.delenv("AW_PROFILE", raising=False) + monkeypatch.setattr(client_module, "SingleInstance", lambda name: object()) + return tmp_path + + +class TestClientConstructor: + def test_testing_alias_uses_testing_port_and_exports_env(self, isolated_dirs): + client = ActivityWatchClient("t", testing=True) + assert client.profile == TESTING_PROFILE + assert client.testing is True + assert client.server_address.endswith(":5666") + assert os.environ["AW_PROFILE"] == "testing" + + def test_explicit_profile_testing_is_the_same(self, isolated_dirs): + client = ActivityWatchClient("t", profile="testing") + assert client.profile == TESTING_PROFILE + assert client.testing is True + assert client.server_address.endswith(":5666") + + def test_default_unsets_env_and_uses_5600(self, isolated_dirs, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + client = ActivityWatchClient("t", profile="default") + assert client.profile == DEFAULT_PROFILE + assert client.testing is False + assert client.server_address.endswith(":5600") + assert "AW_PROFILE" not in os.environ + + def test_named_profile_falls_back_to_server_section(self, isolated_dirs, caplog): + with caplog.at_level(logging.WARNING, logger="aw_client.client"): + client = ActivityWatchClient("t", profile="research") + assert client.profile == "research" + assert client.testing is False + assert client.server_address.endswith(":5600") + assert os.environ["AW_PROFILE"] == "research" + assert any( + "falling back to [server]" in rec.message + and "may collide with the default instance" in rec.message + for rec in caplog.records + ) + + def test_env_from_launcher_is_used_when_no_flags(self, isolated_dirs, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + client = ActivityWatchClient("t") + assert client.profile == "research" + assert os.environ["AW_PROFILE"] == "research" + + def test_explicit_testing_overrides_stale_env(self, isolated_dirs, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + client = ActivityWatchClient("t", testing=True) + assert client.profile == TESTING_PROFILE + assert os.environ["AW_PROFILE"] == "testing" + + def test_host_and_port_kwargs_still_win(self, isolated_dirs): + client = ActivityWatchClient( + "t", profile="research", host="127.0.0.1", port=5667 + ) + assert client.server_address == "http://127.0.0.1:5667" + + def test_conflicting_flags_raise(self, isolated_dirs): + with pytest.raises(ValueError, match="conflicts"): + ActivityWatchClient("t", testing=True, profile="research") + + +class TestPersistqueueSuffix: + def test_testing_keeps_legacy_suffix(self, isolated_dirs): + client = ActivityWatchClient("aw-test-client", testing=True) + assert "aw-test-client-testing." in client.request_queue.persistqueue_path + + def test_named_profile_suffix_is_disjoint(self, isolated_dirs): + testing = ActivityWatchClient("aw-test-client", testing=True) + research = ActivityWatchClient("aw-test-client", profile="research") + assert ( + testing.request_queue.persistqueue_path + != research.request_queue.persistqueue_path + ) + assert "aw-test-client-research." in research.request_queue.persistqueue_path + + def test_reconnect_preserves_named_profile_queue_path( + self, isolated_dirs, monkeypatch + ): + def profile_data_dir(module): + profile = os.environ.get("AW_PROFILE") + root = "activitywatch" if profile is None else f"activitywatch-{profile}" + return str(isolated_dirs / root / module) + + monkeypatch.setattr(client_module, "get_data_dir", profile_data_dir) + research = ActivityWatchClient("aw-test-client", profile="research") + original_path = research.request_queue.persistqueue_path + monkeypatch.setattr(research.request_queue, "_try_connect", lambda: True) + research.request_queue.connected = True + research.connect() + + default = ActivityWatchClient("other-client", profile="default") + assert default.request_queue.persistqueue_path != original_path + research.disconnect() + + assert research.request_queue.persistqueue_path == original_path + + +@pytest.fixture +def fake_platform_dirs(tmp_path, monkeypatch): + """Point rust-config lookup at a tmp tree. platformdirs on macOS/Windows + ignores XDG_* even when set, so patch the wrappers rather than env vars. + """ + data = tmp_path / "data" + config = tmp_path / "config" + cache = tmp_path / "cache" + + def _join(root: Path, appname: str) -> str: + return str(root / appname) + + monkeypatch.setattr( + "aw_client.config._user_data_dir", lambda appname: _join(data, appname) + ) + monkeypatch.setattr( + "aw_client.config._user_config_dir", + lambda appname: _join(config, appname), + ) + monkeypatch.setattr( + "aw_client.config._user_cache_dir", + lambda appname: _join(cache, appname), + ) + monkeypatch.delenv("AW_PROFILE", raising=False) + return {"data": data, "config": config, "cache": cache, "root": tmp_path} + + +def _write_rust_config( + config_root: Path, appname: str, filename: str, content: str +) -> Path: + rust_dir = config_root / appname / "aw-server-rust" + rust_dir.mkdir(parents=True, exist_ok=True) + path = rust_dir / filename + path.write_text(content) + return path + + +def test_load_local_server_api_key_named_profile(fake_platform_dirs): + config = fake_platform_dirs["config"] + _write_rust_config( + config, + "activitywatch-research", + "config.toml", + 'port = 5667\n\n[auth]\napi_key = "research-secret"\n', + ) + _write_rust_config( + config, + "activitywatch", + "config.toml", + 'port = 5600\n\n[auth]\napi_key = "default-secret"\n', + ) + # Suffixed names in the isolated root must not be read — dir isolation + # is the point (ActivityWatch/activitywatch#1399). + _write_rust_config( + config, + "activitywatch-research", + "config-research.toml", + 'port = 5667\n\n[auth]\napi_key = "suffixed-must-be-ignored"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5667, profile="research") + == "research-secret" + ) + assert load_local_server_api_key("127.0.0.1", 5600, profile="default") == ( + "default-secret" + ) + assert load_local_server_api_key("127.0.0.1", 5600, profile="research") is None + + +class TestTestingRootApiKeyLookup: + """Rust API-key lookup follows the #1399 testing-root rule.""" + + def test_fresh_setup_reads_bare_config_in_new_root(self, fake_platform_dirs): + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch-testing", + "config.toml", + 'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "new-root-secret" + ) + paths = [p for p, _ in rust_server_config_candidates("testing")] + assert paths[0].endswith( + os.path.join("activitywatch-testing", "aw-server-rust", "config.toml") + ) + + def test_legacy_artifacts_keep_suffixed_shared_root(self, fake_platform_dirs): + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch", + "config-testing.toml", + 'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "legacy-secret" + ) + paths = [p for p, _ in rust_server_config_candidates("testing")] + assert paths[0].endswith( + os.path.join("activitywatch", "aw-server-rust", "config-testing.toml") + ) + + def test_new_root_wins_over_legacy_artifacts(self, fake_platform_dirs): + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch", + "config-testing.toml", + 'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n', + ) + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch-testing", + "config.toml", + 'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "new-root-secret" + ) + + def test_empty_new_root_still_finds_legacy_key(self, fake_platform_dirs): + """Python creating activitywatch-testing/ must not hide rust's legacy key.""" + (fake_platform_dirs["config"] / "activitywatch-testing").mkdir(parents=True) + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch", + "config-testing.toml", + 'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "legacy-secret" + ) + + +class TestCliPortOverride: + def test_explicit_port_5600_is_not_discarded(self, monkeypatch): + captured = {} + + class FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + def get_buckets(self): + return {} + + monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient) + from click.testing import CliRunner + + from aw_client.cli import main + + result = CliRunner().invoke(main, ["--port", "5600", "buckets"]) + assert result.exit_code == 0, result.output + assert captured["port"] == 5600 + + def test_omitted_port_lets_profile_config_win(self, monkeypatch): + captured = {} + + class FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + def get_buckets(self): + return {} + + monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient) + from click.testing import CliRunner + + from aw_client.cli import main + + result = CliRunner().invoke(main, ["--testing", "buckets"]) + assert result.exit_code == 0, result.output + assert captured["port"] is None + assert captured["testing"] is True diff --git a/tests/test_requestqueue.py b/tests/test_requestqueue.py new file mode 100644 index 0000000..836b69c --- /dev/null +++ b/tests/test_requestqueue.py @@ -0,0 +1,90 @@ +""" +Tests the often-buggy request queue. + +WARNING: A shitload of mocking ahead + +It is said about testing that it makes you able to refactorize +with confidence, and I need some of that right now. +""" + +from time import sleep +from logging import basicConfig, DEBUG + +basicConfig(level=DEBUG) + +import requests +from aw_client.client import RequestQueue + + +class MockClient: + client_name = "Mock" + + def __init__(self): + self.testing = True + self.create_bucket_calls = [] + + def get_buckets(self, *args, **kwargs): + print("Called get_buckets") + return [{"id": "test", "name": "Test"}] + + def create_bucket(self, *args, **kwargs): + self.create_bucket_calls.append((args, kwargs)) + print("Called create_bucket") + + def _post(self, *args, **kwargs): + print(args, kwargs) + return requests.Response() + + +def test_basic(): + client = MockClient() + rq = RequestQueue(client) # type: ignore + + # Mockeypatching + rq._try_connect = lambda: True # type: ignore + rq.connected = True + + rq.start() + rq.add_request("/api/0/buckets/test/heartbeat", {}) + sleep(1) + rq.stop() + rq.join() + + +def test_complex(): + client = MockClient() + rq = RequestQueue(client) # type: ignore + + # Mockeypatching + rq._try_connect = lambda: False # type: ignore + + rq.start() + sleep(1) + rq.stop() + rq.join() + + +def test_register_bucket_creates_immediately_when_connected(): + client = MockClient() + rq = RequestQueue(client) # type: ignore + rq.connected = True + + rq.register_bucket("test-bucket", "test-type") + + assert client.create_bucket_calls == [(("test-bucket", "test-type"), {})] + + +def test_register_bucket_marks_queue_disconnected_on_create_failure(): + class FailingClient(MockClient): + def create_bucket(self, *args, **kwargs): + super().create_bucket(*args, **kwargs) + raise requests.exceptions.ConnectionError() + + client = FailingClient() + rq = RequestQueue(client) # type: ignore + rq.connected = True + + rq.register_bucket("test-bucket", "test-type") + + assert rq.connected is False + assert client.create_bucket_calls == [(("test-bucket", "test-type"), {})]