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

This commit is contained in:
upstream-mirror
2026-09-16 00:20:39 +05:00
commit 96755ecd21
36 changed files with 5338 additions and 0 deletions

48
.github/workflows/build.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,74 @@
aw-client
=========
[![GitHub Actions badge](https://github.com/ActivityWatch/aw-client/workflows/Build/badge.svg)](https://github.com/ActivityWatch/aw-client/actions)
[![PyPI](https://img.shields.io/pypi/v/aw-client)](https://pypi.org/project/aw-client/)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Typechecking: Mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
Client library for ActivityWatch in Python.
Please see [the documentation][docs] for usage, and take a look at `examples/`.
- [Documentation][docs]
- [API Reference][apiref]
[docs]: https://docs.activitywatch.net/en/latest/
[apiref]: https://docs.activitywatch.net/en/latest/api/python.html#aw-client
## How to install
Install from pip: `pip install aw-client`
Install the latest version directly from github without cloning: `pip install git+https://github.com/ActivityWatch/aw-client.git`
To install from a cloned version:
- clone repo: `git clone https://github.com/ActivityWatch/aw-client.git`
- cd into the directory: `cd aw-client`
- run `poetry install` (will create a virtualenv, if none activated)
- If you don't want to use poetry you can also use `pip install .`, but that might not get the exact version of the dependencies (due to not reading the `poetry.lock` file).
## Usage
For the CLI:
```
$ aw-client --help
Usage: aw-client [OPTIONS] COMMAND [ARGS]...
CLI utility for aw-client to aid in interacting with the ActivityWatch
server
Options:
--host TEXT Address of host
--port INTEGER Port to use
-v, --verbose Verbosity
--testing Set to use testing ports by default
--help Show this message and exit.
Commands:
buckets List all buckets
canonical Query 'canonical events' for a single host (filtered,...
events Query events from bucket with ID `bucket_id`
heartbeat Send a heartbeat to bucket with ID `bucket_id` with JSON `data`
query Run a query in file at `path` on the server
report Generate an activity report
```
## Debugging
* Run python with `LOG_LEVEL=debug` to get additional debugging output
* If invalid events have been queued for submission, you may need to delete the file-based queues generated by this library
* To use the development version of this library use `aw-client = {path = "../aw-client" }` in `pyproject.toml`
## Examples
The [`examples/`](examples/) directory contains a couple of example scripts, including:
- [`time_spent_today.py`](examples/time_spent_today.py) - fetches all non-afk events and sums their duration to get the total active time for the day.
- [`working_hours.py`](https://github.com/ActivityWatch/aw-client/blob/master/examples/working_hours.py) - computes hours worked per day (matching a "work" category rule), and exports matching events to a JSON file (for auditing).
- [`load_dataframe.py`](https://github.com/ActivityWatch/aw-client/blob/master/examples/load_dataframe.py) - loads events from a host using a categorizing & AFK-filtering query, put result in a pandas dataframe, and export as CSV.
- [`merge_buckets.py`](examples/merge_buckets.py) - merges two buckets with non-intersecting events by moving all events from one into the other.
- [`redact_sensitive.py`](examples/redact_sensitive.py) - redact sensitive events.

3
aw_client/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .client import ActivityWatchClient
__all__ = ["ActivityWatchClient"]

3
aw_client/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
from aw_client import cli
cli.main()

89
aw_client/classes.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

334
aw_client/queries.py Normal file
View 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()

View 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
View 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()

View 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
View 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()

View 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()

View 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()

View 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()

View 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
View 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()

View 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

File diff suppressed because it is too large Load Diff

51
pyproject.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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"), {})]