From 07fb811788017782e23633f801deb3f1c5fed5b5 Mon Sep 17 00:00:00 2001 From: upstream-mirror Date: Wed, 16 Sep 2026 00:20:00 +0500 Subject: [PATCH] snapshot of https://github.com/ActivityWatch/aw-server.git (master, 2026-09-16; blobs > 131072B omitted) --- .github/dependabot.yml | 20 + .github/workflows/build.yml | 81 ++ .github/workflows/codeql.yml | 41 + .github/workflows/dependabot-automerge.yml | 27 + .github/workflows/lint.yml | 23 + .gitignore | 16 + .gitmodules | 3 + LICENSE.txt | 373 ++++++ Makefile | 58 + README.md | 48 + __main__.py | 3 + aw-server.spec | 48 + aw_server/__about__.py | 139 +++ aw_server/__init__.py | 11 + aw_server/__main__.py | 3 + aw_server/api.py | 388 +++++++ aw_server/config.py | 45 + aw_server/custom_static.py | 55 + aw_server/exceptions.py | 19 + aw_server/extension_cors.py | 134 +++ aw_server/log.py | 25 + aw_server/main.py | 176 +++ aw_server/profile.py | 104 ++ aw_server/py.typed | 0 aw_server/rest.py | 412 +++++++ aw_server/server.py | 151 +++ aw_server/settings.py | 46 + misc/aw-server.service | 12 + misc/process_logs.py | 13 + poetry.lock | 1195 ++++++++++++++++++++ pyproject.toml | 43 + scripts/benchmark-api-heartbeat.py | 43 + scripts/profile-werkzeug-read.py | 6 + scripts/profile-werkzeug.py | 6 + tests/conftest.py | 48 + tests/test_client.py | 297 +++++ tests/test_extension_cors.py | 171 +++ tests/test_profile.py | 94 ++ tests/test_profile_config.py | 127 +++ tests/test_server.py | 184 +++ 40 files changed, 4688 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependabot-automerge.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 __main__.py create mode 100644 aw-server.spec create mode 100644 aw_server/__about__.py create mode 100644 aw_server/__init__.py create mode 100644 aw_server/__main__.py create mode 100644 aw_server/api.py create mode 100644 aw_server/config.py create mode 100644 aw_server/custom_static.py create mode 100644 aw_server/exceptions.py create mode 100644 aw_server/extension_cors.py create mode 100644 aw_server/log.py create mode 100644 aw_server/main.py create mode 100644 aw_server/profile.py create mode 100644 aw_server/py.typed create mode 100644 aw_server/rest.py create mode 100644 aw_server/server.py create mode 100644 aw_server/settings.py create mode 100644 misc/aw-server.service create mode 100644 misc/process_logs.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 scripts/benchmark-api-heartbeat.py create mode 100644 scripts/profile-werkzeug-read.py create mode 100644 scripts/profile-werkzeug.py create mode 100755 tests/conftest.py create mode 100755 tests/test_client.py create mode 100644 tests/test_extension_cors.py create mode 100644 tests/test_profile.py create mode 100644 tests/test_profile_config.py create mode 100644 tests/test_server.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3ed0cfa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Set update schedule for GitHub Actions +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + + # Maintain dependencies for pip/poetry + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + + # Maintain submodule versions + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e01e74e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,81 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + env: + RELEASE: false + SKIP_WEBUI: true + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macOS-latest] + python_version: [3.9] + steps: + - uses: actions/checkout@v3 + with: + submodules: 'recursive' + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python_version }} + - name: Install dependencies + shell: bash + run: | + pip install poetry==1.3.2 # due to: https://github.com/python-poetry/poetry/issues/7611 + 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 + - name: Report coverage + shell: bash + run: | + # allow failures since codecov servers can be grumpy + bash <(curl -s https://codecov.io/bash) || true + - name: Package + shell: bash + run: | + source venv/bin/activate || source venv/Scripts/activate + pip install pyinstaller + make package + + typecheck: + runs-on: ${{ matrix.os }} + env: + RELEASE: false + SKIP_WEBUI: true + strategy: + matrix: + os: [ubuntu-latest] + python_version: [3.9] + steps: + - uses: actions/checkout@v3 + with: + submodules: 'recursive' + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python_version }} + - name: Install dependencies + shell: bash + run: | + pip install poetry + python -m venv venv + source venv/bin/activate || source venv/Scripts/activate + make build + - name: Typecheck + shell: bash + run: | + source venv/bin/activate || source venv/Scripts/activate + make typecheck diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..c5f8729 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: "CodeQL" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: "3 10 * * 0" + +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/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..6a5ea1c --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,27 @@ +name: Dependabot Auto-merge + +# NOTE: This workflow relies on a Personal Access Token from the @ActivityWatchBot user +# See this issue for details: https://github.com/ridedott/merge-me-action/issues/1581 + +on: + workflow_run: + types: + - completed + workflows: + # List all required workflow names here. + - Build + +permissions: + contents: write + pull-requests: read + +jobs: + auto_merge: + name: Auto-merge + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' && github.actor == 'dependabot[bot]' + + steps: + - uses: ridedott/merge-me-action@v2 + with: + GITHUB_TOKEN: ${{ secrets.AWBOT_GH_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..251b2bd --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: Lint + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + # Keep lint reproducible; the floating action tag can adopt new default rules. + - uses: jpetrucciani/ruff-check@5839e3c65007bdb626c1f3362153e45be347654f # ruff 0.15.22 + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + - uses: psf/black@stable diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea00233 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.pyc +__pycache__ +*.swp +*.egg-info +build +dist +static +.*cache +profiling +venv + +# coverage stuff +.coverage +htmlcov +coverage.xml +coverage_html diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..25d96b0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "aw-webui"] + path = aw-webui + url = https://github.com/ActivityWatch/aw-webui.git 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..2a58b51 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +.PHONY: aw-webui build install test typecheck package clean + +build: aw-webui + poetry install + +aw-webui: + mkdir -p aw_server/static/ +ifeq ($(SKIP_WEBUI),true) # Skip building webui if SKIP_WEBUI is true + @echo "Skipping building webui" +else + rm -rf aw_server/static/* + make --directory=aw-webui build DEV=$(DEV) + cp -r aw-webui/dist/* aw_server/static/ + # Needed for https://github.com/ActivityWatch/activitywatch/pull/274, works around https://github.com/pypa/pip/issues/6279 + # https://github.com/ActivityWatch/activitywatch/pull/367 Other solutions have been tried but did not actually work. + # If you aren't sure windows long paths are working, don't remove this + rm -rf aw-webui/node_modules/.cache +endif + +install: + cp misc/aw-server.service /usr/lib/systemd/user/aw-server.service + +test: + @# Note that extensive integration tests are also run in the bundle repo, + @# for both aw-server and aw-server-rust, but without code coverage. + python -c 'import aw_server' + python -m pytest tests/test_server.py tests/test_profile.py tests/test_profile_config.py + +typecheck: + python -m mypy aw_server tests --ignore-missing-imports + +package: bump-version + rm -rf dist + pyinstaller aw-server.spec --clean --noconfirm + +PYFILES=$(shell find . -name '*.py') + +lint: + ruff check . + +lint-fix: + poetry run pyupgrade --py38-plus --exit-zero-even-if-changed $(PYFILES) + ruff check --fix . + +format: + black . + +bump-version: + @# make sure to pull tags in parent repo before running this + poetry run python -m aw_server.__about__ + VERSION=$$(grep -oP '__version__ = "v\K[^"]+' aw_server/__about__.py | head -n1); echo $$VERSION; poetry version $$VERSION + +clean: + rm -rf build dist + rm -rf aw_server/__pycache__ + rm -rf aw_server/static/* + pip3 uninstall -y aw_server + make --directory=aw-webui clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..b49630f --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +aw-server +============ + +[![Build Status](https://github.com/ActivityWatch/aw-server/workflows/Build/badge.svg?branch=master)](https://github.com/ActivityWatch/aw-server/actions?query=workflow%3ABuild+branch%3Amaster) +[![Coverage Status](https://codecov.io/gh/ActivityWatch/aw-server/branch/master/graph/badge.svg)](https://codecov.io/gh/ActivityWatch/aw-server) + + +ActivityWatch server, for secure storage and retrieval of all your Quantified Self data. + +**Note:** In the future this will no longer be the default server used by ActivityWatch, instead we will be using [aw-server-rust](https://github.com/ActivityWatch/aw-server-rust/). The fate of this repo is uncertain, but it will likely be around and maintained for a while (as a reference implementation, for prototyping, etc). + + +## Build and Install + +To install program and dependencies, Poetry is required. You'll want to activate a `venv` before building. + +```bash +make build +``` + +The `aw-server` binary should now be available to you in your PATH (if it is set correctly). + +## Usage + +Run aw-server: + +```bash +aw-server +``` + +## Development + +If you want to run aw-server in development, you probably want to run a +development instance beside your personal (stable) instance. You can do +this by giving aw-server the `--testing` flag. This will start the server +on another port and use a separate datastore. + +```bash +aw-server --testing +``` + + +## API Examples + +You can also get a very decent API browser by browsing to `localhost:5600` after starting the server. + +There are also some API examples in the [documentation](https://docs.activitywatch.net/en/latest/api.html). + diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..07f2a61 --- /dev/null +++ b/__main__.py @@ -0,0 +1,3 @@ +import aw_server + +aw_server.main() diff --git a/aw-server.spec b/aw-server.spec new file mode 100644 index 0000000..57a7542 --- /dev/null +++ b/aw-server.spec @@ -0,0 +1,48 @@ +# -*- mode: python -*- +# vi: set ft=python : + +import os + +import aw_core +import flask_restx + +aw_core_path = os.path.dirname(aw_core.__file__) +restx_path = os.path.dirname(flask_restx.__file__) + +name = "aw-server" +block_cipher = None + + +a = Analysis( + ["__main__.py"], + pathex=[], + binaries=None, + datas=[ + ("aw_server/static", "aw_server/static"), + (os.path.join(restx_path, "templates"), "flask_restx/templates"), + (os.path.join(restx_path, "static"), "flask_restx/static"), + (os.path.join(aw_core_path, "schemas"), "aw_core/schemas"), + ], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +exe = EXE( + pyz, + a.scripts, + exclude_binaries=True, + name=name, + contents_directory=".", + debug=False, + strip=False, + upx=True, + console=True, +) +coll = COLLECT( + exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, name="aw-server" +) diff --git a/aw_server/__about__.py b/aw_server/__about__.py new file mode 100644 index 0000000..a2471dc --- /dev/null +++ b/aw_server/__about__.py @@ -0,0 +1,139 @@ +import subprocess +import re +import logging +from typing import Optional +from pathlib import Path + +from importlib.metadata import PackageNotFoundError, version as get_version + +logger = logging.getLogger(__name__) + +# TODO: Remove reliance on +basever = "v0.12" + +srcpath = Path(__file__).absolute().parent +projectpath = srcpath.parent +bundlepath = projectpath.parent # the ActivityWatch bundle repo, in some circumstances + +# This line set by script when run (metaprogramming) +__version__ = "v0.13.2" + + +def get_rev(): + p = subprocess.run( + "git rev-parse --short HEAD", + shell=True, + capture_output=True, + encoding="utf8", + cwd=workdir, + ) + return p.stdout.strip() + + +def get_tag_exact(): + rev = get_rev() + p = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0", "--exact-match", rev], + capture_output=True, + encoding="utf8", + cwd=workdir, + ) + if p.stderr: + if ( + "no tag exactly matches" in p.stderr + or "No names found, cannot describe anything" in p.stderr + ): + pass + else: + raise Exception(p.stderr) + else: + return p.stdout.strip() + + +def get_tag_latest(): + try: + tag = subprocess.check_output( + ["git", "describe", "--abbrev=0", "--tags"], encoding="utf8", cwd=workdir + ).strip() + if not tag: + return + basever = tag + except subprocess.CalledProcessError as e: + print(e) + return + + try: + return f"{basever}.dev+{get_rev()}" + except Exception as e: + # Unable to get current commit with git + logger.exception(e) + return None + + +def detect_version_git() -> Optional[str]: + # Returns an exact tag if there is one, or an approximate one + commit identifier + version = get_tag_exact() + if version: + return version + + version = get_tag_latest() + if version: + return version + + return None + + +def detect_version_pkg() -> Optional[str]: + try: + return f"v{get_version('aw-server')}.dev+{get_rev()}" + except PackageNotFoundError: + return None + + +def detect_version_poetry() -> Optional[str]: + """Detect version from pyproject.toml file, with `poetry version -s`""" + try: + basever = subprocess.check_output( + ["poetry", "version", "-s"], encoding="utf8", cwd=workdir + ).strip() + except subprocess.CalledProcessError as e: + print(e) + return None + return f"v{basever}.dev+{get_rev()}" + + +def detect_version(): + for detectfunc in (detect_version_git, detect_version_pkg): + version = detectfunc() + if version: + return version + + return basever + ".dev+unknown" + + +def assign_static_version(): + """Self-modifies the current script to lock in version""" + version = detect_version() + with open(__file__) as f: + versionline = f'\n__version__ = "{version}"' + data = re.sub(r"\n__version__ = [^\n;]+", versionline, f.read()) + + with open(__file__, "w") as f: + f.write(data) + + print(f"Set versionline: {versionline.strip()}") + + +if __name__ == "__main__": + # Use the bundle repo as working repo, if it exists + if (bundlepath / ".git").exists(): + print("Found bundle repo in parent dir, using for calls to git") + workdir = bundlepath + else: + workdir = projectpath + + assign_static_version() + + print("By method:") + print(f" git: {detect_version_git()}") + print(f" pkg: {detect_version_pkg()}") diff --git a/aw_server/__init__.py b/aw_server/__init__.py new file mode 100644 index 0000000..9b889a9 --- /dev/null +++ b/aw_server/__init__.py @@ -0,0 +1,11 @@ +import logging as _logging + +logger = _logging.getLogger(__name__) + +from .__about__ import __version__ +from .main import main + +__all__ = [ + "__version__", + "main", +] diff --git a/aw_server/__main__.py b/aw_server/__main__.py new file mode 100644 index 0000000..07f2a61 --- /dev/null +++ b/aw_server/__main__.py @@ -0,0 +1,3 @@ +import aw_server + +aw_server.main() diff --git a/aw_server/api.py b/aw_server/api.py new file mode 100644 index 0000000..20319ea --- /dev/null +++ b/aw_server/api.py @@ -0,0 +1,388 @@ +import functools +import json +import logging +from datetime import datetime +from pathlib import Path +from socket import gethostname +from typing import ( + Any, + Dict, + List, + Optional, +) +from uuid import uuid4 + +import iso8601 +from aw_core.dirs import get_data_dir +from aw_core.log import get_log_file_path +from aw_core.models import Event +from aw_query import query2 +from aw_query.exceptions import QueryException +from aw_transform import heartbeat_merge + +from .__about__ import __version__ +from .exceptions import NotFound +from .profile import profile_from_env +from .settings import Settings + +logger = logging.getLogger(__name__) + + +def get_device_id() -> str: + path = Path(get_data_dir("aw-server")) / "device_id" + if path.exists(): + with open(path) as f: + return f.read() + else: + uuid = str(uuid4()) + with open(path, "w") as f: + f.write(uuid) + return uuid + + +def check_bucket_exists(f): + @functools.wraps(f) + def g(self, bucket_id, *args, **kwargs): + if bucket_id not in self.db.buckets(): + raise NotFound("NoSuchBucket", f"There's no bucket named {bucket_id}") + return f(self, bucket_id, *args, **kwargs) + + return g + + +class ServerAPI: + def __init__(self, db, testing) -> None: + self.db = db + self.settings = Settings(testing) + self.testing = testing + self.profile = profile_from_env(testing=testing) + self.last_event = {} # type: dict + + def get_info(self) -> Dict[str, Any]: + """Get server info""" + payload = { + "hostname": gethostname(), + "version": __version__, + "testing": self.testing, + "device_id": get_device_id(), + "profile": self.profile, + } + return payload + + def get_buckets(self) -> Dict[str, Dict]: + """Get dict {bucket_name: Bucket} of all buckets""" + logger.debug("Received get request for buckets") + buckets = self.db.buckets() + for b in buckets: + # TODO: Move this code to aw-core? + last_events = self.db[b].get(limit=1) + if len(last_events) > 0: + last_event = last_events[0] + last_updated = last_event.timestamp + last_event.duration + buckets[b]["last_updated"] = last_updated.isoformat() + return buckets + + @check_bucket_exists + def get_bucket_metadata(self, bucket_id: str) -> Dict[str, Any]: + """Get metadata about bucket.""" + bucket = self.db[bucket_id] + return bucket.metadata() + + @check_bucket_exists + def export_bucket(self, bucket_id: str) -> Dict[str, Any]: + """Export a bucket to a dataformat consistent across versions, including all events in it.""" + bucket = self.get_bucket_metadata(bucket_id) + bucket["events"] = self.get_events(bucket_id, limit=-1) + # Scrub event IDs + for event in bucket["events"]: + del event["id"] + return bucket + + def export_all(self) -> Dict[str, Any]: + """Exports all buckets and their events to a format consistent across versions""" + buckets = self.get_buckets() + exported_buckets = {} + for bid in buckets.keys(): + exported_buckets[bid] = self.export_bucket(bid) + return exported_buckets + + def import_bucket(self, bucket_data: Any): + bucket_id = bucket_data["id"] + logger.info(f"Importing bucket {bucket_id}") + + # TODO: Check that bucket doesn't already exist + self.db.create_bucket( + bucket_id, + type=bucket_data["type"], + client=bucket_data["client"], + hostname=bucket_data["hostname"], + created=( + bucket_data["created"] + if isinstance(bucket_data["created"], datetime) + else iso8601.parse_date(bucket_data["created"]) + ), + ) + + # scrub IDs from events + # (otherwise causes weird bugs with no events seemingly imported when importing events exported from aw-server-rust, which contains IDs) + for event in bucket_data["events"]: + if "id" in event: + del event["id"] + + self.create_events( + bucket_id, + [Event(**e) if isinstance(e, dict) else e for e in bucket_data["events"]], + ) + + def import_all(self, buckets: Dict[str, Any]): + for bid, bucket in buckets.items(): + self.import_bucket(bucket) + + def create_bucket( + self, + bucket_id: str, + event_type: str, + client: str, + hostname: str, + created: Optional[datetime] = None, + data: Optional[Dict[str, Any]] = None, + ) -> bool: + """ + Create a bucket. + + If hostname is "!local", the hostname and device_id will be set from the server info. + This is useful for watchers which are known/assumed to run locally but might not know their hostname (like aw-watcher-web). + + Returns True if successful, otherwise false if a bucket with the given ID already existed. + """ + if created is None: + created = datetime.now() + if bucket_id in self.db.buckets(): + return False + if hostname == "!local": + info = self.get_info() + if data is None: + data = {} + hostname = info["hostname"] + data["device_id"] = info["device_id"] + self.db.create_bucket( + bucket_id, + type=event_type, + client=client, + hostname=hostname, + created=created, + data=data, + ) + return True + + @check_bucket_exists + def update_bucket( + self, + bucket_id: str, + event_type: Optional[str] = None, + client: Optional[str] = None, + hostname: Optional[str] = None, + data: Optional[Dict[str, Any]] = None, + ) -> None: + """Update bucket metadata""" + self.db.update_bucket( + bucket_id, + type=event_type, + client=client, + hostname=hostname, + data=data, + ) + return None + + @check_bucket_exists + def delete_bucket(self, bucket_id: str) -> None: + """Delete a bucket""" + self.db.delete_bucket(bucket_id) + logger.debug(f"Deleted bucket '{bucket_id}'") + return None + + @check_bucket_exists + def get_event( + self, + bucket_id: str, + event_id: int, + ) -> Optional[Event]: + """Get a single event from a bucket""" + logger.debug( + f"Received get request for event {event_id} in bucket '{bucket_id}'" + ) + event = self.db[bucket_id].get_by_id(event_id) + return event.to_json_dict() if event else None + + @check_bucket_exists + def get_events( + self, + bucket_id: str, + limit: int = -1, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + ) -> List[Event]: + """Get events from a bucket""" + logger.debug(f"Received get request for events in bucket '{bucket_id}'") + if limit is None: # Let limit = None also mean "no limit" + limit = -1 + events = [ + event.to_json_dict() for event in self.db[bucket_id].get(limit, start, end) + ] + return events + + @check_bucket_exists + def create_events(self, bucket_id: str, events: List[Event]) -> List[Event]: + """Create events for a bucket. Can handle both single events and multiple ones. + + Always returns a list of inserted events (matching aw-server-rust behavior). + For single events, the returned event includes the server-assigned ID. + For bulk inserts, returns empty list (events may not have IDs without a response-SQL roundtrip). + """ + if len(events) == 1: + # Pass as single Event so Bucket.insert uses insert_one (returns Event with ID) + inserted = self.db[bucket_id].insert(events[0]) + return [inserted] + else: + self.db[bucket_id].insert(events) + return [] + + @check_bucket_exists + def get_eventcount( + self, + bucket_id: str, + start: Optional[datetime] = None, + end: Optional[datetime] = None, + ) -> int: + """Get eventcount from a bucket""" + logger.debug(f"Received get request for eventcount in bucket '{bucket_id}'") + return self.db[bucket_id].get_eventcount(start, end) + + @check_bucket_exists + def delete_event(self, bucket_id: str, event_id) -> bool: + """Delete a single event from a bucket""" + return self.db[bucket_id].delete(event_id) + + @check_bucket_exists + def heartbeat(self, bucket_id: str, heartbeat: Event, pulsetime: float) -> Event: + """ + Heartbeats are useful when implementing watchers that simply keep + track of a state, how long it's in that state and when it changes. + A single heartbeat always has a duration of zero. + + If the heartbeat was identical to the last (apart from timestamp), then the last event has its duration updated. + If the heartbeat differed, then a new event is created. + + Such as: + - Active application and window title + - Example: aw-watcher-window + - Currently open document/browser tab/playing song + - Example: wakatime + - Example: aw-watcher-web + - Example: aw-watcher-spotify + - Is the user active/inactive? + Send an event on some interval indicating if the user is active or not. + - Example: aw-watcher-afk + + Inspired by: https://wakatime.com/developers#heartbeats + """ + logger.debug( + "Received heartbeat in bucket '{}'\n\ttimestamp: {}, duration: {}, pulsetime: {}\n\tdata: {}".format( + bucket_id, + heartbeat.timestamp, + heartbeat.duration, + pulsetime, + heartbeat.data, + ) + ) + + # The endtime here is set such that in the event that the heartbeat is older than an + # existing event we should try to merge it with the last event before the heartbeat instead. + # FIXME: This (the endtime=heartbeat.timestamp) gets rid of the "heartbeat was older than last event" + # warning and also causes a already existing "newer" event to be overwritten in the + # replace_last call below. This is problematic. + # Solution: This could be solved if we were able to replace arbitrary events. + # That way we could double check that the event has been applied + # and if it hasn't we simply replace it with the updated counterpart. + + last_event = None + if bucket_id not in self.last_event: + last_events = self.db[bucket_id].get(limit=1) + if len(last_events) > 0: + last_event = last_events[0] + else: + last_event = self.last_event[bucket_id] + + if last_event: + if last_event.data == heartbeat.data: + merged = heartbeat_merge(last_event, heartbeat, pulsetime) + if merged is not None: + # Heartbeat was merged into last_event + logger.debug( + "Received valid heartbeat, merging. (bucket: {})".format( + bucket_id + ) + ) + self.last_event[bucket_id] = merged + self.db[bucket_id].replace_last(merged) + return merged + else: + logger.info( + "Received heartbeat after pulse window, inserting as new event. (bucket: {})".format( + bucket_id + ) + ) + else: + logger.debug( + "Received heartbeat with differing data, inserting as new event. (bucket: {})".format( + bucket_id + ) + ) + else: + logger.info( + "Received heartbeat, but bucket was previously empty, inserting as new event. (bucket: {})".format( + bucket_id + ) + ) + + self.db[bucket_id].insert(heartbeat) + self.last_event[bucket_id] = heartbeat + return heartbeat + + def query2(self, name, query, timeperiods, cache): + result = [] + for timeperiod in timeperiods: + period = timeperiod.split("/")[ + :2 + ] # iso8601 timeperiods are separated by a slash + if len(period) != 2: + raise QueryException( + f"Invalid timeperiod '{timeperiod}': expected two ISO8601 " + "datetimes separated by a slash (start/end)" + ) + try: + starttime = iso8601.parse_date(period[0]) + endtime = iso8601.parse_date(period[1]) + except iso8601.ParseError as e: + raise QueryException(f"Invalid timeperiod '{timeperiod}': {e}") + query = "".join(query) + result.append(query2.query(name, query, starttime, endtime, self.db)) + return result + + # TODO: Right now the log format on disk has to be JSON, this is hard to read by humans... + def get_log(self): + """Get the server log in json format""" + payload = [] + with open(get_log_file_path()) as log_file: + for line in log_file.readlines()[::-1]: + payload.append(json.loads(line)) + return payload, 200 + + def get_setting(self, key): + """Get a setting""" + return self.settings.get(key, None) + + def set_setting(self, key, value): + """Set a setting""" + self.settings[key] = value + return value diff --git a/aw_server/config.py b/aw_server/config.py new file mode 100644 index 0000000..16fb942 --- /dev/null +++ b/aw_server/config.py @@ -0,0 +1,45 @@ +from aw_core.config import load_config_toml + +from .profile import DEFAULT_PROFILE, is_testing + +default_config = """ +[server] +host = "localhost" +port = "5600" +storage = "peewee" +cors_origins = "" + +[server.custom_static] + +[server-testing] +host = "localhost" +port = "5666" +storage = "peewee" +cors_origins = "" + +[server-testing.custom_static] +""".strip() + + +def load_config(): + """Load aw-server.toml from the current profile's config dir. + + Must be called *after* ``export_profile`` so aw-core dirs see + ``AW_PROFILE`` and isolate the file from other instances. + """ + return load_config_toml("aw-server", default_config) + + +def config_section(profile: str) -> str: + """TOML section for this profile: ``server`` or ``server-``.""" + return "server" if profile == DEFAULT_PROFILE else f"server-{profile}" + + +def default_port(profile: str) -> int: + """Built-in port: 5666 for testing, 5600 otherwise. + + Named profiles take ``port`` from their own isolated config (the + research build bakes 5667 into that file). There is no hash-to-port + table — a custom profile without a port set collides with default. + """ + return 5666 if is_testing(profile) else 5600 diff --git a/aw_server/custom_static.py b/aw_server/custom_static.py new file mode 100644 index 0000000..138b301 --- /dev/null +++ b/aw_server/custom_static.py @@ -0,0 +1,55 @@ +""" +Contains endpoints, as well as utility functions for custom static content. + +NOTE: Experimental, not (yet) implemented in aw-server-rust. + +Idea: Allow custom watchers to extend the Web UI and to write custom visualizations completely independently and free. + +Usage for the watcher developer: +- You can use Vanilla JavaScript, Vue, React, ... as long as you have static content at the end. + +- Register your watcher visualization in the config: + +[server.custom_static] +aw-watcher-example=/home/user/path/to/static_dir/ + +- Your custom static content automatically gets the data for the requested time span as GET parameter called "data". +Another parameter called "view" can be used if you want to create multiple visualizations for a single watcher. + +- You can show your custom visualizations in the official Activity Watch UI using the "Custom Watcher View" + See https://github.com/ActivityWatch/activitywatch/issues/453#issuecomment-910567848 + +""" + +from flask import ( + Blueprint, + escape, + jsonify, + send_from_directory, +) + + +def get_custom_static_blueprint(custom_static_directories): + custom_static_blueprint = Blueprint("custom_static", __name__, url_prefix="/") + + @custom_static_blueprint.route("pages/") + def custom_static_supported_pages(): + """Serves a list of all watchers that are supported / were registered successfully""" + return jsonify(list(custom_static_directories.keys())) + + @custom_static_blueprint.route( + "pages//", defaults={"path": "index.html"} + ) + @custom_static_blueprint.route("pages//") + def custom_static_pages(name: str, path: str): + """Serves the custom static content""" + + if name in custom_static_directories: + return send_from_directory(custom_static_directories[name], path) + else: + return ( + f"Static content: {escape(path)} of watcher: {escape(name)} not found!", + 404, + ) + + return custom_static_blueprint diff --git a/aw_server/exceptions.py b/aw_server/exceptions.py new file mode 100644 index 0000000..e4c81f5 --- /dev/null +++ b/aw_server/exceptions.py @@ -0,0 +1,19 @@ +import werkzeug.exceptions + + +class BadRequest(werkzeug.exceptions.BadRequest): + def __init__(self, type: str, message: str) -> None: + super().__init__(message) + self.type = type + + +class NotFound(werkzeug.exceptions.NotFound): + def __init__(self, type: str, message: str) -> None: + super().__init__(message) + self.type = type + + +class Unauthorized(werkzeug.exceptions.Unauthorized): + def __init__(self, type: str, message: str) -> None: + super().__init__(message) + self.type = type diff --git a/aw_server/extension_cors.py b/aw_server/extension_cors.py new file mode 100644 index 0000000..54c6dcf --- /dev/null +++ b/aw_server/extension_cors.py @@ -0,0 +1,134 @@ +""" +Endpoint scoping for the moz-extension:// CORS wildcard. + +Firefox assigns every extension its own random origin, so aw-server uses +``moz-extension://*`` to allow aw-watcher-web without knowing the ID in advance. +That wildcard also permits every other installed extension to reach the full API — +including ``/api/0/export``, ``/api/0/import``, queries, and settings — with no +host permission and therefore no install-time browser prompt naming ActivityWatch. + +This module registers a ``before_request`` hook that restricts wildcard-matched +extension origins to the three endpoints aw-watcher-web actually needs: + + GET /api/0/info — hostname/version detection + POST /api/0/buckets/aw-watcher-web- — ensure its bucket exists + POST /api/0/buckets/aw-watcher-web-/heartbeat — heartbeat recording + +All other paths return 403 before the handler executes. flask-cors CORS headers are +still added by the after_request hook, but the 403 status prevents JavaScript from +treating the response as a successful cross-origin fetch (and more importantly, the +server side never processes the request). + +User-configured origins (``cors_origins`` / ``cors_regex`` in config) are exempted: +those are explicit opt-ins by the server owner, unlike the built-in wildcard. + +Path matching uses split segments rather than the raw path string to avoid +percent-encoding bypasses — the same bug class as aw-server-rust#588 and #636. + +See also: ActivityWatch/aw-server-rust#637 (the Rust sibling of this fix). +""" + +import logging +import re +from typing import List, Optional + +from flask import Flask, abort, request + +logger = logging.getLogger(__name__) + +_EXTENSION_SCHEME = "moz-extension://" + + +def register(app: Flask, user_origins: List[str]) -> None: + """Register the extension CORS scope hook on *app*. + + *user_origins* — origins the user configured explicitly (captured before + the built-in ``moz-extension://*`` wildcard is appended). These are + explicit opt-ins and bypass the scope narrowing. + """ + + @app.before_request + def _restrict_extension_cors() -> Optional[object]: + origin = request.headers.get("Origin", "") + if not origin.lower().startswith(_EXTENSION_SCHEME): + return None # not a moz-extension origin — let flask-cors handle it + + # flask-cors 4 treats strings containing regex metacharacters as regular + # expressions and otherwise compares them case-insensitively. Keep this + # exemption consistent with that contract. + for pattern in user_origins: + if _matches_configured_origin(origin, pattern): + return None + + segments = [s for s in request.path.split("/") if s] + if _is_allowed(request.method, segments): + return None + + abort(403) + + +def _matches_configured_origin(origin: str, pattern: str) -> bool: + """Match an origin using flask-cors 4's configured-origin semantics.""" + regex_chars = "*\\]?$^[()" + if any(char in pattern for char in regex_chars): + try: + return re.match(pattern, origin, flags=re.IGNORECASE) is not None + except re.error: + return False + return origin.lower() == pattern.lower() + + +def _is_allowed(method: str, segments: List[str]) -> bool: + """Return True if *method* + *segments* is a path aw-watcher-web actually uses. + + For OPTIONS preflights the path is checked against the set of allowed paths + (not the Access-Control-Request-Method header) to keep the logic simple while + still blocking preflights for disallowed paths such as ``/api/0/export``. + """ + if method == "OPTIONS": + return _is_allowed_path(segments) + + # GET /api/0/info + if method == "GET" and segments == ["api", "0", "info"]: + return True + + # POST /api/0/buckets/aw-watcher-web- + if ( + method == "POST" + and len(segments) == 4 + and segments[:3] == ["api", "0", "buckets"] + and segments[3].startswith("aw-watcher-web-") + ): + return True + + # POST /api/0/buckets/aw-watcher-web-/heartbeat + if ( + method == "POST" + and len(segments) == 5 + and segments[:3] == ["api", "0", "buckets"] + and segments[3].startswith("aw-watcher-web-") + and segments[4] == "heartbeat" + ): + return True + + return False + + +def _is_allowed_path(segments: List[str]) -> bool: + """Return True if *segments* is on any allowed endpoint (for OPTIONS checks).""" + if segments == ["api", "0", "info"]: + return True + if ( + len(segments) == 4 + and segments[:3] == ["api", "0", "buckets"] + and segments[3].startswith("aw-watcher-web-") + ): + return True + if ( + len(segments) == 5 + and segments[:3] == ["api", "0", "buckets"] + and segments[3].startswith("aw-watcher-web-") + and segments[4] == "heartbeat" + ): + return True + return False diff --git a/aw_server/log.py b/aw_server/log.py new file mode 100644 index 0000000..ba8dcad --- /dev/null +++ b/aw_server/log.py @@ -0,0 +1,25 @@ +import logging + +from werkzeug import serving + + +class FlaskLogHandler(serving.WSGIRequestHandler): + def __init__(self, *args): + self.logger = logging.getLogger("flask") + super().__init__(*args) + + def log(self, levelname, message, *args): + msg = args[0] + code = int(args[1]) + + if code in [200, 304]: + levelname = "debug" + # type = "debug" + + if levelname == "info": + levelno = logging.INFO + elif levelname == "debug": + levelno = logging.DEBUG + else: + raise Exception("Unknown level " + type) + self.logger.log(levelno, f"{code} ({self.address_string()}): {msg}") diff --git a/aw_server/main.py b/aw_server/main.py new file mode 100644 index 0000000..c4dd518 --- /dev/null +++ b/aw_server/main.py @@ -0,0 +1,176 @@ +import logging +import sys + +from aw_core.log import setup_logging +from aw_datastore import get_storage_methods + +from . import __version__ +from .config import config_section, default_port, load_config +from .profile import ( + DEFAULT_PROFILE, + export_profile, + is_testing, + resolve_profile, +) +from .server import _start + +logger = logging.getLogger(__name__) + + +def main(): + """Called from the executable and __main__.py""" + + settings, storage_method = parse_settings() + + # FIXME: The LogResource API endpoint relies on the log being in JSON format + # at the path specified by aw_core.log.get_log_file_path(). We probably want + # to write the LogResource API so that it does not depend on any physical file + # but instead add a logging handler that it can use privately. + # That is why log_file_json=True currently. + # UPDATE: The LogResource API is no longer available so log_file_json is now False. + setup_logging( + "aw-server", + testing=settings.testing, + verbose=settings.verbose, + log_stderr=True, + log_file=True, + ) + + logger.info(f"Using storage method: {settings.storage}") + + if settings.testing: + logger.info("Will run in testing mode") + + if settings.profile != DEFAULT_PROFILE: + logger.info(f"Running with profile: {settings.profile}") + + if settings.custom_static: + logger.info(f"Using custom_static: {settings.custom_static}") + + logger.info("Starting up...") + _start( + host=settings.host, + port=settings.port, + testing=settings.testing, + storage_method=storage_method, + cors_origins=settings.cors_origins, + custom_static=settings.custom_static, + ) + + +def parse_settings(): + import argparse + + """ CLI Arguments """ + parser = argparse.ArgumentParser(description="Starts an ActivityWatch server") + parser.add_argument( + "--testing", + action="store_true", + help="Run aw-server in testing mode using different ports and database (alias for --profile testing)", + ) + parser.add_argument( + "--profile", + dest="profile", + default=None, + help="Named instance profile (data, config, port and settings are isolated). --testing is an alias for --profile testing.", + ) + parser.add_argument("--verbose", action="store_true", help="Be chatty.") + parser.add_argument( + "--version", + action="store_true", + help="Print version and quit", + ) + parser.add_argument( + "--log-json", action="store_true", help="Output the logs in JSON format" + ) + parser.add_argument( + "--host", dest="host", help="Which host address to bind the server to" + ) + parser.add_argument( + "--port", dest="port", type=int, help="Which port to run the server on" + ) + parser.add_argument( + "--storage", + dest="storage", + help="The method to use for storing data. Some methods (such as MongoDB) require specific Python packages to be available (in the MongoDB case: pymongo)", + ) + parser.add_argument( + "--cors-origins", + dest="cors_origins", + help="CORS origins to allow (as a comma separated list)", + ) + parser.add_argument( + "--custom-static", + dest="custom_static", + help="The custom static directories. Format: watcher_name=path,watcher_name2=path2,...", + ) + args = parser.parse_args() + if args.version: + print(__version__) + sys.exit(0) + + try: + profile = resolve_profile(args.profile, args.testing) + except ValueError as e: + parser.error(str(e)) + # Export before loading config so aw-core dirs isolate this profile. + export_profile(profile) + testing = is_testing(profile) + + """ Parse config file """ + config = load_config() + section = config_section(profile) + if section not in config: + if profile not in (DEFAULT_PROFILE,): + logger.warning( + "Profile %s has no [%s] section, falling back to [server] " + "(port %s may collide with the default instance)", + profile, + section, + default_port(profile), + ) + section = "server" + settings = argparse.Namespace() + settings.host = config[section]["host"] + settings.port = int(config[section]["port"]) + settings.storage = config[section]["storage"] + settings.cors_origins = config[section]["cors_origins"] + settings.custom_static = dict(config[section]["custom_static"]) + settings.profile = profile + settings.testing = testing + + """ If a argument is not none, override the config value """ + for key, value in vars(args).items(): + if key in ("testing", "profile"): + # Resolved above; --profile testing must keep testing=True + # even when the raw --testing flag was absent. + continue + if value is not None: + if key == "custom_static": + settings.custom_static = parse_str_to_dict(value) + else: + vars(settings)[key] = value + + settings.cors_origins = [o for o in settings.cors_origins.split(",") if o] + + storage_methods = get_storage_methods() + storage_method = storage_methods[settings.storage] + + return settings, storage_method + + +def parse_str_to_dict(str_value): + """Parses a dict from a string in format: key=value,key2=value2,...""" + output = dict() + key_value_pairs = str_value.split(",") + + for pair in key_value_pairs: + pair_split = pair.split("=") + + if len(pair_split) != 2: + raise ValueError(f"Cannot parse key value pair: {pair}") + + key, value = pair_split + output[key] = value + + return output diff --git a/aw_server/profile.py b/aw_server/profile.py new file mode 100644 index 0000000..787512f --- /dev/null +++ b/aw_server/profile.py @@ -0,0 +1,104 @@ +"""Profile resolution for aw-server. + +A *profile* names an isolated ActivityWatch instance (data, config, port, +settings). `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 and anything it spawns — without threading a flag through the +datastore, settings, or Flask stack. + +Kept in sync with aw-qt's ``aw_qt/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 aw-core#149 +maps ``AW_PROFILE=testing`` to ``activitywatch-testing``. The rust +isolation branch keeps testing on the bare ``activitywatch`` root so +existing ``sqlite-testing.db`` files are not orphaned. This module follows +the already-merged python dirs contract; unifying the rust testing root +is a follow-up on that isolation PR, not something to special-case here. +""" + +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_server/py.typed b/aw_server/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/aw_server/rest.py b/aw_server/rest.py new file mode 100644 index 0000000..0b2c320 --- /dev/null +++ b/aw_server/rest.py @@ -0,0 +1,412 @@ +import json +import traceback +from functools import wraps +from threading import Lock +from typing import Dict + +import iso8601 +from aw_core import schema +from aw_core.models import Event +from aw_query.exceptions import QueryException +from flask import ( + Blueprint, + current_app, + jsonify, + make_response, + request, +) +from flask_restx import Api, Resource, fields + +from . import logger +from .api import ServerAPI +from .exceptions import BadRequest, Unauthorized + + +def host_header_check(f): + """ + Protects against DNS rebinding attacks (see https://github.com/ActivityWatch/activitywatch/security/advisories/GHSA-v9fg-6g9j-h4x4) + + Some discussion in Syncthing how they do it: https://github.com/syncthing/syncthing/issues/4819 + """ + + @wraps(f) + def decorator(*args, **kwargs): + server_host = current_app.config["HOST"] + req_host = request.headers.get("host", None) + if server_host == "0.0.0.0": + logger.warning( + "Server is listening on 0.0.0.0, host header check is disabled (potential security issue)." + ) + elif req_host is None: + return {"message": "host header is missing"}, 400 + else: + if req_host.split(":")[0] not in ["localhost", "127.0.0.1", server_host]: + return {"message": f"host header is invalid (was {req_host})"}, 400 + return f(*args, **kwargs) + + return decorator + + +blueprint = Blueprint("api", __name__, url_prefix="/api") +api = Api(blueprint, doc="/", decorators=[host_header_check]) + + +# Loads event and bucket schema from JSONSchema in aw_core +event = api.schema_model("Event", schema.get_json_schema("event")) +bucket = api.schema_model("Bucket", schema.get_json_schema("bucket")) +buckets_export = api.schema_model("Export", schema.get_json_schema("export")) + +# TODO: Construct all the models from JSONSchema? +# A downside to contructing from JSONSchema: flask-restplus does not have marshalling support + +info = api.model( + "Info", + { + "hostname": fields.String(), + "version": fields.String(), + "testing": fields.Boolean(), + "device_id": fields.String(), + "profile": fields.String(), + }, +) + +create_bucket = api.model( + "CreateBucket", + { + "client": fields.String(required=True), + "type": fields.String(required=True), + "hostname": fields.String(required=True), + }, +) + +update_bucket = api.model( + "UpdateBucket", + { + "client": fields.String(required=False), + "type": fields.String(required=False), + "hostname": fields.String(required=False), + "data": fields.String(required=False), + }, +) + +query = api.model( + "Query", + { + "timeperiods": fields.List( + fields.String, required=True, description="List of periods to query" + ), + "query": fields.List( + fields.String, required=True, description="String list of query statements" + ), + }, +) + + +def copy_doc(api_method): + """Decorator that copies another functions docstring to the decorated function. + Used to copy the docstrings in ServerAPI over to the flask-restplus Resources. + (The copied docstrings are then used by flask-restplus/swagger)""" + + def decorator(f): + f.__doc__ = api_method.__doc__ + return f + + return decorator + + +# SERVER INFO + + +@api.route("/0/info") +class InfoResource(Resource): + @api.marshal_with(info) + @copy_doc(ServerAPI.get_info) + def get(self) -> Dict[str, Dict]: + return current_app.api.get_info() + + +# BUCKETS + + +@api.route("/0/buckets/") +class BucketsResource(Resource): + # TODO: Add response marshalling/validation + @copy_doc(ServerAPI.get_buckets) + def get(self) -> Dict[str, Dict]: + return current_app.api.get_buckets() + + +@api.route("/0/buckets/") +class BucketResource(Resource): + @api.doc(model=bucket) + @copy_doc(ServerAPI.get_bucket_metadata) + def get(self, bucket_id): + return current_app.api.get_bucket_metadata(bucket_id) + + @api.expect(create_bucket) + @copy_doc(ServerAPI.create_bucket) + def post(self, bucket_id): + data = request.get_json() + bucket_created = current_app.api.create_bucket( + bucket_id, + event_type=data["type"], + client=data["client"], + hostname=data["hostname"], + ) + if bucket_created: + return {}, 200 + else: + return {}, 304 + + @api.expect(update_bucket) + @copy_doc(ServerAPI.update_bucket) + def put(self, bucket_id): + data = request.get_json() + current_app.api.update_bucket( + bucket_id, + event_type=data["type"], + client=data["client"], + hostname=data["hostname"], + data=data["data"], + ) + return {}, 200 + + @copy_doc(ServerAPI.delete_bucket) + @api.param("force", "Needs to be =1 to delete a bucket it non-testing mode") + def delete(self, bucket_id): + args = request.args + if not current_app.api.testing: + if "force" not in args or args["force"] != "1": + msg = "Deleting buckets is only permitted if aw-server is running in testing mode or if ?force=1" + raise Unauthorized("DeleteBucketUnauthorized", msg) + + current_app.api.delete_bucket(bucket_id) + return {}, 200 + + +# EVENTS + + +@api.route("/0/buckets//events") +class EventsResource(Resource): + # For some reason this doesn't work with the JSONSchema variant + # Marshalling doesn't work with JSONSchema events + # @api.marshal_list_with(event) + @api.doc(model=event) + @api.param("limit", "the maximum number of requests to get") + @api.param("start", "Start date of events") + @api.param("end", "End date of events") + @copy_doc(ServerAPI.get_events) + def get(self, bucket_id): + args = request.args + limit = int(args["limit"]) if "limit" in args else -1 + start = iso8601.parse_date(args["start"]) if "start" in args else None + end = iso8601.parse_date(args["end"]) if "end" in args else None + + events = current_app.api.get_events( + bucket_id, limit=limit, start=start, end=end + ) + return events, 200 + + # TODO: How to tell expect that it could be a list of events? Until then we can't use validate. + @api.expect(event) + @copy_doc(ServerAPI.create_events) + def post(self, bucket_id): + data = request.get_json() + logger.debug( + "Received post request for event in bucket '{}' and data: {}".format( + bucket_id, data + ) + ) + + if isinstance(data, dict): + events = [Event(**data)] + elif isinstance(data, list): + events = [Event(**e) for e in data] + else: + raise BadRequest("Invalid POST data", "") + + events = current_app.api.create_events(bucket_id, events) + return [e.to_json_dict() for e in events], 200 + + +@api.route("/0/buckets//events/count") +class EventCountResource(Resource): + @api.doc(model=fields.Integer) + @api.param("start", "Start date of eventcount") + @api.param("end", "End date of eventcount") + @copy_doc(ServerAPI.get_eventcount) + def get(self, bucket_id): + args = request.args + start = iso8601.parse_date(args["start"]) if "start" in args else None + end = iso8601.parse_date(args["end"]) if "end" in args else None + + events = current_app.api.get_eventcount(bucket_id, start=start, end=end) + return events, 200 + + +@api.route("/0/buckets//events/") +class EventResource(Resource): + @api.doc(model=event) + @copy_doc(ServerAPI.get_event) + def get(self, bucket_id: str, event_id: int): + logger.debug( + f"Received get request for event with id '{event_id}' in bucket '{bucket_id}'" + ) + event = current_app.api.get_event(bucket_id, event_id) + if event: + return event, 200 + else: + return None, 404 + + @copy_doc(ServerAPI.delete_event) + def delete(self, bucket_id: str, event_id: int): + logger.debug( + "Received delete request for event with id '{}' in bucket '{}'".format( + event_id, bucket_id + ) + ) + success = current_app.api.delete_event(bucket_id, event_id) + return {"success": success}, 200 + + +@api.route("/0/buckets//heartbeat") +class HeartbeatResource(Resource): + # Class-level lock shared across all instances. + # Flask-RESTX creates a new Resource instance per request, so an + # instance-level lock would provide no mutual exclusion. + lock = Lock() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @api.expect(event, validate=True) + @api.param( + "pulsetime", "Largest timewindow allowed between heartbeats for them to merge" + ) + @copy_doc(ServerAPI.heartbeat) + def post(self, bucket_id): + heartbeat = Event(**request.get_json()) + + if "pulsetime" in request.args: + pulsetime = float(request.args["pulsetime"]) + else: + raise BadRequest("MissingParameter", "Missing required parameter pulsetime") + + # This lock is meant to ensure that only one heartbeat is processed at a time, + # as the heartbeat function is not thread-safe. + # This should maybe be moved into the api.py file instead (but would be very messy). + acquired = self.lock.acquire(timeout=10) + if not acquired: + logger.warning( + "Heartbeat lock could not be acquired within timeout, rejecting request." + ) + return {"message": "Server busy, try again later"}, 503 + try: + event = current_app.api.heartbeat(bucket_id, heartbeat, pulsetime) + finally: + self.lock.release() + return event.to_json_dict(), 200 + + +# QUERY + + +@api.route("/0/query/") +class QueryResource(Resource): + # TODO Docs + @api.expect(query, validate=True) + @api.param("name", "Name of the query (required if using cache)") + def post(self): + name = "" + if "name" in request.args: + name = request.args["name"] + query = request.get_json() + try: + result = current_app.api.query2( + name, query["query"], query["timeperiods"], False + ) + return jsonify(result) + except QueryException as qe: + traceback.print_exc() + return {"type": type(qe).__name__, "message": str(qe)}, 400 + + +# EXPORT AND IMPORT + + +@api.route("/0/export") +class ExportAllResource(Resource): + @api.doc(model=buckets_export) + @copy_doc(ServerAPI.export_all) + def get(self): + buckets_export = current_app.api.export_all() + payload = {"buckets": buckets_export} + response = make_response(json.dumps(payload)) + filename = "aw-buckets-export.json" + response.headers["Content-Disposition"] = "attachment; filename={}".format( + filename + ) + return response + + +# TODO: Perhaps we don't need this, could be done with a query argument to /0/export instead +@api.route("/0/buckets//export") +class BucketExportResource(Resource): + @api.doc(model=buckets_export) + @copy_doc(ServerAPI.export_bucket) + def get(self, bucket_id): + bucket_export = current_app.api.export_bucket(bucket_id) + payload = {"buckets": {bucket_export["id"]: bucket_export}} + response = make_response(json.dumps(payload)) + filename = "aw-bucket-export_{}.json".format(bucket_export["id"]) + response.headers["Content-Disposition"] = "attachment; filename={}".format( + filename + ) + return response + + +@api.route("/0/import") +class ImportAllResource(Resource): + @api.expect(buckets_export) + @copy_doc(ServerAPI.import_all) + def post(self): + # If import comes from a form in th web-ui + if len(request.files) > 0: + # web-ui form only allows one file, but technically it's possible to + # upload multiple files at the same time + for filename, f in request.files.items(): + buckets = json.loads(f.stream.read())["buckets"] + current_app.api.import_all(buckets) + # Normal import from body + else: + buckets = request.get_json()["buckets"] + current_app.api.import_all(buckets) + return None, 200 + + +# LOGGING + + +@api.route("/0/log") +class LogResource(Resource): + @copy_doc(ServerAPI.get_log) + def get(self): + return current_app.api.get_log(), 200 + + +# SETTINGS + + +@api.route("/0/settings", defaults={"key": ""}) +@api.route("/0/settings/") +class SettingsResource(Resource): + def get(self, key: str): + data = current_app.api.get_setting(key) + return jsonify(data) + + def post(self, key: str): + if not key: + raise BadRequest("MissingParameter", "Missing required parameter key") + data = current_app.api.set_setting(key, request.get_json()) + return data diff --git a/aw_server/server.py b/aw_server/server.py new file mode 100644 index 0000000..073406f --- /dev/null +++ b/aw_server/server.py @@ -0,0 +1,151 @@ +import logging +import os +from datetime import datetime, timedelta +from typing import Dict, List + +import aw_datastore +import flask.json.provider +from aw_datastore import Datastore +from flask import ( + Blueprint, + Flask, + current_app, + send_from_directory, +) +from flask_cors import CORS + +from . import extension_cors, rest +from .api import ServerAPI +from .custom_static import get_custom_static_blueprint +from .log import FlaskLogHandler + +logger = logging.getLogger(__name__) + +app_folder = os.path.dirname(os.path.abspath(__file__)) +static_folder = os.path.join(app_folder, "static") + +root = Blueprint("root", __name__, url_prefix="/") + + +class AWFlask(Flask): + def __init__( + self, + host: str, + testing: bool, + storage_method=None, + cors_origins=[], + custom_static=dict(), + static_folder=static_folder, + static_url_path="", + ): + name = "aw-server" + self.json_provider_class = CustomJSONProvider + # only prettyprint JSON if testing (due to perf) + self.json_provider_class.compact = not testing + + # Initialize Flask + Flask.__init__( + self, + name, + static_folder=static_folder, + static_url_path=static_url_path, + ) + self.config["HOST"] = host # needed for host-header check + with self.app_context(): + _config_cors(cors_origins, testing) + + # Initialize datastore and API + if storage_method is None: + storage_method = aw_datastore.get_storage_methods()["memory"] + db = Datastore(storage_method, testing=testing) + self.api = ServerAPI(db=db, testing=testing) + + self.register_blueprint(root) + self.register_blueprint(rest.blueprint) + self.register_blueprint(get_custom_static_blueprint(custom_static)) + + +class CustomJSONProvider(flask.json.provider.DefaultJSONProvider): + # encoding/decoding of datetime as iso8601 strings + # encoding of timedelta as second floats + def default(self, obj, *args, **kwargs): + try: + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, timedelta): + return obj.total_seconds() + except TypeError: + pass + return super().default(obj) + + +@root.route("/") +def static_root(): + return current_app.send_static_file("index.html") + + +@root.route("/css/") +def static_css(path): + return send_from_directory(static_folder + "/css", path) + + +@root.route("/js/") +def static_js(path): + return send_from_directory(static_folder + "/js", path) + + +def _config_cors(cors_origins: List[str], testing: bool): + if cors_origins: + logger.warning( + "Running with additional allowed CORS origins specified through config " + "or CLI argument (could be a security risk): {}".format(cors_origins) + ) + + if testing: + # Used for development of aw-webui + cors_origins.append("http://127.0.0.1:27180/*") + + # Capture user-configured origins before appending the built-in wildcard. + # extension_cors uses this list to exempt explicit opt-ins from scope narrowing. + user_origins = list(cors_origins) + + # TODO: This could probably be more specific + # See https://github.com/ActivityWatch/aw-server/pull/43#issuecomment-386888769 + cors_origins.append("moz-extension://*") + + # See: https://flask-cors.readthedocs.org/en/latest/ + CORS(current_app, resources={r"/api/*": {"origins": cors_origins}}) + + # Narrow the moz-extension wildcard to only the endpoints aw-watcher-web needs. + # See aw_server/extension_cors.py and ActivityWatch/aw-server-rust#637. + extension_cors.register(current_app._get_current_object(), user_origins) + + +# Only to be called from aw_server.main function! +def _start( + storage_method, + host: str, + port: int, + testing: bool = False, + cors_origins: List[str] = [], + custom_static: Dict[str, str] = dict(), +): + app = AWFlask( + host, + testing=testing, + storage_method=storage_method, + cors_origins=cors_origins, + custom_static=custom_static, + ) + try: + app.run( + debug=testing, + host=host, + port=port, + request_handler=FlaskLogHandler, + use_reloader=False, + threaded=True, + ) + except OSError as e: + logger.exception(e) + raise e diff --git a/aw_server/settings.py b/aw_server/settings.py new file mode 100644 index 0000000..65c2971 --- /dev/null +++ b/aw_server/settings.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +from aw_core.dirs import get_config_dir + +from .profile import profile_from_env, profile_suffix + + +class Settings: + def __init__(self, testing: bool): + # Dir isolation (AW_PROFILE) already separates profiles; the filename + # suffix is the pre-profile workaround and stays so --testing still + # finds settings-testing.json. Named profiles get the same shape. + filename = f"settings{profile_suffix(profile_from_env(testing=testing))}.json" + self.config_file = Path(get_config_dir("aw-server")) / filename + self.load() + + def __getitem__(self, key): + return self.get(key) + + def __setitem__(self, key, value): + return self.set(key, value) + + def load(self): + if self.config_file.exists(): + with open(self.config_file) as f: + self.data = json.load(f) + else: + self.data = {} + + def save(self): + with open(self.config_file, "w") as f: + json.dump(self.data, f, indent=4) + + def get(self, key: str, default=None): + if not key: + return self.data + return self.data.get(key, default) + + def set(self, key, value): + if value: + self.data[key] = value + else: + if key in self.data: + del self.data[key] + self.save() diff --git a/misc/aw-server.service b/misc/aw-server.service new file mode 100644 index 0000000..7877555 --- /dev/null +++ b/misc/aw-server.service @@ -0,0 +1,12 @@ +[Unit] +Description=ActivityWatch +Documentation= +After=mongodb.service + +[Service] +Type=simple +ExecStart=/usr/bin/env aw-server +Restart=always + +[Install] +WantedBy=default.target diff --git a/misc/process_logs.py b/misc/process_logs.py new file mode 100644 index 0000000..1475637 --- /dev/null +++ b/misc/process_logs.py @@ -0,0 +1,13 @@ +""" +Useful for turning JSON logs into human-readable logs. + +Usage: + aw-server --log-json 2>&1 | python3 process_logs.py + +""" + +import json + +while True: + msg = json.loads(input()) + print("{asctime} [{levelname}] {message}".format(**msg)) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..5fb760a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1195 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "aniso8601" +version = "9.0.1" +description = "A library for parsing ISO 8601 strings." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"}, + {file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"}, +] + +[package.extras] +dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"] + +[[package]] +name = "attrs" +version = "23.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.1.1) ; platform_python_implementation == \"CPython\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version < \"3.11\"", "pytest-xdist[psutil]"] + +[[package]] +name = "aw-client" +version = "0.5.12" +description = "Client library for ActivityWatch" +optional = false +python-versions = ">=3.8,<4.0" +groups = ["main"] +files = [ + {file = "aw_client-0.5.12-py3-none-any.whl", hash = "sha256:12bd6cbc02a06e970267f7f192a31a0036b4a9f9ae0852bd0a8bc718b9b8ea5a"}, + {file = "aw_client-0.5.12.tar.gz", hash = "sha256:824b5e83abe755d5f089bbbf87eaabdc8895220c1642904a5aa6b66fba09b6ba"}, +] + +[package.dependencies] +aw-core = ">=0.5.7,<0.6.0" +click = ">=8.0,<9.0" +persist-queue = "*" +requests = "*" +tabulate = "*" +typing-extensions = "*" + +[[package]] +name = "aw-core" +version = "0.5.18" +description = "Core library for ActivityWatch" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "aw_core-0.5.18-py3-none-any.whl", hash = "sha256:6e3df7b46e913d631e0dc8b1b10046c111284a9f4240bec8efd279ce4e438cd5"}, + {file = "aw_core-0.5.18.tar.gz", hash = "sha256:dc14867ed15b4529d6e69b1d07df3fb29c91ccf7ab174de3f9100b90a95cc826"}, +] + +[package.dependencies] +click = "*" +deprecation = "*" +iso8601 = "*" +jsonschema = ">=4.3,<5.0" +peewee = ">=3.0.0,<5.0.0" +platformdirs = "3.10" +rfc3339-validator = ">=0.1.4,<0.2.0" +strict-rfc3339 = ">=0.7,<0.8" +timeslot = "*" +tomlkit = "*" + +[[package]] +name = "black" +version = "23.10.1" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"}, + {file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"}, + {file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"}, + {file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"}, + {file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"}, + {file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"}, + {file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"}, + {file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"}, + {file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"}, + {file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"}, + {file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"}, + {file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"}, + {file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"}, + {file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"}, + {file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"}, + {file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"}, + {file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"}, + {file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "blinker" +version = "1.6.3" +description = "Fast, simple object-to-object and broadcast signaling" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "blinker-1.6.3-py3-none-any.whl", hash = "sha256:296320d6c28b006eb5e32d4712202dbcdcbf5dc482da298c2f44881c43884aaa"}, + {file = "blinker-1.6.3.tar.gz", hash = "sha256:152090d27c1c5c722ee7e48504b02d76502811ce02e1523553b4cf8c8b3d3a8d"}, +] + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "charset-normalizer-3.3.1.tar.gz", hash = "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win32.whl", hash = "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win32.whl", hash = "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win32.whl", hash = "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win32.whl", hash = "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win32.whl", hash = "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win32.whl", hash = "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727"}, + {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +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 = "platform_system == \"Windows\" or sys_platform == \"win32\""} + +[[package]] +name = "coverage" +version = "7.3.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, + {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, + {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, + {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, + {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, + {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, + {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, + {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, + {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, + {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, + {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, + {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, + {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, + {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, + {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, + {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, + {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, + {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, + {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, + {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, + {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, + {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, + {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, + {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, + {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, + {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, + {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, + {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, +] + +[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.1.3" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "flask" +version = "2.3.3" +description = "A simple framework for building complex web applications." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "flask-2.3.3-py3-none-any.whl", hash = "sha256:f69fcd559dc907ed196ab9df0e48471709175e696d6e698dd4dbe940f96ce66b"}, + {file = "flask-2.3.3.tar.gz", hash = "sha256:09c347a92aa7ff4a8e7f3206795f30d826654baf38b873d0744cd571ca609efc"}, +] + +[package.dependencies] +blinker = ">=1.6.2" +click = ">=8.1.3" +importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} +itsdangerous = ">=2.1.2" +Jinja2 = ">=3.1.2" +Werkzeug = ">=2.3.7" + +[package.extras] +async = ["asgiref (>=3.2)"] +dotenv = ["python-dotenv"] + +[[package]] +name = "flask-cors" +version = "4.0.0" +description = "A Flask extension adding a decorator for CORS support" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "Flask-Cors-4.0.0.tar.gz", hash = "sha256:f268522fcb2f73e2ecdde1ef45e2fd5c71cc48fe03cffb4b441c6d1b40684eb0"}, + {file = "Flask_Cors-4.0.0-py2.py3-none-any.whl", hash = "sha256:bc3492bfd6368d27cfe79c7821df5a8a319e1a6d5eab277a3794be19bdc51783"}, +] + +[package.dependencies] +Flask = ">=0.9" + +[[package]] +name = "flask-restx" +version = "1.2.0" +description = "Fully featured framework for fast, easy and documented API development with Flask" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "flask-restx-1.2.0.tar.gz", hash = "sha256:9a5338b108c57fbed1d24d5d53fe98442b2be7ffa2ff3291305af7a613ce6fc0"}, + {file = "flask_restx-1.2.0-py2.py3-none-any.whl", hash = "sha256:be3837378c51893adf94f632b6e458c4c2d54c6bc9f4210206ebc2fb1a70ada9"}, +] + +[package.dependencies] +aniso8601 = ">=0.82" +Flask = ">=0.8,<2.0.0 || >2.0.0,<3.0.0" +importlib-resources = "*" +jsonschema = "<=4.17.3" +pytz = "*" +werkzeug = "<2.0.0 || >2.0.0,<3.0.0" + +[package.extras] +dev = ["Faker (==2.0.0)", "black", "blinker", "invoke (==2.0.0)", "mock (==3.0.5)", "pytest (==7.0.1)", "pytest-benchmark (==3.4.1)", "pytest-cov (==4.0.0)", "pytest-flask (==1.2.0)", "pytest-mock (==3.6.1)", "pytest-profiling (==1.7.0)", "tox", "twine (==3.8.0)", "tzlocal"] +doc = ["Sphinx (==5.3.0)", "alabaster (==0.7.12)", "sphinx-issues (==3.0.1)"] +test = ["Faker (==2.0.0)", "blinker", "invoke (==2.0.0)", "mock (==3.0.5)", "pytest (==7.0.1)", "pytest-benchmark (==3.4.1)", "pytest-cov (==4.0.0)", "pytest-flask (==1.2.0)", "pytest-mock (==3.6.1)", "pytest-profiling (==1.7.0)", "twine (==3.8.0)", "tzlocal"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "importlib-metadata" +version = "6.8.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_version < \"3.10\"" +files = [ + {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, + {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "importlib-resources" +version = "6.1.0" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "importlib_resources-6.1.0-py3-none-any.whl", hash = "sha256:aa50258bbfa56d4e33fbd8aa3ef48ded10d1735f11532b8df95388cc6bdb7e83"}, + {file = "importlib_resources-6.1.0.tar.gz", hash = "sha256:9d48dcccc213325e810fd723e7fbb45ccb39f6cf5c31f00cf2b965f5f10f3cb9"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-ruff", "zipp (>=3.17)"] + +[[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 = "1.1.0" +description = "Simple module to parse ISO 8601 dates" +optional = false +python-versions = ">=3.6.2,<4.0" +groups = ["main"] +files = [ + {file = "iso8601-1.1.0-py3-none-any.whl", hash = "sha256:8400e90141bf792bce2634df533dc57e3bee19ea120a87bebcd3da89a58ad73f"}, + {file = "iso8601-1.1.0.tar.gz", hash = "sha256:32811e7b81deee2063ea6d2e94f8819a86d1f3811e49d23623a41fa832bef03f"}, +] + +[[package]] +name = "itsdangerous" +version = "2.1.2" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"}, + {file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"}, +] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jsonschema" +version = "4.17.3" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jsonschema-4.17.3-py3-none-any.whl", hash = "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6"}, + {file = "jsonschema-4.17.3.tar.gz", hash = "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d"}, +] + +[package.dependencies] +attrs = ">=17.4.0" +importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} +pkgutil-resolve-name = {version = ">=1.3.10", markers = "python_version < \"3.9\""} +pyrsistent = ">=0.14.0,<0.17.0 || >0.17.0,<0.17.1 || >0.17.1,<0.17.2 || >0.17.2" + +[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 (>=1.11)"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "mypy" +version = "1.6.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c"}, + {file = "mypy-1.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb"}, + {file = "mypy-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a1ad938fee7d2d96ca666c77b7c494c3c5bd88dff792220e1afbebb2925b5e"}, + {file = "mypy-1.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b96ae2c1279d1065413965c607712006205a9ac541895004a1e0d4f281f2ff9f"}, + {file = "mypy-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:40b1844d2e8b232ed92e50a4bd11c48d2daa351f9deee6c194b83bf03e418b0c"}, + {file = "mypy-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81af8adaa5e3099469e7623436881eff6b3b06db5ef75e6f5b6d4871263547e5"}, + {file = "mypy-1.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8c223fa57cb154c7eab5156856c231c3f5eace1e0bed9b32a24696b7ba3c3245"}, + {file = "mypy-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8032e00ce71c3ceb93eeba63963b864bf635a18f6c0c12da6c13c450eedb183"}, + {file = "mypy-1.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c46b51de523817a0045b150ed11b56f9fff55f12b9edd0f3ed35b15a2809de0"}, + {file = "mypy-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:19f905bcfd9e167159b3d63ecd8cb5e696151c3e59a1742e79bc3bcb540c42c7"}, + {file = "mypy-1.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:82e469518d3e9a321912955cc702d418773a2fd1e91c651280a1bda10622f02f"}, + {file = "mypy-1.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d4473c22cc296425bbbce7e9429588e76e05bc7342da359d6520b6427bf76660"}, + {file = "mypy-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a0d7d24dfb26729e0a068639a6ce3500e31d6655df8557156c51c1cb874ce7"}, + {file = "mypy-1.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cfd13d47b29ed3bbaafaff7d8b21e90d827631afda134836962011acb5904b71"}, + {file = "mypy-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:eb4f18589d196a4cbe5290b435d135dee96567e07c2b2d43b5c4621b6501531a"}, + {file = "mypy-1.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:41697773aa0bf53ff917aa077e2cde7aa50254f28750f9b88884acea38a16169"}, + {file = "mypy-1.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7274b0c57737bd3476d2229c6389b2ec9eefeb090bbaf77777e9d6b1b5a9d143"}, + {file = "mypy-1.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbaf4662e498c8c2e352da5f5bca5ab29d378895fa2d980630656178bd607c46"}, + {file = "mypy-1.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bb8ccb4724f7d8601938571bf3f24da0da791fe2db7be3d9e79849cb64e0ae85"}, + {file = "mypy-1.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:68351911e85145f582b5aa6cd9ad666c8958bcae897a1bfda8f4940472463c45"}, + {file = "mypy-1.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:49ae115da099dcc0922a7a895c1eec82c1518109ea5c162ed50e3b3594c71208"}, + {file = "mypy-1.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b27958f8c76bed8edaa63da0739d76e4e9ad4ed325c814f9b3851425582a3cd"}, + {file = "mypy-1.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:925cd6a3b7b55dfba252b7c4561892311c5358c6b5a601847015a1ad4eb7d332"}, + {file = "mypy-1.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8f57e6b6927a49550da3d122f0cb983d400f843a8a82e65b3b380d3d7259468f"}, + {file = "mypy-1.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:a43ef1c8ddfdb9575691720b6352761f3f53d85f1b57d7745701041053deff30"}, + {file = "mypy-1.6.1-py3-none-any.whl", hash = "sha256:4cbe68ef919c28ea561165206a2dcb68591c50f3bcf777932323bc208d949cf1"}, + {file = "mypy-1.6.1.tar.gz", hash = "sha256:4d01c00d09a0be62a4ca3f933e315455bde83f37f892ba4b08ce92f3cf44bcc1"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +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 = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "peewee" +version = "3.17.0" +description = "a little orm" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "peewee-3.17.0.tar.gz", hash = "sha256:3a56967f28a43ca7a4287f4803752aeeb1a57a08dee2e839b99868181dfb5df8"}, +] + +[[package]] +name = "persist-queue" +version = "0.8.1" +description = "A thread-safe disk based persistent queue in Python." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "persist-queue-0.8.1.tar.gz", hash = "sha256:e1938d3ac6d9b61692c115f8ddcada8d9a31f22506782585e39588a41ecfa5aa"}, + {file = "persist_queue-0.8.1-py2.py3-none-any.whl", hash = "sha256:9ea2c2fa80edc1ce9f683665c3ebe78bbe71d08868206d0c67a81135182a19f9"}, +] + +[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", "dev"] +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.3.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +description = "Get CPU info with pure Python" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690"}, + {file = "py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5"}, +] + +[[package]] +name = "pyrsistent" +version = "0.19.3" +description = "Persistent/Functional/Immutable data structures" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyrsistent-0.19.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a"}, + {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64"}, + {file = "pyrsistent-0.19.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf"}, + {file = "pyrsistent-0.19.3-cp310-cp310-win32.whl", hash = "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a"}, + {file = "pyrsistent-0.19.3-cp310-cp310-win_amd64.whl", hash = "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da"}, + {file = "pyrsistent-0.19.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9"}, + {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393"}, + {file = "pyrsistent-0.19.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19"}, + {file = "pyrsistent-0.19.3-cp311-cp311-win32.whl", hash = "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3"}, + {file = "pyrsistent-0.19.3-cp311-cp311-win_amd64.whl", hash = "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-win32.whl", hash = "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1"}, + {file = "pyrsistent-0.19.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b"}, + {file = "pyrsistent-0.19.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8"}, + {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a"}, + {file = "pyrsistent-0.19.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c"}, + {file = "pyrsistent-0.19.3-cp38-cp38-win32.whl", hash = "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c"}, + {file = "pyrsistent-0.19.3-cp38-cp38-win_amd64.whl", hash = "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7"}, + {file = "pyrsistent-0.19.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc"}, + {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2"}, + {file = "pyrsistent-0.19.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3"}, + {file = "pyrsistent-0.19.3-cp39-cp39-win32.whl", hash = "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2"}, + {file = "pyrsistent-0.19.3-cp39-cp39-win_amd64.whl", hash = "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98"}, + {file = "pyrsistent-0.19.3-py3-none-any.whl", hash = "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64"}, + {file = "pyrsistent-0.19.3.tar.gz", hash = "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440"}, +] + +[[package]] +name = "pytest" +version = "7.4.3" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-benchmark" +version = "4.0.0" +description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1"}, + {file = "pytest_benchmark-4.0.0-py3-none-any.whl", hash = "sha256:fdb7db64e31c8b277dff9850d2a2556d8b60bcb0ea6524e36e28ffd7c87f71d6"}, +] + +[package.dependencies] +py-cpuinfo = "*" +pytest = ">=3.8" + +[package.extras] +aspect = ["aspectlib"] +elasticsearch = ["elasticsearch"] +histogram = ["pygal", "pygaljs"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytest-flask" +version = "1.3.0" +description = "A set of py.test fixtures to test Flask applications." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e"}, + {file = "pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253"}, +] + +[package.dependencies] +Flask = "*" +pytest = ">=5.2" +Werkzeug = "*" + +[package.extras] +docs = ["Sphinx", "sphinx-rtd-theme"] + +[[package]] +name = "pytz" +version = "2023.3.post1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +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 = "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.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tomlkit" +version = "0.12.1" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tomlkit-0.12.1-py3-none-any.whl", hash = "sha256:712cbd236609acc6a3e2e97253dfc52d4c2082982a88f61b640ecf0817eab899"}, + {file = "tomlkit-0.12.1.tar.gz", hash = "sha256:38e1ff8edb991273ec9f6181244a6a391ac30e9f5098e7535640ea6be97a7c86"}, +] + +[[package]] +name = "types-click" +version = "7.1.8" +description = "Typing stubs for click" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "types-click-7.1.8.tar.gz", hash = "sha256:b6604968be6401dc516311ca50708a0a28baa7a0cb840efd7412f0dbbff4e092"}, + {file = "types_click-7.1.8-py3-none-any.whl", hash = "sha256:8cb030a669e2e927461be9827375f83c16b8178c365852c060a34e24871e7e81"}, +] + +[[package]] +name = "types-flask" +version = "1.1.6" +description = "Typing stubs for Flask" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "types-Flask-1.1.6.tar.gz", hash = "sha256:aac777b3abfff9436e6b01f6d08171cf23ea6e5be71cbf773aaabb1c5763e9cf"}, + {file = "types_Flask-1.1.6-py3-none-any.whl", hash = "sha256:6ab8a9a5e258b76539d652f6341408867298550b19b81f0e41e916825fc39087"}, +] + +[package.dependencies] +types-click = "*" +types-Jinja2 = "*" +types-Werkzeug = "*" + +[[package]] +name = "types-jinja2" +version = "2.11.9" +description = "Typing stubs for Jinja2" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "types-Jinja2-2.11.9.tar.gz", hash = "sha256:dbdc74a40aba7aed520b7e4d89e8f0fe4286518494208b35123bcf084d4b8c81"}, + {file = "types_Jinja2-2.11.9-py3-none-any.whl", hash = "sha256:60a1e21e8296979db32f9374d8a239af4cb541ff66447bb915d8ad398f9c63b2"}, +] + +[package.dependencies] +types-MarkupSafe = "*" + +[[package]] +name = "types-markupsafe" +version = "1.1.10" +description = "Typing stubs for MarkupSafe" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "types-MarkupSafe-1.1.10.tar.gz", hash = "sha256:85b3a872683d02aea3a5ac2a8ef590193c344092032f58457287fbf8e06711b1"}, + {file = "types_MarkupSafe-1.1.10-py3-none-any.whl", hash = "sha256:ca2bee0f4faafc45250602567ef38d533e877d2ddca13003b319c551ff5b3cc5"}, +] + +[[package]] +name = "types-setuptools" +version = "75.8.0.20250110" +description = "Typing stubs for setuptools" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "types_setuptools-75.8.0.20250110-py3-none-any.whl", hash = "sha256:a9f12980bbf9bcdc23ecd80755789085bad6bfce4060c2275bc2b4ca9f2bc480"}, + {file = "types_setuptools-75.8.0.20250110.tar.gz", hash = "sha256:96f7ec8bbd6e0a54ea180d66ad68ad7a1d7954e7281a710ea2de75e355545271"}, +] + +[[package]] +name = "types-werkzeug" +version = "1.0.9" +description = "Typing stubs for Werkzeug" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "types-Werkzeug-1.0.9.tar.gz", hash = "sha256:5cc269604c400133d452a40cee6397655f878fc460e03fde291b9e3a5eaa518c"}, + {file = "types_Werkzeug-1.0.9-py3-none-any.whl", hash = "sha256:194bd5715a13c598f05c63e8a739328657590943bce941e8a3619a6b5d4a54ec"}, +] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, + {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, +] + +[[package]] +name = "urllib3" +version = "2.0.7" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, + {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "werkzeug" +version = "2.3.7" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "werkzeug-2.3.7-py3-none-any.whl", hash = "sha256:effc12dba7f3bd72e605ce49807bbe692bd729c3bb122a3b91747a6ae77df528"}, + {file = "werkzeug-2.3.7.tar.gz", hash = "sha256:2b8c0e447b4b9dbcc85dd97b6eeb4dcbaf6c8b6c3be0bd654e25553e0a2157d8"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + +[[package]] +name = "zipp" +version = "3.17.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_version < \"3.10\"" +files = [ + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-ruff"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.8" +content-hash = "370151e1033b3e3d1c635d9d6ce131af2633bbf6642c9d4e3d11baa38258d60f" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..02fb20c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[tool.poetry] +name = "aw-server" +version = "0.14.0" +description = "Server for ActivityWatch" +authors = ["Erik Bjäreholt "] +license = "MPL-2.0" +include = ["aw_server/static/*"] + +[tool.poetry.scripts] +aw-server = "aw_server:main" + +[tool.poetry.dependencies] +python = "^3.8" +aw-core = "^0.5.18" +aw-client = "^0.5.8" +flask = "^2.2" +flask-restx = "^1.0.3" +flask-cors = "*" +importlib-metadata = {version = "*", python = "<3.10"} +werkzeug = "^2.3.3" + +[tool.poetry.group.dev.dependencies] +mypy = "*" +pytest = "^7.3" +pytest-flask = "*" +pytest-benchmark = "*" +pytest-cov = "*" +types-Werkzeug = "*" +types-setuptools = "*" +types-Flask = "*" +black = "^23.3.0" + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--cov-report=term --cov-report=xml --cov-report=html --cov=aw_server" +python_files = ["*.py",] + +[tool.ruff] +ignore = ["E402", "E501"] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/scripts/benchmark-api-heartbeat.py b/scripts/benchmark-api-heartbeat.py new file mode 100644 index 0000000..110808d --- /dev/null +++ b/scripts/benchmark-api-heartbeat.py @@ -0,0 +1,43 @@ +from time import sleep +import cProfile +import pstats +from datetime import timezone as tz +from datetime import datetime + +from aw_core.models import Event + +import aw_datastore +import aw_server + + +def benchmark(): + ds = aw_datastore.Datastore(aw_datastore.storages.PeeweeStorage, testing=True) + api = aw_server.api.ServerAPI(ds, testing=True) + + print(api.get_info()) + + bucket_id = "test-benchmark" + + try: + api.create_bucket(bucket_id, "test", "test", "test") + except Exception as e: + print(e) + + print("Benchmarking... this will take 30 seconds") + for i in range(120): + sleep(0.1) + api.heartbeat( + bucket_id, + Event(timestamp=datetime.now(tz=tz.utc), data={"test": str(int(i))}), + pulsetime=0.3, + ) + + +if __name__ == "__main__": + f_bench = "benchmark.dat" + cProfile.run("benchmark()", f_bench) + p = pstats.Stats(f_bench) + p.strip_dirs() + # p.sort_stats('tottime') + p.sort_stats("cumulative") + p.print_stats(20) diff --git a/scripts/profile-werkzeug-read.py b/scripts/profile-werkzeug-read.py new file mode 100644 index 0000000..90ee83a --- /dev/null +++ b/scripts/profile-werkzeug-read.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +import pstats + +p = pstats.Stats("profile.out") +p.sort_stats("cumulative").print_stats(100) diff --git a/scripts/profile-werkzeug.py b/scripts/profile-werkzeug.py new file mode 100644 index 0000000..c7e72d2 --- /dev/null +++ b/scripts/profile-werkzeug.py @@ -0,0 +1,6 @@ +from werkzeug.contrib.profiler import ProfilerMiddleware +from aw_server import app + +app.config["PROFILE"] = True +app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30]) +app.run(debug=True) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100755 index 0000000..7e32404 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +import logging +import os + +import pytest +from aw_client import ActivityWatchClient +from aw_server.server import AWFlask + +logging.basicConfig(level=logging.WARN) + + +@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) + + +@pytest.fixture(scope="session") +def app(): + # AWFlask does not go through parse_settings(), so a leftover AW_PROFILE + # from the environment (or a prior test) would disagree with testing=True. + old = os.environ.pop("AW_PROFILE", None) + application = AWFlask("127.0.0.1", testing=True) + if old is not None: + os.environ["AW_PROFILE"] = old + return application + + +@pytest.fixture(scope="session") +def flask_client(app): + yield app.test_client() + + +@pytest.fixture(scope="session") +def aw_client(): + # TODO: Could it be possible to write a sisterclass of ActivityWatchClient + # which calls aw_server.api directly? Would it be of use? Would add another + # layer of integration tests that are actually more like unit tests. + c = ActivityWatchClient("aw-client-test", testing=True) + yield c + + # Delete test buckets after all tests needing the fixture have been run + buckets = c.get_buckets() + for bucket_id in buckets: + if bucket_id.startswith("test-"): + c.delete_bucket(bucket_id) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100755 index 0000000..a650aba --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,297 @@ +import json +import logging +import random +from datetime import datetime, timedelta, timezone +from pprint import pprint +from time import sleep + +import pytest +from aw_core.models import Event + +logging.basicConfig(level=logging.WARN) + +# TODO: Could it be possible to write a sisterclass of ActivityWatchClient +# which calls aw_server.api directly? Would it be of use? Would add another +# layer of integration tests that are actually more like unit tests. + + +@pytest.fixture(scope="function") +def bucket(aw_client): + bucket_id = "test-" + str(random.randint(0, 10**5)) + event_type = "testevents" + aw_client.create_bucket(bucket_id, event_type, queued=False) + print(f"Created bucket {bucket_id}") + sleep(1) + yield bucket_id + aw_client.delete_bucket(bucket_id) + + +@pytest.fixture +def queued_bucket(aw_client, bucket): + # FIXME: We need a way to clear the failed_requests file in order + # to have tests behave reasonably between runs. + aw_client.connect() + yield bucket + aw_client.disconnect() + + +def test_get_info(aw_client): + info = aw_client.get_info() + assert info["testing"] + + +def test_export(aw_client): + export = aw_client._get("export").json() + for bucket_id, bucket in export["buckets"].items(): + assert bucket["id"] + assert "events" in bucket + # print(export) + + +def _create_heartbeat_events( + start=datetime.now(tz=timezone.utc), delta=timedelta(seconds=1) +): + e1_ts = start + e2_ts = e1_ts + delta + + # Needed since server (or underlying datastore) drops precision up to milliseconds. + # Update: Even with millisecond precision it sometimes fails. (tried using `round` and `int`) + # Now rounding down to 10ms precision to prevent random failure. + # 10ms precision at least seems to work well. + # TODO: Figure out why it sometimes fails with millisecond precision. Would probably + # be useful to find the microsecond values where it consistently always fails. + e1_ts = e1_ts.replace(microsecond=int(e1_ts.microsecond / 10000) * 100) + e2_ts = e2_ts.replace(microsecond=int(e2_ts.microsecond / 10000) * 100) + + e1 = Event(timestamp=e1_ts, data={"label": "test"}) + e2 = Event(timestamp=e2_ts, data={"label": "test"}) + + return e1, e2 + + +def _create_periodic_events( + num_events, start=datetime.now(tz=timezone.utc), delta=timedelta(hours=1) +): + events = num_events * [None] + + for i, dt in ((i, start + i * delta) for i in range(len(events))): + events[i] = Event(timestamp=dt, duration=delta, data={"label": "test"}) + + return events + + +def test_heartbeat(aw_client, bucket): + bucket_id = bucket + + e1, e2 = _create_heartbeat_events() + + aw_client.heartbeat(bucket_id, e1, pulsetime=0) + aw_client.heartbeat(bucket_id, e2, pulsetime=10) + + event = aw_client.get_events(bucket_id, limit=1)[0] + + assert event.timestamp == e1.timestamp + assert event.duration == e2.timestamp - e1.timestamp + + +def test_heartbeat_random_order(aw_client, bucket): + bucket_id = bucket + + # All the events will have the same data + events = _create_periodic_events(100, delta=timedelta(seconds=1)) + random.shuffle(events) + + for e in events: + aw_client.heartbeat(bucket_id, e, pulsetime=2) + + events = aw_client.get_events(bucket_id, limit=-1) + + # FIXME: This should pass + # assert len(events) == 1 + + +def test_queued_heartbeat(aw_client, queued_bucket): + bucket_id = queued_bucket + + e1, e2 = _create_heartbeat_events() + + aw_client.heartbeat(bucket_id, e1, pulsetime=0, queued=True) + aw_client.heartbeat(bucket_id, e2, pulsetime=10, queued=True) + + # Needed because of aw_client-side heartbeat merging and delayed dispatch + aw_client.heartbeat( + bucket_id, + Event(timestamp=e2.timestamp, data={"label": "something different"}), + pulsetime=0, + queued=True, + ) + + # Needed since the dispatcher thread might introduce some delay + max_tries = 20 + for i in range(max_tries): + events = aw_client.get_events(bucket_id, limit=1) + if len(events) > 0 and events[0].duration > timedelta(seconds=0): + break + sleep(0.5) + + assert i != max_tries - 1 + print(f"Done on the {i + 1}th try") + + assert len(events) == 1 + event = events[0] + + assert event.timestamp == e1.timestamp + assert event.duration == e2.timestamp - e1.timestamp + + +def test_list_buckets(aw_client, bucket): + buckets = aw_client.get_buckets() + assert bucket in buckets.keys() + + +def test_insert_event(aw_client, bucket): + event = Event(timestamp=datetime.now(tz=timezone.utc), data={"label": "test"}) + aw_client.insert_event(bucket, event) + recv_events = aw_client.get_events(bucket, limit=1) + assert recv_events == [event] + + +def test_insert_events(aw_client, bucket): + events = _create_periodic_events( + 2, start=datetime.now(tz=timezone.utc) - timedelta(days=1) + ) + + aw_client.insert_events(bucket, events) + recv_events = aw_client.get_events(bucket) + + # Why isn't reverse=True needed here? + assert events == sorted(recv_events, key=lambda e: e.timestamp) + + +def test_get_event_single(aw_client, bucket): + start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50) + delta = timedelta(hours=1) + events = _create_periodic_events(10, delta=delta, start=start_dt) + + aw_client.insert_events(bucket, events) + + events = aw_client.get_events(bucket) + + for e in events: + e2 = aw_client.get_event(bucket, e.id) + assert e.id == e2.id + assert e.timestamp == e2.timestamp + assert e.duration == e2.duration + assert e.data == e2.data + + +def test_get_events_interval(aw_client, bucket): + start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50) + end_dt = start_dt + timedelta(days=1) + delta = timedelta(hours=1) + events = _create_periodic_events(1000, delta=delta, start=start_dt) + + aw_client.insert_events(bucket, events) + + # start kwarg isn't currently range-inclusive + recv_events = aw_client.get_events(bucket, limit=50, start=start_dt, end=end_dt) + + assert len(recv_events) == 25 + + # drop IDs + for e in recv_events: + e.id = None + print(recv_events) + src_events = sorted(events[:25], reverse=True, key=lambda e: e.timestamp) + + # The first event gets cut off + assert recv_events[0].timestamp == src_events[0].timestamp + assert recv_events[0].data == src_events[0].data + + # This fails due to microsecond precision issues + assert _round_td(recv_events[0].duration) == _round_td( + end_dt - recv_events[0].timestamp + ) + + # NOTE: Duration can differ by a few microseconds, so we round durations off to 10ms + recv_events = [_round_durations(e) for e in recv_events] + src_events = [_round_durations(e) for e in src_events] + + assert recv_events[1:25] == src_events[1:25] + + +def _round_td(td: timedelta) -> timedelta: + return timedelta(seconds=round(td.total_seconds(), 2)) + + +def _round_durations(e: Event): + e.duration = _round_td(e.duration) + return e + + +def test_store_many_events(aw_client, bucket): + events = _create_periodic_events( + 1000, start=datetime.now(tz=timezone.utc) - timedelta(days=50) + ) + + aw_client.insert_events(bucket, events) + recv_events = aw_client.get_events(bucket, limit=-1) + + assert len(events) == len(recv_events) + assert recv_events == sorted(events, reverse=True, key=lambda e: e.timestamp) + + +def test_midnight(aw_client, bucket): + start_dt = datetime.now() - timedelta(days=1) + midnight = start_dt.replace(hour=23, minute=50) + events = _create_periodic_events(100, start=midnight, delta=timedelta(minutes=1)) + + aw_client.insert_events(bucket, events) + recv_events = aw_client.get_events(bucket, limit=-1) + assert len(recv_events) == len(events) + + +def test_midnight_heartbeats(aw_client, bucket): + now = datetime.now(tz=timezone.utc) - timedelta(days=1) + # Zero out seconds/microseconds for deterministic midnight boundary. + # Without this, residual microseconds cause non-deterministic boundary + # matching when querying events around midnight (the endtime >= start_query + # condition can include/exclude boundary events depending on microseconds). + midnight = now.replace(hour=23, minute=50, second=0, microsecond=0) + events = _create_periodic_events(20, start=midnight, delta=timedelta(minutes=1)) + + label_ring = ["1", "1", "2", "3", "4"] + for i, e in enumerate(events): + e.data["label"] = label_ring[i % len(label_ring)] + aw_client.heartbeat(bucket, e, pulsetime=90) + + recv_events_merged = aw_client.get_events(bucket, limit=-1) + assert len(recv_events_merged) == 4 / 5 * len(events) + + # Query from midnight (00:00), which is exactly midnight + 10 minutes from + # the 23:50 start. Use a small offset to avoid boundary ambiguity where + # events ending exactly at midnight could be included by the >= condition. + query_start = midnight + timedelta(minutes=10, milliseconds=1) + recv_events_after_midnight = aw_client.get_events(bucket, start=query_start) + pprint(recv_events_after_midnight) + assert len(recv_events_after_midnight) == int(len(recv_events_merged) / 2) + + +def test_settings(aw_client): + aw_client.set_setting("test", "test") + aw_client.set_setting("list", json.dumps([1, 2, 3])) + aw_client.set_setting("dict", json.dumps({"a": 1, "b": 2})) + + # check set + assert aw_client.get_setting("test") == "test" + assert json.loads(aw_client.get_setting("list")) == [1, 2, 3] + assert json.loads(aw_client.get_setting("dict")) == {"a": 1, "b": 2} + + # check unset + assert aw_client.get_setting("test2") is None + + # check get all + settings = aw_client.get_setting() + assert settings["test"] == "test" + assert json.loads(settings["list"]) == [1, 2, 3] + assert json.loads(settings["dict"]) == {"a": 1, "b": 2} diff --git a/tests/test_extension_cors.py b/tests/test_extension_cors.py new file mode 100644 index 0000000..5964baf --- /dev/null +++ b/tests/test_extension_cors.py @@ -0,0 +1,171 @@ +"""Tests for moz-extension CORS endpoint scoping (extension_cors module).""" + +import pytest + +from aw_server.extension_cors import ( + _is_allowed, + _is_allowed_path, + _matches_configured_origin, +) +from aw_server.server import AWFlask + +_EXT_ORIGIN = "moz-extension://aabbccddeeff00112233445566778899" +_HOST = "127.0.0.1" + + +@pytest.fixture(scope="module") +def client(): + app = AWFlask(_HOST, testing=True) + return app.test_client() + + +# --------------------------------------------------------------------------- +# Unit tests for the path-matching helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method,path,expected", + [ + ("GET", "/api/0/info", True), + ("POST", "/api/0/buckets/aw-watcher-web-hostname", True), + ("POST", "/api/0/buckets/aw-watcher-web-hostname/heartbeat", True), + # Blocked paths + ("GET", "/api/0/export", False), + ("POST", "/api/0/export", False), + ("GET", "/api/0/buckets/", False), + ("GET", "/api/0/buckets/aw-watcher-web-hostname/events", False), + ("POST", "/api/0/query/", False), + ("GET", "/api/0/settings", False), + # Non-watcher bucket + ("POST", "/api/0/buckets/aw-watcher-window-hostname", False), + # Heartbeat on non-watcher bucket + ("POST", "/api/0/buckets/aw-watcher-afk-hostname/heartbeat", False), + # Percent-encoding should not bypass via raw path (segments are used) + ("GET", "/api/0/%65xport", False), # %65 = 'e', decodes to 'export' + ], +) +def test_is_allowed_unit(method, path, expected): + segments = [s for s in path.split("/") if s] + assert _is_allowed(method, segments) is expected + + +@pytest.mark.parametrize( + "path,expected", + [ + ("/api/0/info", True), + ("/api/0/buckets/aw-watcher-web-hostname", True), + ("/api/0/buckets/aw-watcher-web-hostname/heartbeat", True), + ("/api/0/export", False), + ("/api/0/buckets/", False), + ], +) +def test_is_allowed_path_unit(path, expected): + segments = [s for s in path.split("/") if s] + assert _is_allowed_path(segments) is expected + + +# --------------------------------------------------------------------------- +# Integration tests via Flask test client +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "method,path", + [ + ("GET", "/api/0/info"), + # Bucket creation: 400 without body is expected but NOT 403 + ("POST", "/api/0/buckets/aw-watcher-web-testhost"), + # Heartbeat: 404 (bucket doesn't exist) is expected but NOT 403 + ("POST", "/api/0/buckets/aw-watcher-web-testhost/heartbeat"), + ], +) +def test_extension_allowed(client, method, path): + """moz-extension origins are permitted at aw-watcher-web's endpoints.""" + headers = {"Origin": _EXT_ORIGIN} + r = client.open(path, method=method, headers=headers) + assert ( + r.status_code != 403 + ), f"{method} {path} should not be blocked; got {r.status_code}" + + +@pytest.mark.parametrize( + "method,path", + [ + ("GET", "/api/0/export"), + ("GET", "/api/0/buckets/"), + ("POST", "/api/0/import"), + ("POST", "/api/0/query/"), + ("GET", "/api/0/settings"), + # Events read from a watcher bucket + ("GET", "/api/0/buckets/aw-watcher-web-testhost/events"), + # Non-watcher bucket + ("POST", "/api/0/buckets/aw-watcher-window-testhost"), + ("POST", "/api/0/buckets/aw-watcher-afk-testhost/heartbeat"), + ], +) +def test_extension_blocked(client, method, path): + """moz-extension origins are blocked at endpoints beyond aw-watcher-web's needs.""" + headers = {"Origin": _EXT_ORIGIN} + r = client.open(path, method=method, headers=headers) + assert ( + r.status_code == 403 + ), f"{method} {path} should be blocked (403); got {r.status_code}" + + +@pytest.mark.parametrize( + "pattern,origin,expected", + [ + ("moz-extension://aabbcc", "moz-extension://aabbcc", True), + ("MOZ-EXTENSION://AABBCC", "moz-extension://aabbcc", True), + (r"moz-extension://.*", "moz-extension://aabbcc", True), + (r"moz-extension://[a-f0-9]+", "moz-extension://aabbcc", True), + (r"moz-extension://[0-9]+", "moz-extension://aabbcc", False), + ("moz-extension://other", "moz-extension://aabbcc", False), + ], +) +def test_matches_configured_origin(pattern, origin, expected): + assert _matches_configured_origin(origin, pattern) is expected + + +def test_regex_configured_extension_origin_bypasses_scope_guard(): + """Owner-configured regex origins retain unrestricted endpoint access.""" + app = AWFlask(_HOST, testing=False, cors_origins=[r"moz-extension://.*"]) + client = app.test_client() + + response = client.get("/api/0/export", headers={"Origin": _EXT_ORIGIN}) + + assert response.status_code != 403 + + +def test_non_extension_origin_passthrough(client): + """Non-moz-extension origins are not affected by the scope guard.""" + headers = {"Origin": "http://127.0.0.1:27180"} + r = client.get("/api/0/info", headers=headers) + assert r.status_code != 403 + + +def test_no_origin_passthrough(client): + """Requests without an Origin header (native watchers, curl) are not blocked.""" + r = client.get("/api/0/info") + assert r.status_code != 403 + + +def test_options_allowed_path(client): + """OPTIONS preflight on an allowed path is permitted.""" + headers = { + "Origin": _EXT_ORIGIN, + "Access-Control-Request-Method": "GET", + } + r = client.options("/api/0/info", headers=headers) + assert r.status_code != 403 + + +def test_options_blocked_path(client): + """OPTIONS preflight on a blocked path is rejected.""" + headers = { + "Origin": _EXT_ORIGIN, + "Access-Control-Request-Method": "GET", + } + r = client.options("/api/0/export", headers=headers) + assert r.status_code == 403 diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..2ef418d --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,94 @@ +"""Unit tests for profile resolution.""" + +import os + +import pytest + +from aw_server.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..3be4ade --- /dev/null +++ b/tests/test_profile_config.py @@ -0,0 +1,127 @@ +"""Port, settings, and CLI wiring for named profiles.""" + +import os +import sys + +import pytest + +from aw_server.config import config_section, default_port +from aw_server.main import parse_settings +from aw_server.profile import DEFAULT_PROFILE, TESTING_PROFILE, export_profile +from aw_server.settings import Settings + + +@pytest.fixture +def xdg_tmp(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) + return tmp_path + + +class TestConfigHelpers: + def test_sections_are_disjoint(self): + sections = {config_section(p) for p in ("default", "testing", "research")} + assert sections == {"server", "server-testing", "server-research"} + + def test_default_ports(self): + assert default_port(DEFAULT_PROFILE) == 5600 + assert default_port(TESTING_PROFILE) == 5666 + assert default_port("research") == 5600 + + +class TestSettingsFilename: + def test_testing_keeps_legacy_name(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "aw_server.settings.get_config_dir", lambda module: str(tmp_path) + ) + monkeypatch.delenv("AW_PROFILE", raising=False) + settings = Settings(True) + assert settings.config_file.name == "settings-testing.json" + + def test_default_unsuffixed(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "aw_server.settings.get_config_dir", lambda module: str(tmp_path) + ) + monkeypatch.delenv("AW_PROFILE", raising=False) + settings = Settings(False) + assert settings.config_file.name == "settings.json" + + def test_named_profile_suffix(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "aw_server.settings.get_config_dir", lambda module: str(tmp_path) + ) + monkeypatch.setenv("AW_PROFILE", "research") + settings = Settings(False) + assert settings.config_file.name == "settings-research.json" + + +class TestParseSettings: + def test_testing_flag_selects_testing_port(self, xdg_tmp, monkeypatch): + monkeypatch.setattr(sys, "argv", ["aw-server", "--testing"]) + settings, _storage = parse_settings() + assert settings.testing is True + assert settings.profile == TESTING_PROFILE + assert settings.port == 5666 + assert os.environ["AW_PROFILE"] == "testing" + + def test_profile_testing_is_alias_for_testing_flag(self, xdg_tmp, monkeypatch): + monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "testing"]) + settings, _storage = parse_settings() + assert settings.testing is True + assert settings.profile == TESTING_PROFILE + assert settings.port == 5666 + + def test_default_keeps_port_5600_and_unsets_env(self, xdg_tmp, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + monkeypatch.setattr(sys, "argv", ["aw-server"]) + settings, _storage = parse_settings() + assert settings.testing is False + assert settings.profile == DEFAULT_PROFILE + assert settings.port == 5600 + assert "AW_PROFILE" not in os.environ + + def test_named_profile_exports_env_and_falls_back_to_server_section( + self, xdg_tmp, monkeypatch + ): + monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "research"]) + settings, _storage = parse_settings() + assert settings.testing is False + assert settings.profile == "research" + assert settings.port == 5600 + assert os.environ["AW_PROFILE"] == "research" + + def test_cli_port_override(self, xdg_tmp, monkeypatch): + monkeypatch.setattr( + sys, "argv", ["aw-server", "--profile", "research", "--port", "5667"] + ) + settings, _storage = parse_settings() + assert settings.port == 5667 + assert settings.profile == "research" + + def test_conflicting_flags_are_a_usage_error(self, xdg_tmp, monkeypatch): + monkeypatch.setattr( + sys, "argv", ["aw-server", "--testing", "--profile", "research"] + ) + with pytest.raises(SystemExit): + parse_settings() + + def test_invalid_profile_is_a_usage_error(self, xdg_tmp, monkeypatch): + monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "Research"]) + with pytest.raises(SystemExit): + parse_settings() + + +def test_named_profile_config_is_isolated_from_default(xdg_tmp, monkeypatch): + """AW_PROFILE must be exported before load_config, else both profiles + share ~/.config/activitywatch/aw-server.""" + import aw_core.dirs as dirs + + export_profile("research") + research_dir = dirs.get_config_dir("aw-server") + export_profile(DEFAULT_PROFILE) + default_dir = dirs.get_config_dir("aw-server") + assert research_dir != default_dir + assert "activitywatch-research" in research_dir + assert "activitywatch-research" not in default_dir diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..3549f7e --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,184 @@ +import random +from datetime import datetime, timedelta + +import pytest + + +@pytest.fixture() +def bucket(flask_client): + "Context manager for creating and deleting a testing bucket" + try: + bucket_id = "test" + r = flask_client.post( + f"/api/0/buckets/{bucket_id}", + json={"client": "test", "type": "test", "hostname": "test"}, + ) + assert r.status_code == 200 + yield bucket_id + finally: + r = flask_client.delete(f"/api/0/buckets/{bucket_id}") + assert r.status_code == 200 + + +def test_info(flask_client): + r = flask_client.get("/api/0/info") + assert r.status_code == 200 + assert r.json["testing"] + assert r.json["profile"] == "testing" + + +def test_buckets(flask_client, bucket, benchmark): + @benchmark + def list_buckets(): + r = flask_client.get("/api/0/buckets/") + print(r.json) + assert r.status_code == 200 + assert len(r.json) == 1 + + +def test_heartbeats(flask_client, bucket, benchmark): + # FIXME: Currently tests using the memory storage method + # TODO: Test with a longer data section and see if there's a significant difference + # TODO: Test with a larger bucket and see if there's a significant difference + @benchmark + def heartbeat(): + now = datetime.now() + r = flask_client.post( + f"/api/0/buckets/{bucket}/heartbeat?pulsetime=1", + json={"timestamp": now, "duration": 0, "data": {"random": random.random()}}, + ) + assert r.status_code == 200 + + +def test_get_events(flask_client, bucket, benchmark): + n_events = 100 + start_time = datetime.now() - timedelta(days=100) + for i in range(n_events): + now = start_time + timedelta(hours=i) + r = flask_client.post( + f"/api/0/buckets/{bucket}/heartbeat?pulsetime=0", + json={"timestamp": now, "duration": 0, "data": {"random": random.random()}}, + ) + assert r.status_code == 200 + + @benchmark + def get_events(): + r = flask_client.get(f"/api/0/buckets/{bucket}/events") + assert r.status_code == 200 + assert r.json + assert len(r.json) == n_events + + r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=-1") + assert r.status_code == 200 + assert r.json + assert len(r.json) == n_events + + r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=10") + assert r.status_code == 200 + assert r.json + assert len(r.json) == 10 + + r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=100") + assert r.status_code == 200 + assert r.json + assert len(r.json) == n_events + + r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=1000") + assert r.status_code == 200 + assert r.json + assert len(r.json) == n_events + + +def test_insert_event_returns_list(flask_client, bucket): + """Test that POST /events returns a list of events with IDs (matching aw-server-rust).""" + now = datetime.now() + event_data = { + "timestamp": now.isoformat(), + "duration": 0, + "data": {"label": "test"}, + } + + # Single event as list + r = flask_client.post( + f"/api/0/buckets/{bucket}/events", + json=[event_data], + ) + assert r.status_code == 200 + assert isinstance(r.json, list), f"Expected list, got {type(r.json)}" + assert len(r.json) == 1 + assert r.json[0]["id"] is not None + assert r.json[0]["data"] == {"label": "test"} + + # Single event as dict (legacy format) + r = flask_client.post( + f"/api/0/buckets/{bucket}/events", + json=event_data, + ) + assert r.status_code == 200 + assert isinstance(r.json, list), f"Expected list, got {type(r.json)}" + assert len(r.json) == 1 + assert r.json[0]["id"] is not None + + +def test_insert_events_returns_list(flask_client, bucket): + """Test that POST /events with multiple events returns a list.""" + now = datetime.now() + events_data = [ + { + "timestamp": (now - timedelta(hours=i)).isoformat(), + "duration": 0, + "data": {"label": f"test-{i}"}, + } + for i in range(3) + ] + + r = flask_client.post( + f"/api/0/buckets/{bucket}/events", + json=events_data, + ) + assert r.status_code == 200 + assert isinstance(r.json, list), f"Expected list, got {type(r.json)}" + assert len(r.json) == 0 + + +# TODO: Add benchmark for basic AFK-filtering query + + +def test_query_invalid_timeperiod(flask_client): + """Malformed timeperiods must yield 400 (client error), not 500. + + Regression test: a non-ISO8601 timeperiod previously raised an uncaught + iso8601.ParseError, surfacing as an Internal Server Error. + """ + r = flask_client.post( + "/api/0/query/", + json={"query": ["RETURN = 1;"], "timeperiods": ["not-a-valid-period"]}, + ) + assert r.status_code == 400 + assert "not-a-valid-period" in r.json["message"] + + +def test_query_timeperiod_missing_slash(flask_client): + """A timeperiod without a start/end slash separator must yield 400, not 500. + + Regression test: a single ISO8601 datetime (no slash) previously raised an + uncaught IndexError when indexing the split result. + """ + r = flask_client.post( + "/api/0/query/", + json={"query": ["RETURN = 1;"], "timeperiods": ["2024-01-01T00:00:00+00:00"]}, + ) + assert r.status_code == 400 + + +def test_query_valid_timeperiod(flask_client): + """A well-formed query with a valid timeperiod still succeeds.""" + r = flask_client.post( + "/api/0/query/", + json={ + "query": ["RETURN = 1;"], + "timeperiods": ["2024-01-01T00:00:00+00:00/2024-01-02T00:00:00+00:00"], + }, + ) + assert r.status_code == 200 + assert r.json == [1]