snapshot of https://github.com/ActivityWatch/aw-client.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 2m4s
Examples / Test examples (ubuntu-latest, 3.10) (push) Successful in 2m16s
Lint / lint (push) Successful in 1m25s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 2m4s
Examples / Test examples (ubuntu-latest, 3.10) (push) Successful in 2m16s
Lint / lint (push) Successful in 1m25s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
This commit is contained in:
48
.github/workflows/build.yml
vendored
Normal file
48
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Test on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
python_version: [3.9]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python_version }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pip install poetry==1.3.2
|
||||
python -m venv venv
|
||||
source venv/bin/activate || source venv/Scripts/activate
|
||||
make build
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: |
|
||||
source venv/bin/activate || source venv/Scripts/activate
|
||||
make test
|
||||
make typecheck
|
||||
|
||||
- name: Report coverage
|
||||
shell: bash
|
||||
run: |
|
||||
# Allow to fail, since sometimes codecov servers are grumpy
|
||||
bash <(curl -s https://codecov.io/bash) || true
|
||||
41
.github/workflows/codeql.yml
vendored
Normal file
41
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
schedule:
|
||||
- cron: "52 6 * * 1"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ python ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
56
.github/workflows/examples.yml
vendored
Normal file
56
.github/workflows/examples.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Examples
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Test examples
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
RELEASE: false
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python_version: ["3.10"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python_version }}
|
||||
|
||||
- name: Create virtualenv
|
||||
shell: bash
|
||||
run: |
|
||||
pip install virtualenv
|
||||
python -m virtualenv venv
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pip install poetry
|
||||
source venv/bin/activate || source venv/Scripts/activate
|
||||
make build
|
||||
|
||||
- name: Set up ActivityWatch
|
||||
uses: ActivityWatch/setup-action@master
|
||||
with:
|
||||
aw-server: "aw-server"
|
||||
aw-version: "v0.12.0b2"
|
||||
aw-testing: false
|
||||
fakedata: true
|
||||
|
||||
- name: Run examples
|
||||
shell: bash
|
||||
run: |
|
||||
source venv/bin/activate || source venv/Scripts/activate
|
||||
pip install pandas # needed for one of the examples
|
||||
make test-examples
|
||||
15
.github/workflows/lint.yml
vendored
Normal file
15
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: psf/black@stable
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
build
|
||||
dist
|
||||
secret.env
|
||||
|
||||
# Cache files
|
||||
*.pyc
|
||||
__pycache__
|
||||
.*cache
|
||||
*.egg-info
|
||||
*.swp
|
||||
|
||||
# Output from example scripts
|
||||
*.json
|
||||
*.csv
|
||||
373
LICENSE.txt
Normal file
373
LICENSE.txt
Normal file
@@ -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.
|
||||
32
Makefile
Normal file
32
Makefile
Normal file
@@ -0,0 +1,32 @@
|
||||
.PHONY: build test typecheck clean examples
|
||||
|
||||
build:
|
||||
poetry install
|
||||
|
||||
test:
|
||||
python -c "import aw_client"
|
||||
pytest -s -vv tests/test_requestqueue.py tests/test_profile.py tests/test_profile_config.py
|
||||
|
||||
test-integration:
|
||||
pytest -v tests/test_client.py
|
||||
|
||||
test-examples:
|
||||
cd examples; pytest -v *.py
|
||||
cd examples; yes | python3 load_dataframe.py
|
||||
cd examples; python3 working_hours.py 'activitywatch|aw-|github.com' fakedata
|
||||
|
||||
lint:
|
||||
ruff check .
|
||||
|
||||
lint-fix:
|
||||
ruff check --fix .
|
||||
|
||||
typecheck:
|
||||
poetry run mypy
|
||||
|
||||
format:
|
||||
ruff format .
|
||||
|
||||
clean:
|
||||
rm -rf build dist
|
||||
rm -rf aw_client/__pycache__
|
||||
74
README.md
Normal file
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
aw-client
|
||||
=========
|
||||
|
||||
[](https://github.com/ActivityWatch/aw-client/actions)
|
||||
[](https://pypi.org/project/aw-client/)
|
||||
[](https://github.com/psf/black)
|
||||
[](http://mypy-lang.org/)
|
||||
|
||||
Client library for ActivityWatch in Python.
|
||||
|
||||
Please see [the documentation][docs] for usage, and take a look at `examples/`.
|
||||
|
||||
- [Documentation][docs]
|
||||
- [API Reference][apiref]
|
||||
|
||||
[docs]: https://docs.activitywatch.net/en/latest/
|
||||
[apiref]: https://docs.activitywatch.net/en/latest/api/python.html#aw-client
|
||||
|
||||
## How to install
|
||||
|
||||
Install from pip: `pip install aw-client`
|
||||
|
||||
Install the latest version directly from github without cloning: `pip install git+https://github.com/ActivityWatch/aw-client.git`
|
||||
|
||||
To install from a cloned version:
|
||||
|
||||
- clone repo: `git clone https://github.com/ActivityWatch/aw-client.git`
|
||||
- cd into the directory: `cd aw-client`
|
||||
- run `poetry install` (will create a virtualenv, if none activated)
|
||||
- If you don't want to use poetry you can also use `pip install .`, but that might not get the exact version of the dependencies (due to not reading the `poetry.lock` file).
|
||||
|
||||
## Usage
|
||||
|
||||
For the CLI:
|
||||
|
||||
```
|
||||
$ aw-client --help
|
||||
Usage: aw-client [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
CLI utility for aw-client to aid in interacting with the ActivityWatch
|
||||
server
|
||||
|
||||
Options:
|
||||
--host TEXT Address of host
|
||||
--port INTEGER Port to use
|
||||
-v, --verbose Verbosity
|
||||
--testing Set to use testing ports by default
|
||||
--help Show this message and exit.
|
||||
|
||||
Commands:
|
||||
buckets List all buckets
|
||||
canonical Query 'canonical events' for a single host (filtered,...
|
||||
events Query events from bucket with ID `bucket_id`
|
||||
heartbeat Send a heartbeat to bucket with ID `bucket_id` with JSON `data`
|
||||
query Run a query in file at `path` on the server
|
||||
report Generate an activity report
|
||||
```
|
||||
|
||||
|
||||
## Debugging
|
||||
|
||||
* Run python with `LOG_LEVEL=debug` to get additional debugging output
|
||||
* If invalid events have been queued for submission, you may need to delete the file-based queues generated by this library
|
||||
* To use the development version of this library use `aw-client = {path = "../aw-client" }` in `pyproject.toml`
|
||||
|
||||
## Examples
|
||||
|
||||
The [`examples/`](examples/) directory contains a couple of example scripts, including:
|
||||
|
||||
- [`time_spent_today.py`](examples/time_spent_today.py) - fetches all non-afk events and sums their duration to get the total active time for the day.
|
||||
- [`working_hours.py`](https://github.com/ActivityWatch/aw-client/blob/master/examples/working_hours.py) - computes hours worked per day (matching a "work" category rule), and exports matching events to a JSON file (for auditing).
|
||||
- [`load_dataframe.py`](https://github.com/ActivityWatch/aw-client/blob/master/examples/load_dataframe.py) - loads events from a host using a categorizing & AFK-filtering query, put result in a pandas dataframe, and export as CSV.
|
||||
- [`merge_buckets.py`](examples/merge_buckets.py) - merges two buckets with non-intersecting events by moving all events from one into the other.
|
||||
- [`redact_sensitive.py`](examples/redact_sensitive.py) - redact sensitive events.
|
||||
3
aw_client/__init__.py
Normal file
3
aw_client/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .client import ActivityWatchClient
|
||||
|
||||
__all__ = ["ActivityWatchClient"]
|
||||
3
aw_client/__main__.py
Normal file
3
aw_client/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from aw_client import cli
|
||||
|
||||
cli.main()
|
||||
89
aw_client/classes.py
Normal file
89
aw_client/classes.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Default classes
|
||||
|
||||
Taken from default classes in aw-webui
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import aw_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CategoryId = List[str]
|
||||
CategorySpec = Dict[str, Any]
|
||||
|
||||
default_classes: List[Tuple[CategoryId, CategorySpec]] = [
|
||||
(["Work"], {"type": "regex", "regex": "Google Docs|libreoffice|ReText"}),
|
||||
(
|
||||
["Work", "Programming"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": "GitHub|Stack Overflow|BitBucket|Gitlab|vim|Spyder|kate|Ghidra|Scite",
|
||||
},
|
||||
),
|
||||
(
|
||||
["Work", "Programming", "ActivityWatch"],
|
||||
{"type": "regex", "regex": "ActivityWatch|aw-", "ignore_case": True},
|
||||
),
|
||||
(["Work", "Image"], {"type": "regex", "regex": "Gimp|Inkscape"}),
|
||||
(["Work", "Video"], {"type": "regex", "regex": "Kdenlive"}),
|
||||
(["Work", "Audio"], {"type": "regex", "regex": "Audacity"}),
|
||||
(["Work", "3D"], {"type": "regex", "regex": "Blender"}),
|
||||
(["Media", "Games"], {"type": "regex", "regex": "Minecraft|RimWorld"}),
|
||||
(["Media", "Video"], {"type": "regex", "regex": "YouTube|Plex|VLC"}),
|
||||
(
|
||||
["Media", "Social Media"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": "reddit|Facebook|Twitter|Instagram|devRant",
|
||||
"ignore_case": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
["Media", "Music"],
|
||||
{"type": "regex", "regex": "Spotify|Deezer", "ignore_case": True},
|
||||
),
|
||||
(
|
||||
["Comms", "IM"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": "Messenger|Telegram|Signal|WhatsApp|Rambox|Slack|Riot|Discord|Nheko",
|
||||
},
|
||||
),
|
||||
(
|
||||
["Comms", "Email"],
|
||||
{"type": "regex", "regex": "Gmail|Thunderbird|mutt|alpine"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_classes() -> List[Tuple[List[str], dict]]:
|
||||
"""
|
||||
Get classes from server-side settings.
|
||||
Might throw a 404 if not set yet, in which case we use the default classes as a fallback.
|
||||
"""
|
||||
# NOTE: Always tries to fetch from prod server,
|
||||
# which is potentially wrong if testing server is being used.
|
||||
awc = aw_client.ActivityWatchClient(f"get-setting-{random.randint(0, 10000)}")
|
||||
try:
|
||||
classes = awc.get_setting("classes")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to get classes from server, using default classes as fallback"
|
||||
)
|
||||
return default_classes
|
||||
if not classes:
|
||||
logger.warning(
|
||||
"Classes setting is empty/unset, using default classes as fallback"
|
||||
)
|
||||
return default_classes
|
||||
# map into list of tuples
|
||||
return [(v["name"], v["rule"]) for v in classes]
|
||||
332
aw_client/cli.py
Executable file
332
aw_client/cli.py
Executable file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import logging
|
||||
import textwrap
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import click
|
||||
from aw_core import Event
|
||||
from tabulate import tabulate
|
||||
|
||||
import aw_client
|
||||
|
||||
from . import queries
|
||||
from .classes import default_classes, get_classes
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
td1day = timedelta(days=1)
|
||||
td1yr = timedelta(days=365)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Context:
|
||||
client: aw_client.ActivityWatchClient
|
||||
|
||||
|
||||
@click.group(
|
||||
help="CLI utility for aw-client to aid in interacting with the ActivityWatch server"
|
||||
)
|
||||
@click.option(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="Address of host",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
default=None,
|
||||
type=int,
|
||||
help="Port to use (default: profile config, 5600 / 5666)",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--verbose",
|
||||
is_flag=True,
|
||||
help="Verbosity",
|
||||
)
|
||||
@click.option("--testing", is_flag=True, help="Set to use testing ports by default")
|
||||
@click.option(
|
||||
"--profile",
|
||||
default=None,
|
||||
help="Named instance profile. --testing is an alias for --profile testing.",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(
|
||||
ctx, testing: bool, verbose: bool, host: str, port: int, profile: Optional[str]
|
||||
):
|
||||
ctx.obj = _Context()
|
||||
# default=None so `--port 5600` is a real override, not discarded as
|
||||
# "the Click default". None lets ActivityWatchClient read the profile
|
||||
# config (5600 / 5666 / baked research port).
|
||||
ctx.obj.client = aw_client.ActivityWatchClient(
|
||||
host=host,
|
||||
port=port,
|
||||
testing=testing,
|
||||
profile=profile,
|
||||
)
|
||||
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)
|
||||
|
||||
|
||||
@main.command(help="Send a heartbeat to bucket with ID `bucket_id` with JSON `data`")
|
||||
@click.argument("bucket_id")
|
||||
@click.argument("data")
|
||||
@click.option("--pulsetime", default=60, help="pulsetime to use for merging heartbeats")
|
||||
@click.pass_obj
|
||||
def heartbeat(obj: _Context, bucket_id: str, data: str, pulsetime: int):
|
||||
now = datetime.now(timezone.utc)
|
||||
e = Event(duration=0, data=json.loads(data), timestamp=now)
|
||||
print(e)
|
||||
obj.client.heartbeat(bucket_id, e, pulsetime)
|
||||
|
||||
|
||||
@main.command(help="List all buckets")
|
||||
@click.pass_obj
|
||||
def buckets(obj: _Context):
|
||||
buckets = obj.client.get_buckets()
|
||||
print("Buckets:")
|
||||
for bucket in buckets:
|
||||
print(f" - {bucket}")
|
||||
|
||||
|
||||
@main.command(help="Query events from bucket with ID `bucket_id`")
|
||||
@click.argument("bucket_id")
|
||||
@click.pass_obj
|
||||
def events(obj: _Context, bucket_id: str):
|
||||
events = obj.client.get_events(bucket_id)
|
||||
print("events:")
|
||||
for e in events:
|
||||
print(
|
||||
" - {} ({}) {}".format(
|
||||
e.timestamp.replace(tzinfo=None, microsecond=0),
|
||||
str(e.duration).split(".")[0],
|
||||
e.data,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@main.command(help="Run a query in file at `path` on the server")
|
||||
@click.argument("path")
|
||||
@click.option("--name")
|
||||
@click.option("--cache", is_flag=True)
|
||||
@click.option("--json", "_json", is_flag=True)
|
||||
@click.option("--start", default=now - td1day, type=click.DateTime())
|
||||
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
||||
@click.option(
|
||||
"--timezone",
|
||||
help="Time zone for start and stop options."
|
||||
" Must be a valid IANA identifier like e.g. 'Europe/Warsaw'.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def query(
|
||||
obj: _Context,
|
||||
path: str,
|
||||
cache: bool,
|
||||
_json: bool,
|
||||
start: datetime,
|
||||
stop: datetime,
|
||||
timezone: str,
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
with open(path) as f:
|
||||
query = f.read()
|
||||
|
||||
if timezone:
|
||||
zone_info = ZoneInfo(timezone)
|
||||
start = start.replace(tzinfo=zone_info)
|
||||
stop = stop.replace(tzinfo=zone_info)
|
||||
|
||||
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
||||
if _json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
for period in result:
|
||||
print(f"Showing 10 out of {len(period)} events:")
|
||||
for event in period[:10]:
|
||||
event.pop("id")
|
||||
event.pop("timestamp")
|
||||
print(
|
||||
" - Duration: {} \tData: {}".format(
|
||||
str(timedelta(seconds=event["duration"])).split(".")[0],
|
||||
event["data"],
|
||||
)
|
||||
)
|
||||
print(
|
||||
"Total duration:\t",
|
||||
timedelta(seconds=sum(e["duration"] for e in period)),
|
||||
)
|
||||
|
||||
|
||||
@main.command(help="Generate an activity report")
|
||||
@click.argument("hostname")
|
||||
@click.option("--cache", is_flag=True)
|
||||
@click.option("--start", default=now - td1day, type=click.DateTime())
|
||||
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
||||
@click.option("--limit", default=10)
|
||||
@click.pass_obj
|
||||
def report(
|
||||
obj: _Context,
|
||||
hostname: str,
|
||||
cache: bool,
|
||||
start: datetime,
|
||||
stop: datetime,
|
||||
name: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
):
|
||||
logger.info(f"Querying between {start} and {stop}")
|
||||
bid_window = f"aw-watcher-window_{hostname}"
|
||||
bid_afk = f"aw-watcher-afk_{hostname}"
|
||||
|
||||
if not start.tzinfo:
|
||||
start = start.astimezone()
|
||||
if not stop.tzinfo:
|
||||
stop = stop.astimezone()
|
||||
|
||||
bid_browsers: List[str] = []
|
||||
|
||||
classes = get_classes()
|
||||
params = queries.DesktopQueryParams(
|
||||
bid_browsers=bid_browsers,
|
||||
classes=classes,
|
||||
filter_classes=[],
|
||||
filter_afk=True,
|
||||
include_audible=True,
|
||||
bid_window=bid_window,
|
||||
bid_afk=bid_afk,
|
||||
)
|
||||
query = queries.fullDesktopQuery(params)
|
||||
logger.debug("Query: \n" + queries.pretty_query(query))
|
||||
|
||||
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
||||
|
||||
# TODO: Print titles, apps, categories, with most time
|
||||
for period in result:
|
||||
print()
|
||||
# print(period["window"]["cat_events"])
|
||||
|
||||
cat_events = _parse_events(period["window"]["cat_events"])
|
||||
print_top(
|
||||
cat_events,
|
||||
lambda e: " > ".join(e.data["$category"]),
|
||||
title="Categories",
|
||||
n=limit,
|
||||
)
|
||||
|
||||
title_events = _parse_events(period["window"]["title_events"])
|
||||
print_top(title_events, lambda e: e.data["title"], title="Titles", n=limit)
|
||||
|
||||
active_events = _parse_events(period["window"]["title_events"])
|
||||
print(
|
||||
"Total duration:\t",
|
||||
sum((e.duration for e in active_events), timedelta()),
|
||||
)
|
||||
|
||||
|
||||
def _parse_events(events: List[dict]) -> List[Event]:
|
||||
return [Event(**event) for event in events]
|
||||
|
||||
|
||||
def print_top(events: List[Event], key=lambda e: e.data, title="Events", n=10):
|
||||
print(f"Top {n} {title}" + (f" (out of {len(events)})" if len(events) > 10 else ""))
|
||||
print(
|
||||
tabulate(
|
||||
[
|
||||
(event.duration, key(event))
|
||||
for event in sorted(events, key=lambda e: e.duration, reverse=True)[:10]
|
||||
],
|
||||
headers=["Duration", "Key"],
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
@main.command(help="Query 'canonical events' for a single host (filtered, classified)")
|
||||
@click.argument("hostname")
|
||||
@click.option("--cache", is_flag=True)
|
||||
@click.option("--start", default=now - td1day, type=click.DateTime())
|
||||
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
||||
@click.option(
|
||||
"--format",
|
||||
type=click.Choice(["table", "json"]),
|
||||
default="table",
|
||||
help="Output format (table or json)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def canonical(
|
||||
obj: _Context,
|
||||
hostname: str,
|
||||
cache: bool,
|
||||
start: datetime,
|
||||
stop: datetime,
|
||||
format: str,
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
logger.info(f"Querying between {start} and {stop}")
|
||||
bid_window = f"aw-watcher-window_{hostname}"
|
||||
bid_afk = f"aw-watcher-afk_{hostname}"
|
||||
|
||||
if not start.tzinfo:
|
||||
start = start.astimezone()
|
||||
if not stop.tzinfo:
|
||||
stop = stop.astimezone()
|
||||
|
||||
classes = default_classes
|
||||
|
||||
query = queries.canonicalEvents(
|
||||
queries.DesktopQueryParams(
|
||||
bid_window=bid_window,
|
||||
bid_afk=bid_afk,
|
||||
classes=classes,
|
||||
)
|
||||
)
|
||||
query = f"""{query}\n RETURN = events;"""
|
||||
logger.debug("Query: \n" + queries.pretty_query(query))
|
||||
|
||||
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
||||
|
||||
# TODO: Print titles, apps, categories, with most time
|
||||
for period in result:
|
||||
events = _parse_events(period)
|
||||
|
||||
if format == "json":
|
||||
# Output as JSON array with ISO-8601 timestamps
|
||||
json_events = [
|
||||
{
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
"duration": e.duration.total_seconds(),
|
||||
"data": e.data,
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
print(json.dumps(json_events))
|
||||
else:
|
||||
# Table format (original behavior)
|
||||
print()
|
||||
print(f"Showing last 10 out of {len(events)} events:")
|
||||
|
||||
print(
|
||||
tabulate(
|
||||
[
|
||||
(
|
||||
str(e.timestamp).split(".")[0],
|
||||
str(e.duration).split(".")[0],
|
||||
f'[{e.data["app"]}] {textwrap.shorten(e.data["title"], 60, placeholder="...")}',
|
||||
)
|
||||
for e in events[-10:]
|
||||
],
|
||||
headers=["Timestamp", "Duration", "Data"],
|
||||
)
|
||||
)
|
||||
|
||||
print()
|
||||
print(
|
||||
"Total duration:\t",
|
||||
timedelta(seconds=sum(e["duration"] for e in period)),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
636
aw_client/client.py
Normal file
636
aw_client/client.py
Normal file
@@ -0,0 +1,636 @@
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from time import sleep
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import persistqueue
|
||||
import requests as req
|
||||
from aw_core.dirs import get_data_dir
|
||||
from aw_core.models import Event
|
||||
from aw_transform.heartbeats import heartbeat_merge
|
||||
|
||||
from .config import load_config, load_local_server_api_key
|
||||
from .profile import (
|
||||
DEFAULT_PROFILE,
|
||||
export_profile,
|
||||
is_testing,
|
||||
profile_from_env,
|
||||
profile_suffix,
|
||||
resolve_profile,
|
||||
)
|
||||
from .singleinstance import SingleInstance
|
||||
|
||||
# FIXME: This line is probably badly placed
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_request_exception(e: req.RequestException):
|
||||
logger.warning(str(e))
|
||||
try:
|
||||
d = e.response.json() if e.response else None
|
||||
logger.warning(f"Error message received: {d}")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
def _dt_is_tzaware(dt: datetime) -> bool:
|
||||
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
||||
|
||||
|
||||
def always_raise_for_request_errors(f: Callable[..., req.Response]):
|
||||
@functools.wraps(f)
|
||||
def g(*args, **kwargs):
|
||||
r = f(*args, **kwargs)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except req.RequestException as e:
|
||||
_log_request_exception(e)
|
||||
raise e
|
||||
return r
|
||||
|
||||
return g
|
||||
|
||||
|
||||
class ActivityWatchClient:
|
||||
def __init__(
|
||||
self,
|
||||
client_name: str = "unknown",
|
||||
testing=False,
|
||||
host=None,
|
||||
port=None,
|
||||
protocol="http",
|
||||
profile: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
A handy wrapper around the aw-server REST API. The recommended way of interacting with the server.
|
||||
|
||||
Can be used with a `with`-statement as an alternative to manually calling connect and disconnect in a try-finally clause.
|
||||
|
||||
:Example:
|
||||
|
||||
.. literalinclude:: examples/client.py
|
||||
:lines: 7-
|
||||
|
||||
``profile`` selects an isolated instance (config, port, queue file).
|
||||
``testing=True`` is the compat shim for ``profile="testing"``. If
|
||||
neither is set, ``AW_PROFILE`` from the launcher is used.
|
||||
"""
|
||||
if profile is not None or testing:
|
||||
resolved = resolve_profile(profile, testing)
|
||||
else:
|
||||
resolved = profile_from_env(False)
|
||||
export_profile(resolved)
|
||||
self.profile = resolved
|
||||
self.testing = is_testing(resolved)
|
||||
|
||||
self.client_name = client_name
|
||||
self.client_hostname = socket.gethostname()
|
||||
|
||||
_config = load_config()
|
||||
server_key = "server" if resolved == DEFAULT_PROFILE else f"server-{resolved}"
|
||||
client_key = "client" if resolved == DEFAULT_PROFILE else f"client-{resolved}"
|
||||
if server_key not in _config:
|
||||
if resolved != DEFAULT_PROFILE:
|
||||
logger.warning(
|
||||
"Profile %s has no [%s] section, falling back to [server] "
|
||||
"(port %s may collide with the default instance)",
|
||||
resolved,
|
||||
server_key,
|
||||
5666 if is_testing(resolved) else 5600,
|
||||
)
|
||||
server_key = "server"
|
||||
if client_key not in _config:
|
||||
client_key = "client"
|
||||
server_config = _config[server_key]
|
||||
client_config = _config[client_key]
|
||||
|
||||
server_host = host or server_config["hostname"]
|
||||
server_port = port or server_config["port"]
|
||||
self.server_api_key = load_local_server_api_key(
|
||||
str(server_host), server_port, profile=resolved
|
||||
)
|
||||
self.server_address = f"{protocol}://{server_host}:{server_port}"
|
||||
|
||||
self.instance = SingleInstance(
|
||||
f"{self.client_name}-at-{server_host}-on-{server_port}"
|
||||
)
|
||||
|
||||
self.commit_interval = client_config["commit_interval"]
|
||||
|
||||
self.request_queue = RequestQueue(self)
|
||||
# Dict of each last heartbeat in each bucket
|
||||
self.last_heartbeat = {} # type: Dict[str, Event]
|
||||
self._warned_queue_before_connect = False
|
||||
|
||||
#
|
||||
# Get/Post base requests
|
||||
#
|
||||
|
||||
def _url(self, endpoint: str):
|
||||
return f"{self.server_address}/api/0/{endpoint}"
|
||||
|
||||
def _headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
request_headers = dict(headers or {})
|
||||
if self.server_api_key:
|
||||
request_headers.setdefault("Authorization", f"Bearer {self.server_api_key}")
|
||||
return request_headers
|
||||
|
||||
@always_raise_for_request_errors
|
||||
def _get(self, endpoint: str, params: Optional[dict] = None) -> req.Response:
|
||||
return req.get(self._url(endpoint), params=params, headers=self._headers())
|
||||
|
||||
@always_raise_for_request_errors
|
||||
def _post(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: Union[List[Any], Dict[str, Any]],
|
||||
params: Optional[dict] = None,
|
||||
) -> req.Response:
|
||||
headers = self._headers(
|
||||
{"Content-type": "application/json", "charset": "utf-8"}
|
||||
)
|
||||
return req.post(
|
||||
self._url(endpoint),
|
||||
data=bytes(json.dumps(data), "utf8"),
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@always_raise_for_request_errors
|
||||
def _delete(self, endpoint: str, data: Any = None) -> req.Response:
|
||||
if data is None:
|
||||
data = {}
|
||||
headers = self._headers({"Content-type": "application/json"})
|
||||
return req.delete(self._url(endpoint), data=json.dumps(data), headers=headers)
|
||||
|
||||
def get_info(self):
|
||||
"""Returns a dict currently containing the keys 'hostname' and 'testing'."""
|
||||
endpoint = "info"
|
||||
return self._get(endpoint).json()
|
||||
|
||||
#
|
||||
# Event get/post requests
|
||||
#
|
||||
|
||||
def get_event(
|
||||
self,
|
||||
bucket_id: str,
|
||||
event_id: int,
|
||||
) -> Optional[Event]:
|
||||
endpoint = f"buckets/{bucket_id}/events/{event_id}"
|
||||
try:
|
||||
event = self._get(endpoint).json()
|
||||
return Event(**event)
|
||||
except req.exceptions.HTTPError as e:
|
||||
if e.response and e.response.status_code == 404:
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
def get_events(
|
||||
self,
|
||||
bucket_id: str,
|
||||
limit: int = -1,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> List[Event]:
|
||||
endpoint = f"buckets/{bucket_id}/events"
|
||||
|
||||
params = dict() # type: Dict[str, str]
|
||||
if limit is not None:
|
||||
params["limit"] = str(limit)
|
||||
if start is not None:
|
||||
params["start"] = start.isoformat()
|
||||
if end is not None:
|
||||
params["end"] = end.isoformat()
|
||||
|
||||
events = self._get(endpoint, params=params).json()
|
||||
return [Event(**event) for event in events]
|
||||
|
||||
def insert_event(self, bucket_id: str, event: Event) -> Optional[Event]:
|
||||
endpoint = f"buckets/{bucket_id}/events"
|
||||
data = [event.to_json_dict()]
|
||||
response = self._post(endpoint, data)
|
||||
if response.json():
|
||||
return Event(**response.json()[0])
|
||||
return None
|
||||
|
||||
def insert_events(
|
||||
self, bucket_id: str, events: List[Event]
|
||||
) -> Optional[List[Event]]:
|
||||
endpoint = f"buckets/{bucket_id}/events"
|
||||
data = [event.to_json_dict() for event in events]
|
||||
response = self._post(endpoint, data)
|
||||
if response.json():
|
||||
return [Event(**e) for e in response.json()]
|
||||
return None
|
||||
|
||||
def delete_event(self, bucket_id: str, event_id: int) -> None:
|
||||
endpoint = f"buckets/{bucket_id}/events/{event_id}"
|
||||
self._delete(endpoint)
|
||||
|
||||
def get_eventcount(
|
||||
self,
|
||||
bucket_id: str,
|
||||
limit: int = -1,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
endpoint = f"buckets/{bucket_id}/events/count"
|
||||
|
||||
params = dict() # type: Dict[str, str]
|
||||
if start is not None:
|
||||
params["start"] = start.isoformat()
|
||||
if end is not None:
|
||||
params["end"] = end.isoformat()
|
||||
|
||||
response = self._get(endpoint, params=params)
|
||||
return int(response.text)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
bucket_id: str,
|
||||
event: Event,
|
||||
pulsetime: float,
|
||||
queued: bool = False,
|
||||
commit_interval: Optional[float] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
bucket_id: The bucket_id of the bucket to send the heartbeat to
|
||||
event: The actual heartbeat event
|
||||
pulsetime: The maximum amount of time in seconds since the last heartbeat to be merged with the previous heartbeat in aw-server
|
||||
queued: Use the aw-client queue feature to queue events if client loses connection with the server
|
||||
commit_interval: Override default pre-merge commit interval
|
||||
|
||||
NOTE: This endpoint can use the failed requests retry queue.
|
||||
This makes the request itself non-blocking and therefore
|
||||
the function will in that case always returns None.
|
||||
"""
|
||||
|
||||
endpoint = f"buckets/{bucket_id}/heartbeat?pulsetime={pulsetime}"
|
||||
_commit_interval = commit_interval or self.commit_interval
|
||||
|
||||
if queued:
|
||||
self._warn_queue_before_connect()
|
||||
# Pre-merge heartbeats
|
||||
if bucket_id not in self.last_heartbeat:
|
||||
self.last_heartbeat[bucket_id] = event
|
||||
return None
|
||||
|
||||
last_heartbeat = self.last_heartbeat[bucket_id]
|
||||
|
||||
merge = heartbeat_merge(last_heartbeat, event, pulsetime)
|
||||
|
||||
if merge:
|
||||
# If last_heartbeat becomes longer than commit_interval
|
||||
# then commit, else cache merged.
|
||||
diff = (last_heartbeat.duration).total_seconds()
|
||||
if diff >= _commit_interval:
|
||||
data = merge.to_json_dict()
|
||||
self.request_queue.add_request(endpoint, data)
|
||||
self.last_heartbeat[bucket_id] = event
|
||||
else:
|
||||
self.last_heartbeat[bucket_id] = merge
|
||||
else:
|
||||
data = last_heartbeat.to_json_dict()
|
||||
self.request_queue.add_request(endpoint, data)
|
||||
self.last_heartbeat[bucket_id] = event
|
||||
else:
|
||||
self._post(endpoint, event.to_json_dict())
|
||||
|
||||
#
|
||||
# Bucket get/post requests
|
||||
#
|
||||
|
||||
def get_buckets(self) -> dict:
|
||||
return self._get("buckets/").json()
|
||||
|
||||
def create_bucket(self, bucket_id: str, event_type: str, queued=False):
|
||||
if queued:
|
||||
self._warn_queue_before_connect()
|
||||
self.request_queue.register_bucket(bucket_id, event_type)
|
||||
else:
|
||||
endpoint = f"buckets/{bucket_id}"
|
||||
data = {
|
||||
"client": self.client_name,
|
||||
"hostname": self.client_hostname,
|
||||
"type": event_type,
|
||||
}
|
||||
self._post(endpoint, data)
|
||||
|
||||
def delete_bucket(self, bucket_id: str, force: bool = False):
|
||||
self._delete(f"buckets/{bucket_id}" + ("?force=1" if force else ""))
|
||||
|
||||
# @deprecated
|
||||
def setup_bucket(self, bucket_id: str, event_type: str):
|
||||
self.create_bucket(bucket_id, event_type, queued=True)
|
||||
|
||||
# Import & export
|
||||
|
||||
def export_all(self) -> dict:
|
||||
return self._get("export").json()
|
||||
|
||||
def export_bucket(self, bucket_id) -> dict:
|
||||
return self._get(f"buckets/{bucket_id}/export").json()
|
||||
|
||||
def import_bucket(self, bucket: dict) -> None:
|
||||
endpoint = "import"
|
||||
self._post(endpoint, {"buckets": {bucket["id"]: bucket}})
|
||||
|
||||
#
|
||||
# Query (server-side transformation)
|
||||
#
|
||||
|
||||
def query(
|
||||
self,
|
||||
query: str,
|
||||
timeperiods: List[Tuple[datetime, datetime]],
|
||||
name: Optional[str] = None,
|
||||
cache: bool = False,
|
||||
) -> List[Any]:
|
||||
endpoint = "query/"
|
||||
params = {} # type: Dict[str, Any]
|
||||
if cache:
|
||||
if not name:
|
||||
raise Exception(
|
||||
"You are not allowed to do caching without a query name"
|
||||
)
|
||||
params["name"] = name
|
||||
params["cache"] = int(cache)
|
||||
|
||||
# Check that datetimes have timezone information
|
||||
for start, stop in timeperiods:
|
||||
try:
|
||||
assert _dt_is_tzaware(start)
|
||||
assert _dt_is_tzaware(stop)
|
||||
except AssertionError:
|
||||
raise ValueError("start/stop needs to have a timezone set") from None
|
||||
|
||||
data = {
|
||||
"timeperiods": [
|
||||
"/".join([start.isoformat(), end.isoformat()])
|
||||
for start, end in timeperiods
|
||||
],
|
||||
"query": query.split("\n"),
|
||||
}
|
||||
response = self._post(endpoint, data, params=params)
|
||||
return response.json()
|
||||
|
||||
#
|
||||
# Settings
|
||||
#
|
||||
|
||||
def get_setting(self, key: Optional[str] = None) -> dict:
|
||||
if key:
|
||||
return self._get(f"settings/{key}").json()
|
||||
else:
|
||||
return self._get("settings").json()
|
||||
|
||||
def set_setting(self, key: str, value: str) -> None:
|
||||
self._post(f"settings/{key}", value)
|
||||
|
||||
#
|
||||
# Connect and disconnect
|
||||
#
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.disconnect()
|
||||
|
||||
def connect(self):
|
||||
if not self.request_queue.is_alive():
|
||||
self.request_queue.start()
|
||||
|
||||
def disconnect(self):
|
||||
self.request_queue.stop()
|
||||
self.request_queue.join()
|
||||
|
||||
# Throw away old thread object, create new one since same thread cannot be started twice
|
||||
self.request_queue = RequestQueue(
|
||||
self, persistqueue_path=self.request_queue.persistqueue_path
|
||||
)
|
||||
# Reset so warn-before-connect fires again if user calls queued ops before reconnecting
|
||||
self._warned_queue_before_connect = False
|
||||
|
||||
def wait_for_start(self, timeout: int = 10) -> None:
|
||||
"""Wait for the server to start by trying to get the server info."""
|
||||
start_time = datetime.now()
|
||||
sleep_time = 0.1
|
||||
while (datetime.now() - start_time).seconds < timeout:
|
||||
try:
|
||||
self.get_info()
|
||||
break
|
||||
except req.exceptions.ConnectionError:
|
||||
sleep(sleep_time)
|
||||
sleep_time *= 2
|
||||
else:
|
||||
raise Exception(f"Server at {self.server_address} did not start in time")
|
||||
|
||||
def _warn_queue_before_connect(self) -> None:
|
||||
if self._warned_queue_before_connect or self.request_queue.is_alive():
|
||||
return
|
||||
|
||||
warnings.warn(
|
||||
"Queued requests require calling connect() or using `with client:` "
|
||||
"before buckets can be created and queued events can flush.",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
self._warned_queue_before_connect = True
|
||||
|
||||
|
||||
QueuedRequest = namedtuple("QueuedRequest", ["endpoint", "data"])
|
||||
Bucket = namedtuple("Bucket", ["id", "type"])
|
||||
|
||||
|
||||
class RequestQueue(threading.Thread):
|
||||
"""Used to asynchronously send heartbeats.
|
||||
|
||||
Handles:
|
||||
- Cases where the server is temporarily unavailable
|
||||
- Saves all queued requests to file in case of a server crash
|
||||
"""
|
||||
|
||||
VERSION = 1 # update this whenever the queue-file format changes
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: ActivityWatchClient,
|
||||
persistqueue_path: Optional[str] = None,
|
||||
) -> None:
|
||||
threading.Thread.__init__(self, daemon=True)
|
||||
|
||||
self.client = client
|
||||
|
||||
self.connected = False
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Buckets that will have events queued to them, will be created if they don't exist
|
||||
self._registered_buckets = [] # type: List[Bucket]
|
||||
|
||||
self._attempt_reconnect_interval = 10
|
||||
|
||||
if persistqueue_path is None:
|
||||
data_dir = get_data_dir("aw-client")
|
||||
queued_dir = os.path.join(data_dir, "queued")
|
||||
if not os.path.exists(queued_dir):
|
||||
os.makedirs(queued_dir)
|
||||
|
||||
profile = getattr(client, "profile", None)
|
||||
suffix = (
|
||||
profile_suffix(profile)
|
||||
if profile is not None
|
||||
else ("-testing" if client.testing else "")
|
||||
)
|
||||
persistqueue_path = os.path.join(
|
||||
queued_dir,
|
||||
f"{self.client.client_name}{suffix}.v{self.VERSION}.persistqueue",
|
||||
)
|
||||
|
||||
logger.debug(f"queue path '{persistqueue_path}'")
|
||||
self.persistqueue_path = persistqueue_path
|
||||
|
||||
self._persistqueue = persistqueue.FIFOSQLiteQueue(
|
||||
persistqueue_path, multithreading=True, auto_commit=False
|
||||
)
|
||||
self._current = None # type: Optional[QueuedRequest]
|
||||
|
||||
def _get_next(self) -> Optional[QueuedRequest]:
|
||||
# self._current will always hold the next not-yet-sent event,
|
||||
# until self._task_done() is called.
|
||||
if not self._current:
|
||||
try:
|
||||
self._current = self._persistqueue.get(block=False)
|
||||
except persistqueue.exceptions.Empty:
|
||||
return None
|
||||
return self._current
|
||||
|
||||
def _task_done(self) -> None:
|
||||
self._current = None
|
||||
self._persistqueue.task_done()
|
||||
|
||||
def _create_buckets(self) -> None:
|
||||
for bucket in self._registered_buckets:
|
||||
self.client.create_bucket(bucket.id, bucket.type)
|
||||
|
||||
def _try_connect(self) -> bool:
|
||||
try: # Try to connect
|
||||
self._create_buckets()
|
||||
self.connected = True
|
||||
logger.info(
|
||||
f"Connection to aw-server established by {self.client.client_name}"
|
||||
)
|
||||
except req.RequestException:
|
||||
self.connected = False
|
||||
|
||||
return self.connected
|
||||
|
||||
def wait(self, seconds) -> bool:
|
||||
return self._stop_event.wait(seconds)
|
||||
|
||||
def should_stop(self) -> bool:
|
||||
return self._stop_event.is_set()
|
||||
|
||||
def _dispatch_request(self) -> None:
|
||||
request = self._get_next()
|
||||
if not request:
|
||||
self.wait(0.2) # seconds to wait before re-polling the empty queue
|
||||
return
|
||||
|
||||
try:
|
||||
self.client._post(request.endpoint, request.data)
|
||||
except req.exceptions.ConnectTimeout:
|
||||
# Triggered by:
|
||||
# - server not running (connection refused)
|
||||
# - server not responding (timeout)
|
||||
# Safe to retry according to requests docs:
|
||||
# https://requests.readthedocs.io/en/latest/api/#requests.ConnectTimeout
|
||||
|
||||
self.connected = False
|
||||
logger.warning(
|
||||
"Connection refused or timeout, will queue requests until connection is available."
|
||||
)
|
||||
# wait a bit before retrying, so we don't spam the server (or logs), see:
|
||||
# - https://github.com/ActivityWatch/activitywatch/issues/815
|
||||
# - https://github.com/ActivityWatch/activitywatch/issues/756#issuecomment-1266662861
|
||||
sleep(0.5)
|
||||
return
|
||||
except req.RequestException as e:
|
||||
if e.response and e.response.status_code == 400:
|
||||
# HTTP 400 - Bad request
|
||||
# Example case: https://github.com/ActivityWatch/activitywatch/issues/815
|
||||
# We don't want to retry, because a bad payload is likely to fail forever.
|
||||
logger.error(f"Bad request, not retrying: {request.data}")
|
||||
elif e.response and e.response.status_code == 500:
|
||||
# HTTP 500 - Internal server error
|
||||
# It is possible that the server is in a bad state (and will recover on restart),
|
||||
# in which case we want to retry. I hope this can never caused by a bad payload.
|
||||
logger.error(f"Internal server error, retrying: {request.data}")
|
||||
sleep(0.5)
|
||||
return
|
||||
else:
|
||||
logger.exception(f"Unknown error, not retrying: {request.data}")
|
||||
except Exception:
|
||||
logger.exception(f"Unknown error, not retrying: {request.data}")
|
||||
|
||||
# Mark the request as done
|
||||
self._task_done()
|
||||
|
||||
def run(self) -> None:
|
||||
self._stop_event.clear()
|
||||
while not self.should_stop():
|
||||
# Connect
|
||||
while not self._try_connect():
|
||||
logger.warning(
|
||||
f"Not connected to server, {self._persistqueue.qsize()} requests in queue"
|
||||
)
|
||||
if self.wait(self._attempt_reconnect_interval):
|
||||
break
|
||||
|
||||
# Dispatch requests until connection is lost or thread should stop
|
||||
while self.connected and not self.should_stop():
|
||||
self._dispatch_request()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def add_request(self, endpoint: str, data: dict) -> None:
|
||||
"""
|
||||
Add a request to the queue.
|
||||
NOTE: Only supports heartbeats
|
||||
"""
|
||||
assert "/heartbeat" in endpoint
|
||||
assert isinstance(data, dict)
|
||||
self._persistqueue.put(QueuedRequest(endpoint, data))
|
||||
|
||||
def register_bucket(self, bucket_id: str, event_type: str) -> None:
|
||||
bucket = Bucket(bucket_id, event_type)
|
||||
self._registered_buckets.append(bucket)
|
||||
|
||||
if not self.connected:
|
||||
return
|
||||
|
||||
try:
|
||||
self.client.create_bucket(bucket_id, event_type)
|
||||
except req.RequestException:
|
||||
self.connected = False
|
||||
206
aw_client/config.py
Normal file
206
aw_client/config.py
Normal file
@@ -0,0 +1,206 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import platformdirs
|
||||
import tomlkit
|
||||
from aw_core.config import load_config_toml
|
||||
|
||||
from .profile import DEFAULT_PROFILE, TESTING_PROFILE, profile_from_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_APPNAME = "activitywatch"
|
||||
_TESTING_APPNAME = "activitywatch-testing"
|
||||
|
||||
# Identical to aw-core#152 / aw-server-rust#652 so python and rust agree on
|
||||
# the same on-disk state (ActivityWatch/activitywatch#1399). Keep this list
|
||||
# specific: a false positive would pin a fresh install to legacy forever.
|
||||
_LEGACY_TESTING_FILENAME_MARKERS = (
|
||||
"peewee-sqlite-testing",
|
||||
"sqlite-testing",
|
||||
"settings-testing",
|
||||
"config-testing",
|
||||
"-testing.db",
|
||||
"-testing.toml",
|
||||
"-testing.json",
|
||||
"_testing_",
|
||||
)
|
||||
|
||||
default_config = """
|
||||
[server]
|
||||
hostname = "127.0.0.1"
|
||||
port = "5600"
|
||||
|
||||
[client]
|
||||
commit_interval = 10
|
||||
|
||||
[server-testing]
|
||||
hostname = "127.0.0.1"
|
||||
port = "5666"
|
||||
|
||||
[client-testing]
|
||||
commit_interval = 5
|
||||
""".strip()
|
||||
|
||||
|
||||
def load_config():
|
||||
return load_config_toml("aw-client", default_config)
|
||||
|
||||
|
||||
def _user_data_dir(appname: str) -> str:
|
||||
return platformdirs.user_data_dir(appname)
|
||||
|
||||
|
||||
def _user_config_dir(appname: str) -> str:
|
||||
return platformdirs.user_config_dir(appname)
|
||||
|
||||
|
||||
def _user_cache_dir(appname: str) -> str:
|
||||
return platformdirs.user_cache_dir(appname)
|
||||
|
||||
|
||||
def _is_legacy_testing_filename(name: str) -> bool:
|
||||
lower = name.lower()
|
||||
return any(marker in lower for marker in _LEGACY_TESTING_FILENAME_MARKERS)
|
||||
|
||||
|
||||
def _new_testing_root_exists() -> bool:
|
||||
"""True if any platform dir for ``activitywatch-testing`` already exists.
|
||||
|
||||
Must not create directories: existence is the signal that a previous run
|
||||
already adopted the isolated testing root.
|
||||
"""
|
||||
for getter in (_user_data_dir, _user_config_dir, _user_cache_dir):
|
||||
if os.path.isdir(getter(_TESTING_APPNAME)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _legacy_testing_artifacts_exist() -> bool:
|
||||
"""True if testing data still lives under the shared ``activitywatch`` root."""
|
||||
roots = (
|
||||
_user_data_dir(_DEFAULT_APPNAME),
|
||||
_user_config_dir(_DEFAULT_APPNAME),
|
||||
_user_cache_dir(_DEFAULT_APPNAME),
|
||||
)
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
for filename in filenames:
|
||||
if _is_legacy_testing_filename(filename):
|
||||
return True
|
||||
# Descend one level (activitywatch/aw-server-rust/...) not further.
|
||||
if os.path.relpath(dirpath, root) != ".":
|
||||
dirnames.clear()
|
||||
return False
|
||||
|
||||
|
||||
def using_legacy_testing_root() -> bool:
|
||||
"""Whether ``profile=testing`` should stay on the shared ``activitywatch`` root.
|
||||
|
||||
Resolution rule (ActivityWatch/activitywatch#1399), identical on python
|
||||
and rust:
|
||||
|
||||
1. If ``activitywatch-testing/`` already exists: use it (new layout).
|
||||
2. Else if legacy testing artifacts exist in the bare ``activitywatch/``
|
||||
root: stay in legacy mode (old paths, old filenames).
|
||||
3. Else (fresh setup): create and use ``activitywatch-testing/``.
|
||||
"""
|
||||
if _new_testing_root_exists():
|
||||
return False
|
||||
return _legacy_testing_artifacts_exist()
|
||||
|
||||
|
||||
def rust_server_config_candidates(profile: str) -> List[Tuple[str, int]]:
|
||||
"""Ordered rust config files to read for this profile.
|
||||
|
||||
Isolated profile roots (including new-style ``activitywatch-testing/``)
|
||||
use bare ``config.toml`` — the directory already isolates. Suffixed
|
||||
``config-testing.toml`` remains only in the legacy shared-root layout.
|
||||
|
||||
Lookup does **not** create directories (``get_config_dir`` would, and
|
||||
creating ``activitywatch-testing/`` would flip the fallback). Testing
|
||||
tries the other layout second so a python client that already created
|
||||
the new root still finds a rust server that wrote the key on the
|
||||
shared root — the regression Erik named on #1399.
|
||||
"""
|
||||
testing_new = (
|
||||
os.path.join(
|
||||
_user_config_dir(_TESTING_APPNAME), "aw-server-rust", "config.toml"
|
||||
),
|
||||
5666,
|
||||
)
|
||||
testing_legacy = (
|
||||
os.path.join(
|
||||
_user_config_dir(_DEFAULT_APPNAME),
|
||||
"aw-server-rust",
|
||||
"config-testing.toml",
|
||||
),
|
||||
5666,
|
||||
)
|
||||
if profile == TESTING_PROFILE:
|
||||
if using_legacy_testing_root():
|
||||
return [testing_legacy, testing_new]
|
||||
return [testing_new, testing_legacy]
|
||||
if profile == DEFAULT_PROFILE or not profile:
|
||||
return [
|
||||
(
|
||||
os.path.join(
|
||||
_user_config_dir(_DEFAULT_APPNAME),
|
||||
"aw-server-rust",
|
||||
"config.toml",
|
||||
),
|
||||
5600,
|
||||
)
|
||||
]
|
||||
return [
|
||||
(
|
||||
os.path.join(
|
||||
_user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),
|
||||
"aw-server-rust",
|
||||
"config.toml",
|
||||
),
|
||||
5600,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def load_local_server_api_key(
|
||||
host: str,
|
||||
port: Union[int, str],
|
||||
profile: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if host not in {"127.0.0.1", "localhost", "::1"}:
|
||||
return None
|
||||
|
||||
try:
|
||||
requested_port = int(str(port))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if profile is None:
|
||||
profile = profile_from_env(False)
|
||||
|
||||
for config_path, default_port in rust_server_config_candidates(profile):
|
||||
if not os.path.isfile(config_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = tomlkit.parse(f.read())
|
||||
configured_port = int(str(config.get("port", default_port)))
|
||||
if configured_port != requested_port:
|
||||
continue
|
||||
|
||||
auth_config = config.get("auth", {})
|
||||
api_key = auth_config.get("api_key")
|
||||
if api_key:
|
||||
return str(api_key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to read aw-server-rust config %s: %s", config_path, e
|
||||
)
|
||||
|
||||
return None
|
||||
111
aw_client/profile.py
Normal file
111
aw_client/profile.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Profile resolution for aw-client.
|
||||
|
||||
A *profile* names an isolated ActivityWatch instance (data, config, port,
|
||||
queue file). `default` is the ordinary install, `testing` is what
|
||||
`--testing` has always meant, and any other name (for example `research`)
|
||||
is a sibling instance that can run at the same time as the others.
|
||||
|
||||
The carrier is the ``AW_PROFILE`` environment variable. aw-core's
|
||||
``_get_appname()`` suffixes the platformdirs root when it is set to a
|
||||
non-empty value, so exporting the profile here isolates dirs for this
|
||||
process — without threading a flag through the request queue or REST
|
||||
client internals.
|
||||
|
||||
Kept in sync with aw-qt's ``aw_qt/profile.py`` and aw-server's
|
||||
``aw_server/profile.py`` (same validation rule as aw-server-rust). One
|
||||
intentional difference: the default profile *unsets* ``AW_PROFILE``
|
||||
instead of setting it to ``"default"``. aw-core treats any non-empty
|
||||
value as a suffix, so ``AW_PROFILE=default`` would resolve to
|
||||
``activitywatch-default`` and orphan an existing install.
|
||||
|
||||
Testing-root note (ActivityWatch/activitywatch#1399): python and rust
|
||||
resolve ``testing`` identically — new-root-plus-legacy-fallback:
|
||||
|
||||
1. If ``activitywatch-testing/`` exists: use it.
|
||||
2. Else if legacy testing artifacts exist in the bare ``activitywatch/``
|
||||
root: stay in legacy mode.
|
||||
3. Else (fresh setup): create and use ``activitywatch-testing/``.
|
||||
|
||||
Isolated roots use bare filenames (``config.toml``). Suffixed names
|
||||
(``config-testing.toml``) stay legacy-only. The rust API-key lookup in
|
||||
``config.py`` follows this rule so a ``--testing`` client finds the key
|
||||
where aw-server-rust actually wrote it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_PROFILE = "default"
|
||||
TESTING_PROFILE = "testing"
|
||||
|
||||
#: Same rule as aw-server-rust's `validate_profile`: lowercase alphanumeric
|
||||
#: plus `-`/`_`, at most 32 chars, so a profile is always a safe path segment.
|
||||
PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||
|
||||
ENV_VAR = "AW_PROFILE"
|
||||
|
||||
|
||||
def validate_profile(profile: str) -> str:
|
||||
"""Return the profile unchanged, or raise ValueError if it is not usable."""
|
||||
if not PROFILE_RE.match(profile):
|
||||
raise ValueError(
|
||||
f"Invalid profile name {profile!r}: expected lowercase alphanumeric "
|
||||
"with '-' or '_', at most 32 characters"
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def resolve_profile(profile: Optional[str], testing: bool) -> str:
|
||||
"""Resolve the effective profile from the CLI flags.
|
||||
|
||||
``--testing`` is an alias for ``--profile testing``; passing both is only
|
||||
an error if they disagree.
|
||||
"""
|
||||
if profile is None:
|
||||
return TESTING_PROFILE if testing else DEFAULT_PROFILE
|
||||
|
||||
profile = validate_profile(profile)
|
||||
if testing and profile != TESTING_PROFILE:
|
||||
raise ValueError(
|
||||
f"--testing conflicts with --profile {profile}: --testing is an "
|
||||
f"alias for --profile {TESTING_PROFILE}"
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def is_testing(profile: str) -> bool:
|
||||
return profile == TESTING_PROFILE
|
||||
|
||||
|
||||
def profile_suffix(profile: str) -> str:
|
||||
"""Filename suffix for a profile (``""``, ``"-testing"``, ``"-research"``)."""
|
||||
return "" if profile == DEFAULT_PROFILE else f"-{profile}"
|
||||
|
||||
|
||||
def profile_from_env(testing: bool = False) -> str:
|
||||
"""Read the profile the process was started with.
|
||||
|
||||
Falls back to the `--testing` bool for callers that only track that, so
|
||||
behaviour is unchanged when no profile was set.
|
||||
"""
|
||||
profile = os.environ.get(ENV_VAR)
|
||||
if not profile:
|
||||
return TESTING_PROFILE if testing else DEFAULT_PROFILE
|
||||
try:
|
||||
return validate_profile(profile)
|
||||
except ValueError:
|
||||
return TESTING_PROFILE if testing else DEFAULT_PROFILE
|
||||
|
||||
|
||||
def export_profile(profile: str) -> None:
|
||||
"""Publish the profile to this process and its children.
|
||||
|
||||
The default profile leaves ``AW_PROFILE`` unset so aw-core keeps the
|
||||
bare ``activitywatch`` root. Named profiles (including ``testing``)
|
||||
set the env var; children inherit it without a CLI flag.
|
||||
"""
|
||||
if profile == DEFAULT_PROFILE:
|
||||
os.environ.pop(ENV_VAR, None)
|
||||
else:
|
||||
os.environ[ENV_VAR] = profile
|
||||
1
aw_client/py.typed
Normal file
1
aw_client/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
334
aw_client/queries.py
Normal file
334
aw_client/queries.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Common queries.
|
||||
|
||||
Most of these are from: https://github.com/ActivityWatch/aw-webui/blob/master/src/queries.ts
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
import aw_client
|
||||
|
||||
from .classes import get_classes
|
||||
|
||||
|
||||
class EnhancedJSONEncoder(json.JSONEncoder):
|
||||
"""For encoding dataclasses into JSON"""
|
||||
|
||||
def default(self, o):
|
||||
if dataclasses.is_dataclass(o):
|
||||
return dataclasses.asdict(o) # type: ignore
|
||||
return super().default(o)
|
||||
|
||||
|
||||
"""
|
||||
Do these dataclasses look confusing?
|
||||
Read up on dataclass inheritance: https://stackoverflow.com/a/53085935/965332
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _QueryParamsDefaultsBase:
|
||||
bid_browsers: List[str] = field(default_factory=list)
|
||||
classes: List[Tuple[List[str], dict]] = field(default_factory=list)
|
||||
filter_classes: List[List[str]] = field(default_factory=list)
|
||||
filter_afk: bool = True
|
||||
include_audible: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryParams(_QueryParamsDefaultsBase):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DesktopQueryParamsBase:
|
||||
bid_window: str
|
||||
bid_afk: str
|
||||
always_active_pattern: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DesktopQueryParams(QueryParams, _DesktopQueryParamsBase):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AndroidQueryParamsBase:
|
||||
bid_android: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AndroidQueryParams(QueryParams, _AndroidQueryParamsBase):
|
||||
pass
|
||||
|
||||
|
||||
def isDesktopParams(params: QueryParams) -> TypeGuard[DesktopQueryParams]:
|
||||
return isinstance(params, DesktopQueryParams)
|
||||
|
||||
|
||||
def isAndroidParams(params: QueryParams) -> TypeGuard[AndroidQueryParams]:
|
||||
return isinstance(params, AndroidQueryParams)
|
||||
|
||||
|
||||
def canonicalEvents(params: Union[DesktopQueryParams, AndroidQueryParams]) -> str:
|
||||
if not params.classes:
|
||||
# if categories not explicitly set,
|
||||
# get categories from server settings
|
||||
params.classes = get_classes()
|
||||
|
||||
# Needs escaping for regex patterns like '\w' to work (JSON.stringify adds extra unnecessary escaping)
|
||||
classes_str = json.dumps(params.classes, cls=EnhancedJSONEncoder)
|
||||
classes_str = re.sub(r"\\\\", r"\\", classes_str)
|
||||
|
||||
cat_filter_str = json.dumps(params.filter_classes)
|
||||
|
||||
# For simplicity, we assume that bid_window and bid_android are exchangeable (note however it needs special treatment)
|
||||
bid_window = (
|
||||
params.bid_window
|
||||
if isinstance(params, DesktopQueryParams)
|
||||
else params.bid_android
|
||||
)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
# Fetch window/app events
|
||||
f'events = flood(query_bucket(find_bucket("{bid_window}")));',
|
||||
# On Android, merge events to avoid overload of events
|
||||
(
|
||||
'events = merge_events_by_keys(events, ["app"]);'
|
||||
if isAndroidParams(params)
|
||||
else ""
|
||||
),
|
||||
# Fetch not-afk events
|
||||
(
|
||||
f"""
|
||||
not_afk = flood(query_bucket(find_bucket("{params.bid_afk}")));
|
||||
not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);"""
|
||||
+ (
|
||||
"""
|
||||
not_treat_as_afk = filter_keyvals_regex(events, "app", "%s");
|
||||
not_afk = period_union(not_afk, not_treat_as_afk);
|
||||
not_treat_as_afk = filter_keyvals_regex(events, "title", "%s");
|
||||
not_afk = period_union(not_afk, not_treat_as_afk);"""
|
||||
% (
|
||||
params.always_active_pattern.replace('"', '\\"'),
|
||||
params.always_active_pattern.replace('"', '\\"'),
|
||||
)
|
||||
if params.always_active_pattern
|
||||
else ""
|
||||
)
|
||||
if isDesktopParams(params)
|
||||
else ""
|
||||
),
|
||||
# Fetch browser events
|
||||
(
|
||||
(browserEvents(params) if isDesktopParams(params) else "")
|
||||
+ ( # Include focused and audible browser events as indications of not-afk
|
||||
"""
|
||||
audible_events = filter_keyvals(browser_events, "audible", [true]);
|
||||
not_afk = period_union(not_afk, audible_events);
|
||||
"""
|
||||
if params.include_audible
|
||||
else ""
|
||||
)
|
||||
if params.bid_browsers
|
||||
else ""
|
||||
),
|
||||
# Filter out window events when the user was afk
|
||||
(
|
||||
"events = filter_period_intersect(events, not_afk);"
|
||||
if isDesktopParams(params) and params.filter_afk
|
||||
else ""
|
||||
),
|
||||
# Categorize
|
||||
f"events = categorize(events, {classes_str});" if params.classes else "",
|
||||
# Filter out selected categories
|
||||
(
|
||||
f'events = filter_keyvals(events, "$category", {cat_filter_str});'
|
||||
if params.filter_classes
|
||||
else ""
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def pretty_query(query: str) -> str:
|
||||
return "\n".join([line.strip() for line in query.split("\n") if line.strip()])
|
||||
|
||||
|
||||
def _browser_in_buckets(browser: str, browserbuckets: List[str]) -> Optional[str]:
|
||||
for bucket in browserbuckets:
|
||||
if browser in bucket:
|
||||
return bucket
|
||||
return None
|
||||
|
||||
|
||||
def browsersWithBuckets(browserbuckets: List[str]) -> List[Tuple[str, str]]:
|
||||
"""Returns a list of (browserName, bucketId) pairs for found browser buckets"""
|
||||
browsername_to_bucketid: List[Tuple[str, Optional[str]]] = [
|
||||
(browserName, _browser_in_buckets(browserName, browserbuckets))
|
||||
for browserName in browser_appnames
|
||||
]
|
||||
|
||||
# Only return browsers for which a bucket could be found
|
||||
return [t for t in browsername_to_bucketid if t[1]] # type: ignore
|
||||
|
||||
|
||||
def browserEvents(params: DesktopQueryParams) -> str:
|
||||
"""Returns a list of active browser events (where the browser was the active window) from all browser buckets"""
|
||||
code = "browser_events = [];"
|
||||
|
||||
for browserName, bucketId in browsersWithBuckets(params.bid_browsers):
|
||||
browser_appnames_str = json.dumps(browser_appnames[browserName])
|
||||
code += f"""
|
||||
events_{browserName} = flood(query_bucket("{bucketId}"));
|
||||
window_{browserName} = filter_keyvals(events, "app", {browser_appnames_str});
|
||||
events_{browserName} = filter_period_intersect(events_{browserName}, window_{browserName});
|
||||
events_{browserName} = split_url_events(events_{browserName});
|
||||
browser_events = concat(browser_events, events_{browserName});
|
||||
browser_events = sort_by_timestamp(browser_events);
|
||||
"""
|
||||
return code
|
||||
|
||||
|
||||
browser_appnames = {
|
||||
"chrome": [
|
||||
# Chrome
|
||||
"Google Chrome",
|
||||
"Google-chrome",
|
||||
"chrome.exe",
|
||||
"google-chrome-stable",
|
||||
# Chromium
|
||||
"Chromium",
|
||||
"Chromium-browser",
|
||||
"Chromium-browser-chromium",
|
||||
"chromium.exe",
|
||||
# Pre-releases
|
||||
"Google-chrome-beta",
|
||||
"Google-chrome-unstable",
|
||||
# Brave (should this be merged with the brave entry?)
|
||||
"Brave-browser",
|
||||
],
|
||||
"firefox": [
|
||||
"Firefox",
|
||||
"Firefox.exe",
|
||||
"firefox",
|
||||
"firefox.exe",
|
||||
"Firefox Developer Edition",
|
||||
"firefoxdeveloperedition",
|
||||
"Firefox-esr",
|
||||
"Firefox Beta",
|
||||
"Nightly",
|
||||
"org.mozilla.firefox",
|
||||
],
|
||||
"opera": ["opera.exe", "Opera"],
|
||||
"brave": ["brave.exe"],
|
||||
"edge": [
|
||||
"msedge.exe", # Windows
|
||||
"Microsoft Edge", # macOS
|
||||
],
|
||||
"vivaldi": ["Vivaldi-stable", "Vivaldi-snapshot", "vivaldi.exe"],
|
||||
}
|
||||
|
||||
default_limit = 100
|
||||
|
||||
|
||||
def querystr_to_array(querystr: str) -> List[str]:
|
||||
return [line + ";" for line in querystr.split(";") if line]
|
||||
|
||||
|
||||
def escape_doublequote(s: str) -> str:
|
||||
return s.replace('"', '\\"')
|
||||
|
||||
|
||||
def fullDesktopQuery(
|
||||
params: DesktopQueryParams,
|
||||
) -> str:
|
||||
# Escape `"`
|
||||
params.bid_window = escape_doublequote(params.bid_window)
|
||||
params.bid_afk = escape_doublequote(params.bid_afk)
|
||||
params.bid_browsers = [escape_doublequote(bucket) for bucket in params.bid_browsers]
|
||||
|
||||
# Build the base query
|
||||
query = f"""
|
||||
{canonicalEvents(params)}
|
||||
title_events = sort_by_duration(merge_events_by_keys(events, ["app", "title"]));
|
||||
app_events = sort_by_duration(merge_events_by_keys(title_events, ["app"]));
|
||||
cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"]));
|
||||
app_events = limit_events(app_events, {default_limit});
|
||||
title_events = limit_events(title_events, {default_limit});
|
||||
duration = sum_durations(events);
|
||||
"""
|
||||
|
||||
# Add browser-related query parts if browser buckets exist
|
||||
if params.bid_browsers:
|
||||
query += f"""
|
||||
browser_events = split_url_events(browser_events);
|
||||
browser_urls = merge_events_by_keys(browser_events, ["url"]);
|
||||
browser_urls = sort_by_duration(browser_urls);
|
||||
browser_urls = limit_events(browser_urls, {default_limit});
|
||||
browser_domains = merge_events_by_keys(browser_events, ["$domain"]);
|
||||
browser_domains = sort_by_duration(browser_domains);
|
||||
browser_domains = limit_events(browser_domains, {default_limit});
|
||||
browser_duration = sum_durations(browser_events);
|
||||
"""
|
||||
else:
|
||||
query += """
|
||||
browser_events = [];
|
||||
browser_urls = [];
|
||||
browser_domains = [];
|
||||
browser_duration = 0;
|
||||
"""
|
||||
|
||||
# Add the return statement
|
||||
query += """
|
||||
RETURN = {
|
||||
"events": events,
|
||||
"window": {
|
||||
"app_events": app_events,
|
||||
"title_events": title_events,
|
||||
"cat_events": cat_events,
|
||||
"active_events": not_afk,
|
||||
"duration": duration
|
||||
},
|
||||
"browser": {
|
||||
"domains": browser_domains,
|
||||
"urls": browser_urls,
|
||||
"duration": browser_duration
|
||||
}
|
||||
};
|
||||
"""
|
||||
return query
|
||||
|
||||
|
||||
def test_fullDesktopQuery():
|
||||
params = DesktopQueryParams(
|
||||
bid_window="aw-watcher-window_",
|
||||
bid_afk="aw-watcher-afk_",
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
start = now - timedelta(days=7)
|
||||
end = now
|
||||
timeperiods = [(start, end)]
|
||||
query = fullDesktopQuery(params)
|
||||
|
||||
awc = aw_client.ActivityWatchClient("test")
|
||||
res = awc.query(query, timeperiods)[0]
|
||||
events = res["events"]
|
||||
print(len(events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_fullDesktopQuery()
|
||||
47
aw_client/singleinstance.py
Normal file
47
aw_client/singleinstance.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
if sys.platform != "win32":
|
||||
import fcntl
|
||||
|
||||
from aw_core.dirs import get_cache_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SingleInstance:
|
||||
"""
|
||||
This code is taken and modified form the tendo python package
|
||||
(Python Software Foundation License Version 2)
|
||||
http://pythonhosted.org/tendo/_modules/tendo/singleton.html
|
||||
"""
|
||||
|
||||
def __init__(self, client_name):
|
||||
self.lockfile = os.path.join(get_cache_dir("client_locks"), client_name)
|
||||
logger.debug("SingleInstance lockfile: " + self.lockfile)
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
# file already exists, we try to remove (in case previous execution was interrupted)
|
||||
if os.path.exists(self.lockfile):
|
||||
os.unlink(self.lockfile)
|
||||
self.fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
except OSError as e:
|
||||
if e.errno == 13:
|
||||
logger.error("Another instance is already running, quitting.")
|
||||
sys.exit(-1)
|
||||
else:
|
||||
raise e
|
||||
else: # non Windows
|
||||
self.fp = open(self.lockfile, "w")
|
||||
try:
|
||||
fcntl.lockf(self.fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
logger.error("Another instance is already running, quitting.")
|
||||
sys.exit(-1)
|
||||
|
||||
def __del__(self):
|
||||
if sys.platform == "win32":
|
||||
if hasattr(self, "fd"):
|
||||
os.close(self.fd)
|
||||
os.unlink(self.lockfile)
|
||||
97
examples/email_report.py
Normal file
97
examples/email_report.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Sends an email with a summary report (WIP)
|
||||
|
||||
For now, it just sends stdin.
|
||||
In the future, it will generate a pretty email and send that.
|
||||
|
||||
Requires that an SMTP server is configured through environment variables.
|
||||
|
||||
Example usage:
|
||||
|
||||
$ env OUTPUT_HTML=true python3 examples/working_hours.py | python3 examples/email_report.py
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recipient:
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
def create_msg(
|
||||
sender: Recipient,
|
||||
receiver: Recipient,
|
||||
subject: str,
|
||||
text: str,
|
||||
html=None,
|
||||
) -> MIMEMultipart:
|
||||
"""Based on https://stackoverflow.com/a/882770/965332"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = sender.email
|
||||
msg["To"] = receiver.email
|
||||
|
||||
# Record the MIME types of both parts - text/plain and text/html.
|
||||
# Also attach parts into message container.
|
||||
# According to RFC 2046, the last part of a multipart message, in this case
|
||||
# the HTML message, is best and preferred.
|
||||
msg.attach(MIMEText(text, "plain"))
|
||||
if html:
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def main(read_stdin=True) -> None:
|
||||
smtp_server = os.environ["SMTP_SERVER"].strip()
|
||||
smtp_username = os.environ["SMTP_USERNAME"].strip()
|
||||
smtp_password = os.environ["SMTP_PASSWORD"].strip()
|
||||
|
||||
assert smtp_server, "Environment variable SMTP_SERVER not set"
|
||||
assert smtp_username, "Environment variable SMTP_USERNAME not set"
|
||||
assert smtp_password, "Environment variable SMTP_PASSWORD not set"
|
||||
|
||||
sender = Recipient("ActivityWatch (automated script)", "noreply@activitywatch.net")
|
||||
receiver = Recipient("Erik Bjäreholt", "erik.bjareholt@gmail.com")
|
||||
|
||||
if read_stdin:
|
||||
# Accepts input from stdin
|
||||
text = sys.stdin.read()
|
||||
else:
|
||||
text = "Just a test. ActivityWatch stats will go here."
|
||||
|
||||
text = text.replace("\n\n", "<hr>")
|
||||
# text = text.replace("\n\n", "<br>")
|
||||
# Create the body of the message (a plain-text and an HTML version).
|
||||
html = f"""\
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<p>{text}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
subject = "Example report from aw-client"
|
||||
msg = create_msg(sender, receiver, subject, text, html)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP_SSL(smtp_server, 465) as smtp:
|
||||
smtp.login(smtp_username, smtp_password)
|
||||
smtp.send_message(msg)
|
||||
smtp.quit()
|
||||
print("Successfully sent email")
|
||||
except smtplib.SMTPException as e:
|
||||
print("Error: unable to send email")
|
||||
print(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
66
examples/load_dataframe.py
Normal file
66
examples/load_dataframe.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Load ActivityWatch data into a dataframe, and export as CSV.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import iso8601
|
||||
import pandas as pd
|
||||
from aw_client import ActivityWatchClient
|
||||
from aw_client.classes import default_classes
|
||||
from aw_client.queries import DesktopQueryParams, canonicalEvents
|
||||
|
||||
|
||||
def build_query() -> str:
|
||||
hostname = "fakedata" if os.getenv("CI") else socket.gethostname()
|
||||
canonicalQuery = canonicalEvents(
|
||||
DesktopQueryParams(
|
||||
bid_window=f"aw-watcher-window_{hostname}",
|
||||
bid_afk=f"aw-watcher-afk_{hostname}",
|
||||
classes=default_classes,
|
||||
)
|
||||
)
|
||||
return f"""
|
||||
{canonicalQuery}
|
||||
RETURN = {{"events": events}};
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
td30d = timedelta(days=30)
|
||||
|
||||
aw = ActivityWatchClient()
|
||||
print("Querying...")
|
||||
query = build_query()
|
||||
data = aw.query(query, [(now - td30d, now)])
|
||||
|
||||
events = [
|
||||
{
|
||||
"timestamp": e["timestamp"],
|
||||
"duration": timedelta(seconds=e["duration"]),
|
||||
**e["data"],
|
||||
}
|
||||
for e in data[0]["events"]
|
||||
]
|
||||
|
||||
for e in events:
|
||||
e["$category"] = " > ".join(e["$category"])
|
||||
|
||||
df = pd.json_normalize(events)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"].apply(iso8601.parse_date))
|
||||
df.set_index("timestamp", inplace=True)
|
||||
|
||||
print(df)
|
||||
|
||||
answer = input("Do you want to export to CSV? (y/N): ")
|
||||
if answer == "y":
|
||||
filename = "output.csv"
|
||||
df.to_csv(filename)
|
||||
print(f"Wrote to {filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
67
examples/merge_buckets.py
Normal file
67
examples/merge_buckets.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import aw_client
|
||||
from aw_transform.filter_period_intersect import _intersecting_eventpairs
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Inserts all events from one bucket into another bucket, after checking for
|
||||
overlap (which you shouldn't have if it was caused by a changing hostname).
|
||||
|
||||
Useful to fix duplicate buckets caused by a changing hostname, as in this issue:
|
||||
https://github.com/ActivityWatch/activitywatch/issues/454
|
||||
"""
|
||||
|
||||
# You need to set testing=False if you're going to run this on your normal instance
|
||||
aw = aw_client.ActivityWatchClient(testing=True)
|
||||
|
||||
buckets = aw.get_buckets()
|
||||
print("Available bucket IDs:")
|
||||
print()
|
||||
for id in buckets.keys():
|
||||
print(id)
|
||||
print()
|
||||
|
||||
src_id = input("Source bucket ID: ")
|
||||
dest_id = input("Destination bucket ID: ")
|
||||
|
||||
src_events = aw.get_events(src_id)
|
||||
print(f"✓ src events: {len(src_events)}")
|
||||
dest_events = aw.get_events(dest_id)
|
||||
print(f"✓ dest events: {len(dest_events)}")
|
||||
|
||||
print("Checking overlap...")
|
||||
overlaps = list(_intersecting_eventpairs(src_events, dest_events))
|
||||
if overlaps:
|
||||
total_duration_src = sum((e.duration for e in src_events), timedelta())
|
||||
total_overlap = sum((tp.duration for _, _, tp in overlaps), timedelta())
|
||||
print(
|
||||
f"Buckets had overlap ({total_overlap} out of {total_duration_src}), can't safely merge, exiting."
|
||||
)
|
||||
exit(1)
|
||||
print("No overlap detected, continuing...")
|
||||
|
||||
print("You want to merge these two buckets:")
|
||||
print(f" - {src_id}")
|
||||
print(f" - {dest_id}")
|
||||
print(
|
||||
"WARNING: you should backup/export the buckets before attempting this operation"
|
||||
)
|
||||
if input("Does that look right? (y/n): ") != "y":
|
||||
print("Aborting")
|
||||
exit(1)
|
||||
|
||||
print("Inserting source events into destination bucket...")
|
||||
aw.insert_events(dest_id, src_events)
|
||||
|
||||
print("Operation complete")
|
||||
if input("Do you want to delete the source bucket? (y/n): ") == "y":
|
||||
aw.delete_bucket(src_id, force=True)
|
||||
print("Bucket deleted")
|
||||
|
||||
print("Exiting")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
examples/redact_sensitive.py
Normal file
147
examples/redact_sensitive.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Interactive script to redact sensitive data.
|
||||
Be careful to not delete stuff you want to keep!
|
||||
|
||||
Issues/improvements:
|
||||
- If an event matches the sensitive string, only the sensitive field will be redacted (so if the title matches but not the URL, the URL will remain unredacted)
|
||||
- One might not want to redact to the non-informative 'REDACTED', but instead to a string with secret meaning.
|
||||
- No preview of the events/strings to be redacted.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from typing import (
|
||||
List,
|
||||
Pattern,
|
||||
Set,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from aw_client import ActivityWatchClient
|
||||
from aw_core import Event
|
||||
|
||||
aw: ActivityWatchClient
|
||||
|
||||
REDACTED = "REDACTED"
|
||||
DRYRUN = True
|
||||
|
||||
|
||||
def main():
|
||||
global DRYRUN
|
||||
if "--wet" in sys.argv:
|
||||
DRYRUN = False
|
||||
|
||||
global aw
|
||||
aw = ActivityWatchClient(testing=True)
|
||||
|
||||
buckets = aw.get_buckets()
|
||||
print("Buckets: ")
|
||||
print("\n".join([" - " + bid for bid in buckets.keys()]) + "\n")
|
||||
|
||||
bid_to_redact = input(
|
||||
"In which bucket are the events you want to redact? (* for all): "
|
||||
)
|
||||
assert bid_to_redact == "*" or bid_to_redact in buckets, "Not a valid option"
|
||||
|
||||
regex_or_string = input(
|
||||
"Do you want to search by regex or string? (regex/string): "
|
||||
)
|
||||
assert regex_or_string in ["regex", "string"], "Not a valid option"
|
||||
|
||||
print("\nNOTE: Matching is not case sensitive!")
|
||||
pattern: Union[str, Pattern]
|
||||
if regex_or_string == "string":
|
||||
pattern = input("Enter a string indicating sensitive content: ").lower()
|
||||
else:
|
||||
pattern = re.compile(
|
||||
input("Enter a regex indicating sensitive content: ").lower()
|
||||
)
|
||||
|
||||
print("")
|
||||
if DRYRUN:
|
||||
print(
|
||||
"NOTE: Performing a dry run, no events will be modified. Run with --wet to modify events."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"WARNING: Note that this performs an operation that cannot be undone. We strongly recommend that you backup/export your data before proceeding."
|
||||
)
|
||||
input("Press ENTER to continue, or Ctrl-C to abort")
|
||||
|
||||
if bid_to_redact == "*":
|
||||
for bucket_id in buckets.keys():
|
||||
if bucket_id.startswith("aw-watcher-afk"):
|
||||
continue
|
||||
_redact_bucket(bucket_id, pattern)
|
||||
else:
|
||||
_redact_bucket(bid_to_redact, pattern)
|
||||
|
||||
|
||||
def _redact_bucket(bucket_id: str, pattern: Union[str, Pattern]):
|
||||
print(f"\nChecking bucket: {bucket_id}")
|
||||
|
||||
global aw
|
||||
events = aw.get_events(bucket_id, limit=-1)
|
||||
sensitive_ids = _find_sensitive(events, pattern)
|
||||
print(f"Found {len(sensitive_ids)} sensitive events")
|
||||
|
||||
if not sensitive_ids:
|
||||
return
|
||||
|
||||
yes_redact = input(
|
||||
f"Do you want to replace all the matching strings with '{REDACTED}'? (y/N): "
|
||||
)
|
||||
if yes_redact == "y":
|
||||
for e in events:
|
||||
if e.id in sensitive_ids:
|
||||
e_before = e
|
||||
e = _redact_event(e, pattern)
|
||||
print(f"\nData before: {e_before.data}")
|
||||
print(f"Data after: {e.data}")
|
||||
|
||||
if DRYRUN:
|
||||
print("DRYRUN, would do: aw.insert_event(bucket_id, e)")
|
||||
else:
|
||||
aw.delete_event(bucket_id, cast(int, e_before.id))
|
||||
aw.insert_event(bucket_id, e)
|
||||
print("Redacted event")
|
||||
|
||||
|
||||
def _check_event(e: Event, pattern: Union[str, Pattern]) -> bool:
|
||||
for v in e.data.values():
|
||||
if isinstance(v, str):
|
||||
if isinstance(pattern, str):
|
||||
if pattern in v.lower():
|
||||
return True
|
||||
else:
|
||||
if pattern.findall(v.lower()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _redact_event(e: Event, pattern: Union[str, Pattern]) -> Event:
|
||||
e = deepcopy(e)
|
||||
for k, v in e.data.items():
|
||||
if isinstance(v, str):
|
||||
if isinstance(pattern, str):
|
||||
if pattern in v.lower():
|
||||
e.data[k] = REDACTED
|
||||
else:
|
||||
if pattern.findall(v.lower()):
|
||||
e.data[k] = REDACTED
|
||||
return e
|
||||
|
||||
|
||||
def _find_sensitive(el: List[Event], pattern: Union[str, Pattern]) -> Set:
|
||||
sensitive_ids = set()
|
||||
for e in el:
|
||||
if _check_event(e, pattern):
|
||||
sensitive_ids.add(e.id)
|
||||
|
||||
return sensitive_ids
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
88
examples/suggest_categories.py
Normal file
88
examples/suggest_categories.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Lists the most common words among uncategorized events, by duration, to help in creating categories.
|
||||
|
||||
This might make more sense as a notebook.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from tabulate import tabulate
|
||||
from typing import Dict, List, Tuple, Any
|
||||
|
||||
from aw_core import Event
|
||||
import aw_client
|
||||
from aw_client import queries
|
||||
|
||||
# set up client
|
||||
awc = aw_client.ActivityWatchClient("test")
|
||||
|
||||
|
||||
def example_categories():
|
||||
# TODO: Use tools in aw-research to load categories from toml file
|
||||
return [
|
||||
(
|
||||
("Work", "ActivityWatch"),
|
||||
{"type": "regex", "regex": "aw-|activitywatch", "ignore_case": True},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_events(categories=List[Tuple[Tuple[str], Dict[str, Any]]]):
|
||||
"""
|
||||
Retrieves AFK-filtered events, only returns events which are Uncategorized.
|
||||
"""
|
||||
|
||||
start = datetime(2022, 1, 1, tzinfo=timezone.utc)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
timeperiods = [(start, now)]
|
||||
|
||||
canonicalQuery = queries.canonicalEvents(
|
||||
queries.DesktopQueryParams(
|
||||
bid_window="aw-watcher-window_",
|
||||
bid_afk="aw-watcher-afk_",
|
||||
classes=categories,
|
||||
)
|
||||
)
|
||||
res = awc.query(
|
||||
f"""
|
||||
{canonicalQuery}
|
||||
events = filter_keyvals(events, "$category", [["Uncategorized"]]);
|
||||
duration = sum_durations(events);
|
||||
RETURN = {{"events": events, "duration": duration}};
|
||||
""",
|
||||
timeperiods,
|
||||
)
|
||||
events = res[0]["events"]
|
||||
print(f"Fetched {len(events)} events")
|
||||
return [Event(**e) for e in events]
|
||||
|
||||
|
||||
def events2words(events):
|
||||
for e in events:
|
||||
for v in e.data.values():
|
||||
if isinstance(v, str):
|
||||
for word in v.split():
|
||||
if len(word) >= 3:
|
||||
# normalize
|
||||
word = word.lower()
|
||||
yield (word, e.duration)
|
||||
|
||||
|
||||
def main():
|
||||
categories = example_categories()
|
||||
events = get_events(categories)
|
||||
|
||||
# find most common words, by duration
|
||||
corpus: Dict[str, timedelta] = Counter() # type: ignore
|
||||
for word, duration in events2words(events):
|
||||
if word not in corpus:
|
||||
corpus[word] = timedelta(0)
|
||||
corpus[word] += duration
|
||||
|
||||
# The top words are rarely useful for categorization, as they are usually browsers and other categories
|
||||
# of activity which are too broad for it to make sense as a rule (except as a fallback).
|
||||
print(tabulate(corpus.most_common(50), headers=["word", "duration"])) # type: ignore
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
213
examples/suggest_categories_gpt.py
Normal file
213
examples/suggest_categories_gpt.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Uses GPT-3 to suggest new categories.
|
||||
|
||||
Builds on suggest_categories.py for basic operations, like getting events.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from aw_core import Event
|
||||
from aw_transform.classify import Rule, categorize
|
||||
|
||||
from suggest_categories import example_categories, get_events
|
||||
|
||||
Category = Tuple[List[str], Dict[str, Any]]
|
||||
|
||||
|
||||
def prompt_preamble(categories: List[Category]) -> str:
|
||||
categories_str = "\n\n".join(
|
||||
[
|
||||
f" - Category: {' > '.join(name)}\n Regex: {rule['regex']}"
|
||||
for name, rule in categories
|
||||
]
|
||||
)
|
||||
|
||||
prompt = f"""We will classify window titles into user-defined categories defined by regular expressions.
|
||||
If a suitable one doesn't exists, we will create one with a suitable regex.
|
||||
|
||||
Existing categories:
|
||||
|
||||
{categories_str}
|
||||
|
||||
---
|
||||
|
||||
What category should "ActivityWatch - wwww.github.com" be in?
|
||||
Category: Work > ActivityWatch
|
||||
|
||||
What category should "reddit: the front page of the internet" be in?
|
||||
New Category: Media > Social > Reddit
|
||||
Regex: reddit
|
||||
|
||||
What category should "Twitter" be in?
|
||||
New Category: Media > Social > Twitter
|
||||
Regex: Twitter
|
||||
|
||||
What category should "Demis Hassabis: DeepMind - AI, Superintelligence & the Future of Humanity | Lex Fridman Podcast - YouTube - Mozilla Firefox" be in?
|
||||
New Category: Media > Video > YouTube
|
||||
Regex: YouTube
|
||||
|
||||
What category should "Tweetdeck" be in?
|
||||
Modify Category: Media > Social > Twitter
|
||||
Append Regex: Tweetdeck
|
||||
|
||||
What category should "cloudflare-ipfs.com | 524: A timeout occurred - cloudflare-ipfs.com - Mozilla Firefox" be in?
|
||||
Skip: No suitable category found or to suggest, best left as uncategorized.
|
||||
|
||||
What category should "Mozilla Firefox" be in?
|
||||
Skip: No suitable category found or to suggest, best left as uncategorized.
|
||||
|
||||
What category should "RimWorld" be in?
|
||||
New Category: Games > RimWorld
|
||||
Regex: RimWorld
|
||||
|
||||
What category should "Minecraft" be in?
|
||||
New Category: Games > Minecraft
|
||||
Regex: Minecraft
|
||||
|
||||
What category should "Free Porn Videos & Sex Movies - Porno, XXX, Porn Tube | Pornhub — Mozilla Firefox" be in?
|
||||
New Category: Media > Porn
|
||||
Regex: Pornhub"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def process_prompt(prompt, categories, quiet=False) -> List[Category]:
|
||||
"""processes the prompt preamble for categories created/modified in the prompt"""
|
||||
for entry in prompt.split("---", 1)[1].split("\n\n"):
|
||||
if not entry.strip():
|
||||
continue
|
||||
# FIXME: Will break if string contains double-quotes
|
||||
title = entry.split("\n", 1)[0].split('"', 2)[1]
|
||||
response = entry.strip().split("\n", 1)[1]
|
||||
categories = parse_gpt_response(response, categories, title=title, quiet=quiet)
|
||||
return categories
|
||||
|
||||
|
||||
def gpt_suggest(event: Event, categories: List[Category]) -> List[Category]:
|
||||
"""
|
||||
Use OpenAI GPT-3 to suggest new categories.
|
||||
|
||||
Takes an uncategorized event and the current categories, and returns a list of suggested categories.
|
||||
"""
|
||||
import os
|
||||
import openai
|
||||
from copy import deepcopy
|
||||
|
||||
openai.api_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
title = event.data["title"]
|
||||
categories = deepcopy(categories)
|
||||
|
||||
prompt = prompt_preamble(categories)
|
||||
categories = process_prompt(prompt, categories, quiet=True)
|
||||
|
||||
prompt = f"""{prompt}
|
||||
|
||||
What category should "{title}" be in?"""
|
||||
|
||||
response = openai.Completion.create(
|
||||
model="text-davinci-002",
|
||||
prompt=prompt,
|
||||
temperature=0,
|
||||
max_tokens=64,
|
||||
top_p=1.0,
|
||||
frequency_penalty=0.0,
|
||||
presence_penalty=0.0,
|
||||
)
|
||||
text = response["choices"][0]["text"]
|
||||
print("-" * 80)
|
||||
# print(prompt + text)
|
||||
# print("> " + text.strip().replace("\n", "\n> "))
|
||||
|
||||
return parse_gpt_response(text, categories, title)
|
||||
|
||||
|
||||
def check_is_category(text: str, category: Category) -> bool:
|
||||
name, rule = category
|
||||
event = categorize(
|
||||
[Event(timestamp=datetime.now(tz=timezone.utc), data={"title": text})],
|
||||
[(list(name), Rule(rule))],
|
||||
)[0]
|
||||
return event.data["$category"] == list(category[0])
|
||||
|
||||
|
||||
def parse_gpt_response(text: str, categories: List[Category], title=None, quiet=False):
|
||||
category_names = [tuple(name) for name, _ in categories]
|
||||
|
||||
line1, *lines = text.strip().split("\n")
|
||||
if line1.startswith("Category:"):
|
||||
# chose existing category
|
||||
cat_name = tuple(line1.split(":", 1)[1].strip().split(" > "))
|
||||
if not quiet:
|
||||
print(f"Chose existing category {cat_name} (title: {title})")
|
||||
if cat_name not in [tuple(name[: len(cat_name)]) for name in category_names]:
|
||||
print(f"No category named {cat_name} found, skipping")
|
||||
elif line1.startswith("New Category:"):
|
||||
line2 = lines[0]
|
||||
category = line1.split(":", 1)[1].strip().split(" > ")
|
||||
regex = line2.strip().split(":", 1)[1].strip()
|
||||
if not quiet:
|
||||
print(f"Added category {category} with regex {regex} (title: {title})")
|
||||
cat: Category = (
|
||||
category,
|
||||
{"type": "regex", "regex": regex, "ignore_case": True},
|
||||
)
|
||||
if title and not check_is_category(title, cat):
|
||||
print(
|
||||
f"Bad suggested regex '{cat[1]['regex']}'. Title '{title}' does not match category {category}."
|
||||
)
|
||||
else:
|
||||
categories.append(cat)
|
||||
elif line1.startswith("Modify Category:"):
|
||||
line2 = lines[0]
|
||||
category = line1.split(":", 1)[1].strip().split(" > ")
|
||||
assert line2.startswith("Append Regex:")
|
||||
regex = line2.strip().split(":", 1)[1].strip()
|
||||
# get existing category
|
||||
for name, rule in categories:
|
||||
if name == category:
|
||||
rule["regex"] += "|" + regex
|
||||
if not quiet:
|
||||
print(f"Appended {regex} to {category}")
|
||||
break
|
||||
elif line1.startswith("Skip:"):
|
||||
pass
|
||||
else:
|
||||
print(f"Unknown response: '{text.strip()}'")
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def main():
|
||||
categories = example_categories()
|
||||
events = get_events(categories)
|
||||
|
||||
events_by_dur = sorted(events, key=lambda e: e.duration, reverse=True)
|
||||
for event in events_by_dur[:100]:
|
||||
# re-categorize and skip event if it is already categorized by a new rule
|
||||
event, *_ = categorize(
|
||||
[event], [(list(name), Rule(rule)) for name, rule in categories]
|
||||
)
|
||||
if list(event.data["$category"]) != ["Uncategorized"]:
|
||||
continue
|
||||
|
||||
categories = gpt_suggest(event, categories)
|
||||
|
||||
|
||||
def test_parse_gpt_response():
|
||||
categories = example_categories()
|
||||
prompt = prompt_preamble(categories)
|
||||
categories = process_prompt(prompt, categories)
|
||||
|
||||
cat_twitter = list(
|
||||
(name, rule)
|
||||
for name, rule in categories
|
||||
if tuple(name) == ("Media", "Social", "Twitter")
|
||||
)
|
||||
assert len(cat_twitter) == 1
|
||||
assert cat_twitter[0][1]["regex"] == "Twitter|Tweetdeck"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
examples/time_spent_today.py
Normal file
16
examples/time_spent_today.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
import socket
|
||||
|
||||
import aw_client
|
||||
|
||||
if __name__ == "__main__":
|
||||
bucket_id = f"aw-watcher-afk_{socket.gethostname()}"
|
||||
|
||||
daystart = datetime.combine(datetime.now().date(), time()).astimezone(timezone.utc)
|
||||
dayend = daystart + timedelta(days=1)
|
||||
|
||||
awc = aw_client.ActivityWatchClient("testclient")
|
||||
events = awc.get_events(bucket_id, start=daystart, end=dayend)
|
||||
events = [e for e in events if e.data["status"] == "not-afk"]
|
||||
total_duration = sum((e.duration for e in events), timedelta())
|
||||
print(f"Total time spent on computer today: {total_duration}")
|
||||
157
examples/working_hours.py
Normal file
157
examples/working_hours.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Script that computes how many hours was spent in a regex-specified "work" category for each day in a given month.
|
||||
|
||||
Also saves the matching work-events to a JSON file (for auditing purposes).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import aw_client
|
||||
from aw_client import queries
|
||||
from aw_core import Event
|
||||
from aw_transform import flood
|
||||
from tabulate import tabulate
|
||||
|
||||
OUTPUT_HTML = os.environ.get("OUTPUT_HTML", "").lower() == "true"
|
||||
|
||||
td1d = timedelta(days=1)
|
||||
day_offset = timedelta(hours=4)
|
||||
|
||||
|
||||
def _pretty_timedelta(td: timedelta) -> str:
|
||||
s = str(td)
|
||||
s = re.sub(r"^(0+[:]?)+", "", s)
|
||||
s = s.rjust(len(str(td)), " ")
|
||||
s = re.sub(r"[.]\d+", "", s)
|
||||
return s
|
||||
|
||||
|
||||
assert _pretty_timedelta(timedelta(seconds=120)) == " 2:00"
|
||||
assert _pretty_timedelta(timedelta(hours=9, minutes=5)) == "9:05:00"
|
||||
|
||||
|
||||
def generous_approx(events: List[dict], max_break: float) -> timedelta:
|
||||
"""
|
||||
Returns a generous approximation of worked time by including non-categorized time when shorter than a specific duration
|
||||
|
||||
max_break: Max time (in seconds) to flood when there's an empty slot between events
|
||||
"""
|
||||
events_e: List[Event] = [Event(**e) for e in events]
|
||||
return sum(
|
||||
map(lambda e: e.duration, flood(events_e, max_break)),
|
||||
timedelta(),
|
||||
)
|
||||
|
||||
|
||||
def query(regex: str, timeperiods, hostname: str):
|
||||
print("Querying events...")
|
||||
print(f" Day offset: {day_offset}")
|
||||
print("")
|
||||
|
||||
categories: List[Tuple[List[str], Dict]] = [
|
||||
(
|
||||
["Work"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": regex,
|
||||
"ignore_case": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
aw = aw_client.ActivityWatchClient(client_name="working_hours")
|
||||
|
||||
canonicalQuery = queries.canonicalEvents(
|
||||
queries.DesktopQueryParams(
|
||||
bid_window=f"aw-watcher-window_{hostname}",
|
||||
bid_afk=f"aw-watcher-afk_{hostname}",
|
||||
classes=categories,
|
||||
filter_classes=[["Work"]],
|
||||
)
|
||||
)
|
||||
query = f"""
|
||||
{canonicalQuery}
|
||||
duration = sum_durations(events);
|
||||
RETURN = {{"events": events, "duration": duration}};
|
||||
"""
|
||||
|
||||
res = aw.query(query, timeperiods)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 working_hours.py <regex> [hostname]")
|
||||
exit(1)
|
||||
|
||||
regex = sys.argv[1]
|
||||
print(f"Using regex: {regex}")
|
||||
|
||||
if len(sys.argv) > 2:
|
||||
hostname = sys.argv[2]
|
||||
print(f"Using hostname: {hostname}")
|
||||
else:
|
||||
hostname = socket.gethostname()
|
||||
|
||||
now = datetime.now().astimezone()
|
||||
today = (datetime.combine(now.date(), time()) + day_offset).astimezone()
|
||||
|
||||
timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(5)]
|
||||
timeperiods.reverse()
|
||||
|
||||
res = query(regex, timeperiods, hostname)
|
||||
|
||||
for break_time in [0, 5 * 60, 15 * 60]:
|
||||
_print(timeperiods, res, break_time, {"regex": regex})
|
||||
|
||||
fn = "working_hours_events.json"
|
||||
with open(fn, "w") as f:
|
||||
print(f"Saving to {fn}...")
|
||||
json.dump(res, f, indent=2)
|
||||
|
||||
|
||||
def _print(timeperiods, res, break_time, params: dict):
|
||||
print("Using:")
|
||||
print(f" break_time={break_time}")
|
||||
print("\n".join(f" {key}={val}" for key, val in params.items()))
|
||||
print(
|
||||
tabulate(
|
||||
[
|
||||
[
|
||||
start.date(),
|
||||
# Without flooding:
|
||||
# _pretty_timedelta(timedelta(seconds=res[i]["duration"])),
|
||||
# With flooding:
|
||||
_pretty_timedelta(generous_approx(res[i]["events"], break_time)),
|
||||
len(res[i]["events"]),
|
||||
]
|
||||
for i, (start, stop) in enumerate(timeperiods)
|
||||
],
|
||||
headers=["Date", "Duration", "Events"],
|
||||
colalign=(
|
||||
"left",
|
||||
"right",
|
||||
),
|
||||
tablefmt="html" if OUTPUT_HTML else "simple",
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
f"Total: {sum((generous_approx(res[i]['events'], break_time) for i in range(len(timeperiods))), timedelta())}"
|
||||
)
|
||||
print("")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ignore log warnings in aw_transform
|
||||
logging.getLogger("aw_transform").setLevel(logging.ERROR)
|
||||
|
||||
main()
|
||||
129
examples/working_hours_gspread.py
Normal file
129
examples/working_hours_gspread.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
This script uses ActivityWatch events to updates a Google Sheet
|
||||
with the any events matching a regex for the last `days_back` days.
|
||||
|
||||
It uses the `working_hours.py` example to calculate the working hours
|
||||
and the `gspread` library to interact with Google Sheets.
|
||||
|
||||
The Google Sheet is identified by its key, which is hardcoded in the script.
|
||||
The script uses a service account for authentication with the Google Sheets API.
|
||||
|
||||
The script assumes that the Google Sheet has a worksheet for each hostname, named "worked-{hostname}".
|
||||
If such a worksheet does not exist, the script will fail.
|
||||
|
||||
The working hours are calculated generously, meaning that if the time between two consecutive
|
||||
events is less than `break_time` (10 minutes by default), it is considered as working time.
|
||||
|
||||
Usage:
|
||||
python3 working_hours_gspread.py <sheet_key> <regex>
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Union
|
||||
|
||||
import gspread
|
||||
from gspread.utils import ValueInputOption
|
||||
|
||||
import working_hours
|
||||
|
||||
td1d = timedelta(days=1)
|
||||
break_time = 10 * 60
|
||||
|
||||
|
||||
def update_sheet(sheet_key: str, regex: str):
|
||||
"""
|
||||
Update the Google Sheet with the working hours for the last `days_back` days.
|
||||
|
||||
1. Open the sheet and get the last entry
|
||||
2. Query the working hours for the days since the last entry
|
||||
3. Update the last entry in the Google Sheet (if any)
|
||||
4. Append any new entries
|
||||
"""
|
||||
|
||||
hostname = socket.gethostname()
|
||||
hostname_display = hostname.replace(".localdomain", "").replace(".local", "")
|
||||
|
||||
try:
|
||||
gc = gspread.service_account()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(
|
||||
"Failed to authenticate with Google Sheets API.\n"
|
||||
"Make sure you have a service account key in ~/.config/gspread/service_account.json\n"
|
||||
"See https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account"
|
||||
)
|
||||
exit(1)
|
||||
|
||||
# Open the sheet
|
||||
sh = gc.open_by_key(sheet_key)
|
||||
print(f"Updating document: {sh.title}")
|
||||
worksheet = sh.worksheet(f"worked-{hostname_display}")
|
||||
print(f"Updating worksheet: {worksheet.title}")
|
||||
|
||||
# Get the most recent entry from the Google Sheet
|
||||
values = worksheet.get_all_values()
|
||||
if values:
|
||||
last_row = values[-1]
|
||||
last_date = datetime.strptime(last_row[0], "%Y-%m-%d").date()
|
||||
else:
|
||||
last_date = None
|
||||
|
||||
last_datetime = (
|
||||
(datetime.combine(last_date, time()) + working_hours.day_offset).astimezone()
|
||||
if last_date
|
||||
else None
|
||||
)
|
||||
|
||||
if last_datetime:
|
||||
print(f"Last entry: {last_datetime}")
|
||||
|
||||
now = datetime.now().astimezone()
|
||||
today = (
|
||||
datetime.combine(now.date(), time()) + working_hours.day_offset
|
||||
).astimezone()
|
||||
|
||||
# Create a list of time periods to query, from last_date or days_back_on_new back if None
|
||||
days_back_on_new = 30
|
||||
days_back = (today - last_datetime).days + 1 if last_datetime else days_back_on_new
|
||||
timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(days_back)]
|
||||
timeperiods.reverse()
|
||||
|
||||
# Run the query function from the original script and get the result
|
||||
res = working_hours.query(regex, timeperiods, hostname)
|
||||
|
||||
# Iterate over the result and update or append the data to the Google Sheet
|
||||
for tp, r in zip(timeperiods, res):
|
||||
# skip if the timeperiod is in the future (might happen between midnight and the day_offset)
|
||||
if tp[0] > now:
|
||||
continue
|
||||
|
||||
date = tp[0].date()
|
||||
duration = (
|
||||
working_hours.generous_approx(r["events"], break_time).total_seconds()
|
||||
/ 3600
|
||||
)
|
||||
row: list[Union[str, float]] = [str(date), duration]
|
||||
|
||||
# If the date is the same as the last entry, update it
|
||||
if last_date and date == last_date:
|
||||
print(f"Updating {row}")
|
||||
worksheet.update_cell(len(worksheet.get_all_values()), 2, duration)
|
||||
# If the date is later than the last entry, append it
|
||||
elif not last_date or date > last_date:
|
||||
print(f"Appending {row}")
|
||||
worksheet.append_row(row, value_input_option=ValueInputOption.user_entered)
|
||||
else:
|
||||
print(f"Skipping {row}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3:
|
||||
sheet_key = sys.argv[1]
|
||||
regex = sys.argv[2]
|
||||
else:
|
||||
print("Usage: python3 working_hours_gspread.py <sheet_key> <regex>")
|
||||
exit(1)
|
||||
|
||||
update_sheet(sheet_key, regex)
|
||||
1112
poetry.lock
generated
Normal file
1112
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
pyproject.toml
Normal file
51
pyproject.toml
Normal file
@@ -0,0 +1,51 @@
|
||||
[tool.poetry]
|
||||
name = "aw-client"
|
||||
version = "0.5.15"
|
||||
description = "Client library for ActivityWatch"
|
||||
authors = ["Erik Bjäreholt <erik@bjareho.lt>", "Johan Bjäreholt <johan@bjareho.lt>"]
|
||||
license = "MPL-2.0"
|
||||
readme = "README.md"
|
||||
homepage = "https://activitywatch.net/"
|
||||
repository = "https://github.com/ActivityWatch/aw-client/"
|
||||
documentation = "https://docs.activitywatch.net/"
|
||||
packages = [
|
||||
{ include = "aw_client" },
|
||||
]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
aw-client = "aw_client.cli:main"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
aw-core = "^0.5.16"
|
||||
requests = "*"
|
||||
persist-queue = "*"
|
||||
click = "^8.0"
|
||||
tabulate = "*"
|
||||
tomlkit = "*"
|
||||
typing-extensions = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mypy = "*"
|
||||
ruff = "*"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
types-requests = "*"
|
||||
types-tabulate = "*"
|
||||
gspread = "*" # used in examples
|
||||
|
||||
[tool.mypy]
|
||||
files = ["aw_client", "tests", "examples"]
|
||||
ignore_missing_imports = true
|
||||
check_untyped_defs = true
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py38"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "B", "UP"]
|
||||
ignore = ["E402", "E501", "B905"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
12
tests/conftest.py
Normal file
12
tests/conftest.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_aw_profile_after_test():
|
||||
"""export_profile() writes os.environ directly; monkeypatch.delenv does
|
||||
not record an undo when the var was already unset, so later tests would
|
||||
inherit a leftover profile."""
|
||||
yield
|
||||
os.environ.pop("AW_PROFILE", None)
|
||||
104
tests/test_auth.py
Normal file
104
tests/test_auth.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aw_client import ActivityWatchClient
|
||||
from aw_client.config import load_local_server_api_key
|
||||
from aw_client import client as client_module
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, data: Optional[Dict[str, Any]] = None):
|
||||
self._data = data or {}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
def json(self) -> Dict[str, Any]:
|
||||
return self._data
|
||||
|
||||
|
||||
def write_server_config(tmp_path, filename: str, content: str) -> None:
|
||||
config_dir = tmp_path / "activitywatch" / "aw-server-rust"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / filename).write_text(content)
|
||||
|
||||
|
||||
def test_load_local_server_api_key_matches_port(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aw_client.config._user_config_dir",
|
||||
lambda appname: str(tmp_path / appname),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_client.config._user_data_dir",
|
||||
lambda appname: str(tmp_path / "data" / appname),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_client.config._user_cache_dir",
|
||||
lambda appname: str(tmp_path / "cache" / appname),
|
||||
)
|
||||
write_server_config(
|
||||
tmp_path,
|
||||
"config.toml",
|
||||
'port = 5601\n\n[auth]\napi_key = "secret123"\n',
|
||||
)
|
||||
write_server_config(
|
||||
tmp_path,
|
||||
"config-testing.toml",
|
||||
'port = 5666\n\n[auth]\napi_key = "testing-secret"\n',
|
||||
)
|
||||
|
||||
assert load_local_server_api_key("127.0.0.1", 5601) == "secret123"
|
||||
assert (
|
||||
load_local_server_api_key("localhost", "5666", profile="testing")
|
||||
== "testing-secret"
|
||||
)
|
||||
assert load_local_server_api_key("::1", 5601) == "secret123"
|
||||
assert load_local_server_api_key("127.0.0.1", 5600) is None
|
||||
assert load_local_server_api_key("example.com", 5601) is None
|
||||
# Default profile must not pick up the testing server's key.
|
||||
assert load_local_server_api_key("localhost", "5666") is None
|
||||
|
||||
|
||||
def test_client_sends_authorization_header_for_local_server(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
write_server_config(
|
||||
tmp_path,
|
||||
"config.toml",
|
||||
'port = 5600\n\n[auth]\napi_key = "secret123"\n',
|
||||
)
|
||||
monkeypatch.setattr(client_module, "SingleInstance", lambda name: object())
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, params=None, headers=None):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return DummyResponse({"hostname": "test-host", "testing": False})
|
||||
|
||||
monkeypatch.setattr(client_module.req, "get", fake_get)
|
||||
|
||||
client = ActivityWatchClient("test-client", host="127.0.0.1", port=5600)
|
||||
assert client.get_info()["hostname"] == "test-host"
|
||||
assert captured["url"] == "http://127.0.0.1:5600/api/0/info"
|
||||
assert captured["headers"]["Authorization"] == "Bearer secret123"
|
||||
|
||||
|
||||
def test_client_skips_authorization_header_for_remote_server(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
write_server_config(
|
||||
tmp_path,
|
||||
"config.toml",
|
||||
'port = 5600\n\n[auth]\napi_key = "secret123"\n',
|
||||
)
|
||||
monkeypatch.setattr(client_module, "SingleInstance", lambda name: object())
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_get(url, params=None, headers=None):
|
||||
captured["headers"] = headers
|
||||
return DummyResponse({"hostname": "remote-host", "testing": False})
|
||||
|
||||
monkeypatch.setattr(client_module.req, "get", fake_get)
|
||||
|
||||
client = ActivityWatchClient("test-client", host="aw.example.com", port=5600)
|
||||
assert client.get_info()["hostname"] == "remote-host"
|
||||
assert "Authorization" not in captured["headers"]
|
||||
130
tests/test_client.py
Executable file
130
tests/test_client.py
Executable file
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
import warnings
|
||||
from random import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_core.models import Event
|
||||
from aw_client import ActivityWatchClient
|
||||
|
||||
|
||||
def create_unique_event():
|
||||
return Event(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
duration=timedelta(),
|
||||
data={"label": str(random())},
|
||||
)
|
||||
|
||||
|
||||
def test_full():
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
client_name = "aw-test-client"
|
||||
bucket_name = "test-bucket"
|
||||
bucket_etype = "test"
|
||||
|
||||
# Test context manager
|
||||
with ActivityWatchClient(client_name, testing=True) as client:
|
||||
time.sleep(1)
|
||||
|
||||
# Check that client name is set correctly
|
||||
assert client.client_name == client_name
|
||||
|
||||
# Delete bucket before creating it, and handle error if it doesn't already exist
|
||||
try:
|
||||
client.delete_bucket(bucket_name)
|
||||
except HTTPError:
|
||||
pass
|
||||
|
||||
# Create bucket
|
||||
client.create_bucket(bucket_name, bucket_etype)
|
||||
|
||||
# Check bucket
|
||||
buckets = client.get_buckets()
|
||||
assert bucket_name in buckets
|
||||
assert bucket_name == buckets[bucket_name]["id"]
|
||||
assert bucket_etype == buckets[bucket_name]["type"]
|
||||
|
||||
# Insert events
|
||||
e1 = create_unique_event()
|
||||
e2 = create_unique_event()
|
||||
e3 = create_unique_event()
|
||||
events = [e1, e2, e3]
|
||||
client.insert_events(bucket_name, events)
|
||||
|
||||
# Get events
|
||||
fetched_events = client.get_events(bucket_name, limit=len(events))
|
||||
|
||||
# Assert events
|
||||
assert [(e.timestamp, e.duration, e.data) for e in fetched_events] == [
|
||||
(e.timestamp, e.duration, e.data) for e in events
|
||||
]
|
||||
|
||||
# Check eventcount
|
||||
eventcount = client.get_eventcount(bucket_name)
|
||||
assert eventcount == len(events)
|
||||
|
||||
result = client.query(
|
||||
f'RETURN = query_bucket("{bucket_name}");',
|
||||
timeperiods=[(now - timedelta(hours=1), now + timedelta(hours=1))],
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 3
|
||||
|
||||
# Get single event
|
||||
e = client.get_event(bucket_name, fetched_events[1].id)
|
||||
assert e.id == fetched_events[1].id
|
||||
|
||||
# Delete single event
|
||||
client.delete_event(bucket_name, fetched_events[1].id)
|
||||
assert client.get_event(bucket_name, fetched_events[1].id) is None
|
||||
|
||||
# Test exception raising
|
||||
with pytest.raises(ValueError):
|
||||
# timeperiod end time does not have a timezone set
|
||||
result = client.query(
|
||||
f'RETURN = query_bucket("{bucket_name}");',
|
||||
timeperiods=[(now - timedelta(hours=1), datetime.now())],
|
||||
)
|
||||
|
||||
# Create and delete an event: check that it no longer exists
|
||||
e_del = create_unique_event()
|
||||
client.insert_event(bucket_name, e_del)
|
||||
fetched_events = client.get_events(bucket_name)
|
||||
assert (e_del.timestamp, e_del.duration, e_del.data) in [
|
||||
(e.timestamp, e.duration, e.data) for e in fetched_events
|
||||
]
|
||||
|
||||
e_del_fetched = [e for e in fetched_events if e.data == e_del.data][0]
|
||||
client.delete_event(bucket_name, e_del_fetched)
|
||||
fetched_events = client.get_events(bucket_name)
|
||||
assert (e_del.timestamp, e_del.duration, e_del.data) not in [
|
||||
(e.timestamp, e.duration, e.data) for e in fetched_events
|
||||
]
|
||||
|
||||
# Delete bucket
|
||||
client.delete_bucket(bucket_name)
|
||||
|
||||
|
||||
def test_queued_usage_warns_once_before_connect():
|
||||
client = ActivityWatchClient(f"aw-test-client-{random()}", testing=True)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
client.create_bucket("test-bucket", "test", queued=True)
|
||||
client.heartbeat(
|
||||
"test-bucket",
|
||||
create_unique_event(),
|
||||
pulsetime=1,
|
||||
queued=True,
|
||||
)
|
||||
|
||||
queue_warnings = [
|
||||
warning
|
||||
for warning in caught
|
||||
if "connect()" in str(warning.message) or "with client:" in str(warning.message)
|
||||
]
|
||||
assert len(queue_warnings) == 1
|
||||
56
tests/test_failqueue.py
Executable file
56
tests/test_failqueue.py
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
from pprint import pprint
|
||||
from random import randint
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aw_core.models import Event
|
||||
from aw_client import ActivityWatchClient
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def create_unique_event():
|
||||
return Event(timestamp=now, data={"label": str(randint(0, 10000))})
|
||||
|
||||
|
||||
def test_failqueue():
|
||||
client_name = "aw-test-client-" + str(randint(0, 10000))
|
||||
bucket_id = "test-bucket-" + str(randint(0, 10000))
|
||||
bucket_etype = "test"
|
||||
|
||||
input("Make sure aw-server isn't running, then press enter > ")
|
||||
client1 = ActivityWatchClient(client_name, testing=True)
|
||||
client1.create_bucket(bucket_id, bucket_etype, queued=True)
|
||||
|
||||
print("Creating events")
|
||||
events = [create_unique_event() for _ in range(3)]
|
||||
for i, e in enumerate(events):
|
||||
e.timestamp += timedelta(seconds=i)
|
||||
client1.heartbeat(bucket_id, e, pulsetime=1, queued=True)
|
||||
|
||||
print("Trying to send events (should fail)")
|
||||
with client1:
|
||||
time.sleep(1)
|
||||
|
||||
input("Start aw-server with --testing, then press enter > ")
|
||||
|
||||
client2 = ActivityWatchClient(client_name, testing=True)
|
||||
client2.create_bucket(bucket_id, bucket_etype, queued=True)
|
||||
|
||||
# Here the previously queued events should be sent
|
||||
with client2:
|
||||
time.sleep(1)
|
||||
|
||||
print("Getting events")
|
||||
recv_events = client2.get_events(bucket_id)
|
||||
|
||||
print("Asserting latest event")
|
||||
pprint(recv_events)
|
||||
pprint(recv_events[0].data["label"])
|
||||
pprint(events[2].data["label"])
|
||||
assert recv_events[0].data["label"] == events[2].data["label"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_failqueue()
|
||||
94
tests/test_profile.py
Normal file
94
tests/test_profile.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Unit tests for profile resolution."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_client.profile import (
|
||||
DEFAULT_PROFILE,
|
||||
TESTING_PROFILE,
|
||||
export_profile,
|
||||
is_testing,
|
||||
profile_from_env,
|
||||
profile_suffix,
|
||||
resolve_profile,
|
||||
validate_profile,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveProfile:
|
||||
def test_no_flags_gives_default(self):
|
||||
assert resolve_profile(None, False) == DEFAULT_PROFILE
|
||||
|
||||
def test_testing_flag_is_an_alias(self):
|
||||
assert resolve_profile(None, True) == TESTING_PROFILE
|
||||
|
||||
def test_explicit_profile_wins(self):
|
||||
assert resolve_profile("research", False) == "research"
|
||||
|
||||
def test_testing_with_matching_profile_is_allowed(self):
|
||||
assert resolve_profile("testing", True) == TESTING_PROFILE
|
||||
|
||||
def test_testing_with_conflicting_profile_raises(self):
|
||||
with pytest.raises(ValueError, match="conflicts"):
|
||||
resolve_profile("research", True)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["Research", "with space", "a" * 33, "", "../escape", "-leading"]
|
||||
)
|
||||
def test_invalid_names_rejected(self, name):
|
||||
with pytest.raises(ValueError):
|
||||
validate_profile(name)
|
||||
|
||||
@pytest.mark.parametrize("name", ["research", "aw2", "my_profile", "my-profile"])
|
||||
def test_valid_names_accepted(self, name):
|
||||
assert validate_profile(name) == name
|
||||
|
||||
|
||||
class TestProfileSuffix:
|
||||
def test_default_has_no_suffix(self):
|
||||
assert profile_suffix(DEFAULT_PROFILE) == ""
|
||||
|
||||
def test_testing_keeps_legacy_suffix(self):
|
||||
assert profile_suffix(TESTING_PROFILE) == "-testing"
|
||||
|
||||
def test_custom_profile_suffix(self):
|
||||
assert profile_suffix("research") == "-research"
|
||||
|
||||
def test_suffixes_are_disjoint(self):
|
||||
suffixes = {profile_suffix(p) for p in ("default", "testing", "research")}
|
||||
assert len(suffixes) == 3
|
||||
|
||||
|
||||
class TestIsTesting:
|
||||
def test_only_testing_profile_is_testing(self):
|
||||
assert is_testing(TESTING_PROFILE)
|
||||
assert not is_testing(DEFAULT_PROFILE)
|
||||
assert not is_testing("research")
|
||||
|
||||
|
||||
class TestExportProfile:
|
||||
def test_exported_for_child_processes(self, monkeypatch):
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
export_profile("research")
|
||||
assert os.environ["AW_PROFILE"] == "research"
|
||||
|
||||
def test_default_unsets_env_so_aw_core_keeps_bare_root(self, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
export_profile(DEFAULT_PROFILE)
|
||||
assert "AW_PROFILE" not in os.environ
|
||||
|
||||
|
||||
class TestProfileFromEnv:
|
||||
def test_defaults_when_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
assert profile_from_env(False) == DEFAULT_PROFILE
|
||||
assert profile_from_env(True) == TESTING_PROFILE
|
||||
|
||||
def test_reads_exported_profile(self, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
assert profile_from_env(False) == "research"
|
||||
|
||||
def test_ignores_invalid_env_value(self, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "Not A Profile")
|
||||
assert profile_from_env(False) == DEFAULT_PROFILE
|
||||
294
tests/test_profile_config.py
Normal file
294
tests/test_profile_config.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Client constructor, config sections, persistqueue, and rust api-key lookup."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_client import ActivityWatchClient
|
||||
from aw_client import client as client_module
|
||||
from aw_client.config import load_local_server_api_key, rust_server_config_candidates
|
||||
from aw_client.profile import DEFAULT_PROFILE, TESTING_PROFILE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_dirs(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
monkeypatch.setattr(client_module, "SingleInstance", lambda name: object())
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestClientConstructor:
|
||||
def test_testing_alias_uses_testing_port_and_exports_env(self, isolated_dirs):
|
||||
client = ActivityWatchClient("t", testing=True)
|
||||
assert client.profile == TESTING_PROFILE
|
||||
assert client.testing is True
|
||||
assert client.server_address.endswith(":5666")
|
||||
assert os.environ["AW_PROFILE"] == "testing"
|
||||
|
||||
def test_explicit_profile_testing_is_the_same(self, isolated_dirs):
|
||||
client = ActivityWatchClient("t", profile="testing")
|
||||
assert client.profile == TESTING_PROFILE
|
||||
assert client.testing is True
|
||||
assert client.server_address.endswith(":5666")
|
||||
|
||||
def test_default_unsets_env_and_uses_5600(self, isolated_dirs, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
client = ActivityWatchClient("t", profile="default")
|
||||
assert client.profile == DEFAULT_PROFILE
|
||||
assert client.testing is False
|
||||
assert client.server_address.endswith(":5600")
|
||||
assert "AW_PROFILE" not in os.environ
|
||||
|
||||
def test_named_profile_falls_back_to_server_section(self, isolated_dirs, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="aw_client.client"):
|
||||
client = ActivityWatchClient("t", profile="research")
|
||||
assert client.profile == "research"
|
||||
assert client.testing is False
|
||||
assert client.server_address.endswith(":5600")
|
||||
assert os.environ["AW_PROFILE"] == "research"
|
||||
assert any(
|
||||
"falling back to [server]" in rec.message
|
||||
and "may collide with the default instance" in rec.message
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
def test_env_from_launcher_is_used_when_no_flags(self, isolated_dirs, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
client = ActivityWatchClient("t")
|
||||
assert client.profile == "research"
|
||||
assert os.environ["AW_PROFILE"] == "research"
|
||||
|
||||
def test_explicit_testing_overrides_stale_env(self, isolated_dirs, monkeypatch):
|
||||
monkeypatch.setenv("AW_PROFILE", "research")
|
||||
client = ActivityWatchClient("t", testing=True)
|
||||
assert client.profile == TESTING_PROFILE
|
||||
assert os.environ["AW_PROFILE"] == "testing"
|
||||
|
||||
def test_host_and_port_kwargs_still_win(self, isolated_dirs):
|
||||
client = ActivityWatchClient(
|
||||
"t", profile="research", host="127.0.0.1", port=5667
|
||||
)
|
||||
assert client.server_address == "http://127.0.0.1:5667"
|
||||
|
||||
def test_conflicting_flags_raise(self, isolated_dirs):
|
||||
with pytest.raises(ValueError, match="conflicts"):
|
||||
ActivityWatchClient("t", testing=True, profile="research")
|
||||
|
||||
|
||||
class TestPersistqueueSuffix:
|
||||
def test_testing_keeps_legacy_suffix(self, isolated_dirs):
|
||||
client = ActivityWatchClient("aw-test-client", testing=True)
|
||||
assert "aw-test-client-testing." in client.request_queue.persistqueue_path
|
||||
|
||||
def test_named_profile_suffix_is_disjoint(self, isolated_dirs):
|
||||
testing = ActivityWatchClient("aw-test-client", testing=True)
|
||||
research = ActivityWatchClient("aw-test-client", profile="research")
|
||||
assert (
|
||||
testing.request_queue.persistqueue_path
|
||||
!= research.request_queue.persistqueue_path
|
||||
)
|
||||
assert "aw-test-client-research." in research.request_queue.persistqueue_path
|
||||
|
||||
def test_reconnect_preserves_named_profile_queue_path(
|
||||
self, isolated_dirs, monkeypatch
|
||||
):
|
||||
def profile_data_dir(module):
|
||||
profile = os.environ.get("AW_PROFILE")
|
||||
root = "activitywatch" if profile is None else f"activitywatch-{profile}"
|
||||
return str(isolated_dirs / root / module)
|
||||
|
||||
monkeypatch.setattr(client_module, "get_data_dir", profile_data_dir)
|
||||
research = ActivityWatchClient("aw-test-client", profile="research")
|
||||
original_path = research.request_queue.persistqueue_path
|
||||
monkeypatch.setattr(research.request_queue, "_try_connect", lambda: True)
|
||||
research.request_queue.connected = True
|
||||
research.connect()
|
||||
|
||||
default = ActivityWatchClient("other-client", profile="default")
|
||||
assert default.request_queue.persistqueue_path != original_path
|
||||
research.disconnect()
|
||||
|
||||
assert research.request_queue.persistqueue_path == original_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_platform_dirs(tmp_path, monkeypatch):
|
||||
"""Point rust-config lookup at a tmp tree. platformdirs on macOS/Windows
|
||||
ignores XDG_* even when set, so patch the wrappers rather than env vars.
|
||||
"""
|
||||
data = tmp_path / "data"
|
||||
config = tmp_path / "config"
|
||||
cache = tmp_path / "cache"
|
||||
|
||||
def _join(root: Path, appname: str) -> str:
|
||||
return str(root / appname)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aw_client.config._user_data_dir", lambda appname: _join(data, appname)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_client.config._user_config_dir",
|
||||
lambda appname: _join(config, appname),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aw_client.config._user_cache_dir",
|
||||
lambda appname: _join(cache, appname),
|
||||
)
|
||||
monkeypatch.delenv("AW_PROFILE", raising=False)
|
||||
return {"data": data, "config": config, "cache": cache, "root": tmp_path}
|
||||
|
||||
|
||||
def _write_rust_config(
|
||||
config_root: Path, appname: str, filename: str, content: str
|
||||
) -> Path:
|
||||
rust_dir = config_root / appname / "aw-server-rust"
|
||||
rust_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = rust_dir / filename
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
|
||||
def test_load_local_server_api_key_named_profile(fake_platform_dirs):
|
||||
config = fake_platform_dirs["config"]
|
||||
_write_rust_config(
|
||||
config,
|
||||
"activitywatch-research",
|
||||
"config.toml",
|
||||
'port = 5667\n\n[auth]\napi_key = "research-secret"\n',
|
||||
)
|
||||
_write_rust_config(
|
||||
config,
|
||||
"activitywatch",
|
||||
"config.toml",
|
||||
'port = 5600\n\n[auth]\napi_key = "default-secret"\n',
|
||||
)
|
||||
# Suffixed names in the isolated root must not be read — dir isolation
|
||||
# is the point (ActivityWatch/activitywatch#1399).
|
||||
_write_rust_config(
|
||||
config,
|
||||
"activitywatch-research",
|
||||
"config-research.toml",
|
||||
'port = 5667\n\n[auth]\napi_key = "suffixed-must-be-ignored"\n',
|
||||
)
|
||||
assert (
|
||||
load_local_server_api_key("127.0.0.1", 5667, profile="research")
|
||||
== "research-secret"
|
||||
)
|
||||
assert load_local_server_api_key("127.0.0.1", 5600, profile="default") == (
|
||||
"default-secret"
|
||||
)
|
||||
assert load_local_server_api_key("127.0.0.1", 5600, profile="research") is None
|
||||
|
||||
|
||||
class TestTestingRootApiKeyLookup:
|
||||
"""Rust API-key lookup follows the #1399 testing-root rule."""
|
||||
|
||||
def test_fresh_setup_reads_bare_config_in_new_root(self, fake_platform_dirs):
|
||||
_write_rust_config(
|
||||
fake_platform_dirs["config"],
|
||||
"activitywatch-testing",
|
||||
"config.toml",
|
||||
'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n',
|
||||
)
|
||||
assert (
|
||||
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
|
||||
== "new-root-secret"
|
||||
)
|
||||
paths = [p for p, _ in rust_server_config_candidates("testing")]
|
||||
assert paths[0].endswith(
|
||||
os.path.join("activitywatch-testing", "aw-server-rust", "config.toml")
|
||||
)
|
||||
|
||||
def test_legacy_artifacts_keep_suffixed_shared_root(self, fake_platform_dirs):
|
||||
_write_rust_config(
|
||||
fake_platform_dirs["config"],
|
||||
"activitywatch",
|
||||
"config-testing.toml",
|
||||
'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n',
|
||||
)
|
||||
assert (
|
||||
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
|
||||
== "legacy-secret"
|
||||
)
|
||||
paths = [p for p, _ in rust_server_config_candidates("testing")]
|
||||
assert paths[0].endswith(
|
||||
os.path.join("activitywatch", "aw-server-rust", "config-testing.toml")
|
||||
)
|
||||
|
||||
def test_new_root_wins_over_legacy_artifacts(self, fake_platform_dirs):
|
||||
_write_rust_config(
|
||||
fake_platform_dirs["config"],
|
||||
"activitywatch",
|
||||
"config-testing.toml",
|
||||
'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n',
|
||||
)
|
||||
_write_rust_config(
|
||||
fake_platform_dirs["config"],
|
||||
"activitywatch-testing",
|
||||
"config.toml",
|
||||
'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n',
|
||||
)
|
||||
assert (
|
||||
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
|
||||
== "new-root-secret"
|
||||
)
|
||||
|
||||
def test_empty_new_root_still_finds_legacy_key(self, fake_platform_dirs):
|
||||
"""Python creating activitywatch-testing/ must not hide rust's legacy key."""
|
||||
(fake_platform_dirs["config"] / "activitywatch-testing").mkdir(parents=True)
|
||||
_write_rust_config(
|
||||
fake_platform_dirs["config"],
|
||||
"activitywatch",
|
||||
"config-testing.toml",
|
||||
'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n',
|
||||
)
|
||||
assert (
|
||||
load_local_server_api_key("127.0.0.1", 5666, profile="testing")
|
||||
== "legacy-secret"
|
||||
)
|
||||
|
||||
|
||||
class TestCliPortOverride:
|
||||
def test_explicit_port_5600_is_not_discarded(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
def get_buckets(self):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient)
|
||||
from click.testing import CliRunner
|
||||
|
||||
from aw_client.cli import main
|
||||
|
||||
result = CliRunner().invoke(main, ["--port", "5600", "buckets"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["port"] == 5600
|
||||
|
||||
def test_omitted_port_lets_profile_config_win(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
def get_buckets(self):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient)
|
||||
from click.testing import CliRunner
|
||||
|
||||
from aw_client.cli import main
|
||||
|
||||
result = CliRunner().invoke(main, ["--testing", "buckets"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["port"] is None
|
||||
assert captured["testing"] is True
|
||||
90
tests/test_requestqueue.py
Normal file
90
tests/test_requestqueue.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Tests the often-buggy request queue.
|
||||
|
||||
WARNING: A shitload of mocking ahead
|
||||
|
||||
It is said about testing that it makes you able to refactorize
|
||||
with confidence, and I need some of that right now.
|
||||
"""
|
||||
|
||||
from time import sleep
|
||||
from logging import basicConfig, DEBUG
|
||||
|
||||
basicConfig(level=DEBUG)
|
||||
|
||||
import requests
|
||||
from aw_client.client import RequestQueue
|
||||
|
||||
|
||||
class MockClient:
|
||||
client_name = "Mock"
|
||||
|
||||
def __init__(self):
|
||||
self.testing = True
|
||||
self.create_bucket_calls = []
|
||||
|
||||
def get_buckets(self, *args, **kwargs):
|
||||
print("Called get_buckets")
|
||||
return [{"id": "test", "name": "Test"}]
|
||||
|
||||
def create_bucket(self, *args, **kwargs):
|
||||
self.create_bucket_calls.append((args, kwargs))
|
||||
print("Called create_bucket")
|
||||
|
||||
def _post(self, *args, **kwargs):
|
||||
print(args, kwargs)
|
||||
return requests.Response()
|
||||
|
||||
|
||||
def test_basic():
|
||||
client = MockClient()
|
||||
rq = RequestQueue(client) # type: ignore
|
||||
|
||||
# Mockeypatching
|
||||
rq._try_connect = lambda: True # type: ignore
|
||||
rq.connected = True
|
||||
|
||||
rq.start()
|
||||
rq.add_request("/api/0/buckets/test/heartbeat", {})
|
||||
sleep(1)
|
||||
rq.stop()
|
||||
rq.join()
|
||||
|
||||
|
||||
def test_complex():
|
||||
client = MockClient()
|
||||
rq = RequestQueue(client) # type: ignore
|
||||
|
||||
# Mockeypatching
|
||||
rq._try_connect = lambda: False # type: ignore
|
||||
|
||||
rq.start()
|
||||
sleep(1)
|
||||
rq.stop()
|
||||
rq.join()
|
||||
|
||||
|
||||
def test_register_bucket_creates_immediately_when_connected():
|
||||
client = MockClient()
|
||||
rq = RequestQueue(client) # type: ignore
|
||||
rq.connected = True
|
||||
|
||||
rq.register_bucket("test-bucket", "test-type")
|
||||
|
||||
assert client.create_bucket_calls == [(("test-bucket", "test-type"), {})]
|
||||
|
||||
|
||||
def test_register_bucket_marks_queue_disconnected_on_create_failure():
|
||||
class FailingClient(MockClient):
|
||||
def create_bucket(self, *args, **kwargs):
|
||||
super().create_bucket(*args, **kwargs)
|
||||
raise requests.exceptions.ConnectionError()
|
||||
|
||||
client = FailingClient()
|
||||
rq = RequestQueue(client) # type: ignore
|
||||
rq.connected = True
|
||||
|
||||
rq.register_bucket("test-bucket", "test-type")
|
||||
|
||||
assert rq.connected is False
|
||||
assert client.create_bucket_calls == [(("test-bucket", "test-type"), {})]
|
||||
Reference in New Issue
Block a user