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