snapshot of https://github.com/ActivityWatch/aw-server.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 3m6s
Lint / lint (push) Successful in 22s
Build / typecheck (ubuntu-latest, 3.9) (push) Successful in 1m49s
Lint / format (push) Successful in 42s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m31s

This commit is contained in:
upstream-mirror
2026-09-16 00:20:00 +05:00
commit 07fb811788
40 changed files with 4688 additions and 0 deletions

20
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
# Set update schedule for GitHub Actions
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
# Maintain dependencies for pip/poetry
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
# Maintain submodule versions
- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "weekly"

81
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Test on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
env:
RELEASE: false
SKIP_WEBUI: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macOS-latest]
python_version: [3.9]
steps:
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python_version }}
- name: Install dependencies
shell: bash
run: |
pip install poetry==1.3.2 # due to: https://github.com/python-poetry/poetry/issues/7611
python -m venv venv
source venv/bin/activate || source venv/Scripts/activate
make build
- name: Run tests
shell: bash
run: |
source venv/bin/activate || source venv/Scripts/activate
make test
- name: Report coverage
shell: bash
run: |
# allow failures since codecov servers can be grumpy
bash <(curl -s https://codecov.io/bash) || true
- name: Package
shell: bash
run: |
source venv/bin/activate || source venv/Scripts/activate
pip install pyinstaller
make package
typecheck:
runs-on: ${{ matrix.os }}
env:
RELEASE: false
SKIP_WEBUI: true
strategy:
matrix:
os: [ubuntu-latest]
python_version: [3.9]
steps:
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python_version }}
- name: Install dependencies
shell: bash
run: |
pip install poetry
python -m venv venv
source venv/bin/activate || source venv/Scripts/activate
make build
- name: Typecheck
shell: bash
run: |
source venv/bin/activate || source venv/Scripts/activate
make typecheck

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: "3 10 * * 0"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ python ]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{ matrix.language }}"

View File

@@ -0,0 +1,27 @@
name: Dependabot Auto-merge
# NOTE: This workflow relies on a Personal Access Token from the @ActivityWatchBot user
# See this issue for details: https://github.com/ridedott/merge-me-action/issues/1581
on:
workflow_run:
types:
- completed
workflows:
# List all required workflow names here.
- Build
permissions:
contents: write
pull-requests: read
jobs:
auto_merge:
name: Auto-merge
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success' && github.actor == 'dependabot[bot]'
steps:
- uses: ridedott/merge-me-action@v2
with:
GITHUB_TOKEN: ${{ secrets.AWBOT_GH_TOKEN }}

23
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Lint
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
# Keep lint reproducible; the floating action tag can adopt new default rules.
- uses: jpetrucciani/ruff-check@5839e3c65007bdb626c1f3362153e45be347654f # ruff 0.15.22
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: psf/black@stable

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
*.pyc
__pycache__
*.swp
*.egg-info
build
dist
static
.*cache
profiling
venv
# coverage stuff
.coverage
htmlcov
coverage.xml
coverage_html

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "aw-webui"]
path = aw-webui
url = https://github.com/ActivityWatch/aw-webui.git

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.

58
Makefile Normal file
View File

@@ -0,0 +1,58 @@
.PHONY: aw-webui build install test typecheck package clean
build: aw-webui
poetry install
aw-webui:
mkdir -p aw_server/static/
ifeq ($(SKIP_WEBUI),true) # Skip building webui if SKIP_WEBUI is true
@echo "Skipping building webui"
else
rm -rf aw_server/static/*
make --directory=aw-webui build DEV=$(DEV)
cp -r aw-webui/dist/* aw_server/static/
# Needed for https://github.com/ActivityWatch/activitywatch/pull/274, works around https://github.com/pypa/pip/issues/6279
# https://github.com/ActivityWatch/activitywatch/pull/367 Other solutions have been tried but did not actually work.
# If you aren't sure windows long paths are working, don't remove this
rm -rf aw-webui/node_modules/.cache
endif
install:
cp misc/aw-server.service /usr/lib/systemd/user/aw-server.service
test:
@# Note that extensive integration tests are also run in the bundle repo,
@# for both aw-server and aw-server-rust, but without code coverage.
python -c 'import aw_server'
python -m pytest tests/test_server.py tests/test_profile.py tests/test_profile_config.py
typecheck:
python -m mypy aw_server tests --ignore-missing-imports
package: bump-version
rm -rf dist
pyinstaller aw-server.spec --clean --noconfirm
PYFILES=$(shell find . -name '*.py')
lint:
ruff check .
lint-fix:
poetry run pyupgrade --py38-plus --exit-zero-even-if-changed $(PYFILES)
ruff check --fix .
format:
black .
bump-version:
@# make sure to pull tags in parent repo before running this
poetry run python -m aw_server.__about__
VERSION=$$(grep -oP '__version__ = "v\K[^"]+' aw_server/__about__.py | head -n1); echo $$VERSION; poetry version $$VERSION
clean:
rm -rf build dist
rm -rf aw_server/__pycache__
rm -rf aw_server/static/*
pip3 uninstall -y aw_server
make --directory=aw-webui clean

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
aw-server
============
[![Build Status](https://github.com/ActivityWatch/aw-server/workflows/Build/badge.svg?branch=master)](https://github.com/ActivityWatch/aw-server/actions?query=workflow%3ABuild+branch%3Amaster)
[![Coverage Status](https://codecov.io/gh/ActivityWatch/aw-server/branch/master/graph/badge.svg)](https://codecov.io/gh/ActivityWatch/aw-server)
ActivityWatch server, for secure storage and retrieval of all your Quantified Self data.
**Note:** In the future this will no longer be the default server used by ActivityWatch, instead we will be using [aw-server-rust](https://github.com/ActivityWatch/aw-server-rust/). The fate of this repo is uncertain, but it will likely be around and maintained for a while (as a reference implementation, for prototyping, etc).
## Build and Install
To install program and dependencies, Poetry is required. You'll want to activate a `venv` before building.
```bash
make build
```
The `aw-server` binary should now be available to you in your PATH (if it is set correctly).
## Usage
Run aw-server:
```bash
aw-server
```
## Development
If you want to run aw-server in development, you probably want to run a
development instance beside your personal (stable) instance. You can do
this by giving aw-server the `--testing` flag. This will start the server
on another port and use a separate datastore.
```bash
aw-server --testing
```
## API Examples
You can also get a very decent API browser by browsing to `localhost:5600` after starting the server.
There are also some API examples in the [documentation](https://docs.activitywatch.net/en/latest/api.html).

3
__main__.py Normal file
View File

@@ -0,0 +1,3 @@
import aw_server
aw_server.main()

48
aw-server.spec Normal file
View File

@@ -0,0 +1,48 @@
# -*- mode: python -*-
# vi: set ft=python :
import os
import aw_core
import flask_restx
aw_core_path = os.path.dirname(aw_core.__file__)
restx_path = os.path.dirname(flask_restx.__file__)
name = "aw-server"
block_cipher = None
a = Analysis(
["__main__.py"],
pathex=[],
binaries=None,
datas=[
("aw_server/static", "aw_server/static"),
(os.path.join(restx_path, "templates"), "flask_restx/templates"),
(os.path.join(restx_path, "static"), "flask_restx/static"),
(os.path.join(aw_core_path, "schemas"), "aw_core/schemas"),
],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
exclude_binaries=True,
name=name,
contents_directory=".",
debug=False,
strip=False,
upx=True,
console=True,
)
coll = COLLECT(
exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, name="aw-server"
)

139
aw_server/__about__.py Normal file
View File

@@ -0,0 +1,139 @@
import subprocess
import re
import logging
from typing import Optional
from pathlib import Path
from importlib.metadata import PackageNotFoundError, version as get_version
logger = logging.getLogger(__name__)
# TODO: Remove reliance on
basever = "v0.12"
srcpath = Path(__file__).absolute().parent
projectpath = srcpath.parent
bundlepath = projectpath.parent # the ActivityWatch bundle repo, in some circumstances
# This line set by script when run (metaprogramming)
__version__ = "v0.13.2"
def get_rev():
p = subprocess.run(
"git rev-parse --short HEAD",
shell=True,
capture_output=True,
encoding="utf8",
cwd=workdir,
)
return p.stdout.strip()
def get_tag_exact():
rev = get_rev()
p = subprocess.run(
["git", "describe", "--tags", "--abbrev=0", "--exact-match", rev],
capture_output=True,
encoding="utf8",
cwd=workdir,
)
if p.stderr:
if (
"no tag exactly matches" in p.stderr
or "No names found, cannot describe anything" in p.stderr
):
pass
else:
raise Exception(p.stderr)
else:
return p.stdout.strip()
def get_tag_latest():
try:
tag = subprocess.check_output(
["git", "describe", "--abbrev=0", "--tags"], encoding="utf8", cwd=workdir
).strip()
if not tag:
return
basever = tag
except subprocess.CalledProcessError as e:
print(e)
return
try:
return f"{basever}.dev+{get_rev()}"
except Exception as e:
# Unable to get current commit with git
logger.exception(e)
return None
def detect_version_git() -> Optional[str]:
# Returns an exact tag if there is one, or an approximate one + commit identifier
version = get_tag_exact()
if version:
return version
version = get_tag_latest()
if version:
return version
return None
def detect_version_pkg() -> Optional[str]:
try:
return f"v{get_version('aw-server')}.dev+{get_rev()}"
except PackageNotFoundError:
return None
def detect_version_poetry() -> Optional[str]:
"""Detect version from pyproject.toml file, with `poetry version -s`"""
try:
basever = subprocess.check_output(
["poetry", "version", "-s"], encoding="utf8", cwd=workdir
).strip()
except subprocess.CalledProcessError as e:
print(e)
return None
return f"v{basever}.dev+{get_rev()}"
def detect_version():
for detectfunc in (detect_version_git, detect_version_pkg):
version = detectfunc()
if version:
return version
return basever + ".dev+unknown"
def assign_static_version():
"""Self-modifies the current script to lock in version"""
version = detect_version()
with open(__file__) as f:
versionline = f'\n__version__ = "{version}"'
data = re.sub(r"\n__version__ = [^\n;]+", versionline, f.read())
with open(__file__, "w") as f:
f.write(data)
print(f"Set versionline: {versionline.strip()}")
if __name__ == "__main__":
# Use the bundle repo as working repo, if it exists
if (bundlepath / ".git").exists():
print("Found bundle repo in parent dir, using for calls to git")
workdir = bundlepath
else:
workdir = projectpath
assign_static_version()
print("By method:")
print(f" git: {detect_version_git()}")
print(f" pkg: {detect_version_pkg()}")

11
aw_server/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
import logging as _logging
logger = _logging.getLogger(__name__)
from .__about__ import __version__
from .main import main
__all__ = [
"__version__",
"main",
]

3
aw_server/__main__.py Normal file
View File

@@ -0,0 +1,3 @@
import aw_server
aw_server.main()

388
aw_server/api.py Normal file
View File

@@ -0,0 +1,388 @@
import functools
import json
import logging
from datetime import datetime
from pathlib import Path
from socket import gethostname
from typing import (
Any,
Dict,
List,
Optional,
)
from uuid import uuid4
import iso8601
from aw_core.dirs import get_data_dir
from aw_core.log import get_log_file_path
from aw_core.models import Event
from aw_query import query2
from aw_query.exceptions import QueryException
from aw_transform import heartbeat_merge
from .__about__ import __version__
from .exceptions import NotFound
from .profile import profile_from_env
from .settings import Settings
logger = logging.getLogger(__name__)
def get_device_id() -> str:
path = Path(get_data_dir("aw-server")) / "device_id"
if path.exists():
with open(path) as f:
return f.read()
else:
uuid = str(uuid4())
with open(path, "w") as f:
f.write(uuid)
return uuid
def check_bucket_exists(f):
@functools.wraps(f)
def g(self, bucket_id, *args, **kwargs):
if bucket_id not in self.db.buckets():
raise NotFound("NoSuchBucket", f"There's no bucket named {bucket_id}")
return f(self, bucket_id, *args, **kwargs)
return g
class ServerAPI:
def __init__(self, db, testing) -> None:
self.db = db
self.settings = Settings(testing)
self.testing = testing
self.profile = profile_from_env(testing=testing)
self.last_event = {} # type: dict
def get_info(self) -> Dict[str, Any]:
"""Get server info"""
payload = {
"hostname": gethostname(),
"version": __version__,
"testing": self.testing,
"device_id": get_device_id(),
"profile": self.profile,
}
return payload
def get_buckets(self) -> Dict[str, Dict]:
"""Get dict {bucket_name: Bucket} of all buckets"""
logger.debug("Received get request for buckets")
buckets = self.db.buckets()
for b in buckets:
# TODO: Move this code to aw-core?
last_events = self.db[b].get(limit=1)
if len(last_events) > 0:
last_event = last_events[0]
last_updated = last_event.timestamp + last_event.duration
buckets[b]["last_updated"] = last_updated.isoformat()
return buckets
@check_bucket_exists
def get_bucket_metadata(self, bucket_id: str) -> Dict[str, Any]:
"""Get metadata about bucket."""
bucket = self.db[bucket_id]
return bucket.metadata()
@check_bucket_exists
def export_bucket(self, bucket_id: str) -> Dict[str, Any]:
"""Export a bucket to a dataformat consistent across versions, including all events in it."""
bucket = self.get_bucket_metadata(bucket_id)
bucket["events"] = self.get_events(bucket_id, limit=-1)
# Scrub event IDs
for event in bucket["events"]:
del event["id"]
return bucket
def export_all(self) -> Dict[str, Any]:
"""Exports all buckets and their events to a format consistent across versions"""
buckets = self.get_buckets()
exported_buckets = {}
for bid in buckets.keys():
exported_buckets[bid] = self.export_bucket(bid)
return exported_buckets
def import_bucket(self, bucket_data: Any):
bucket_id = bucket_data["id"]
logger.info(f"Importing bucket {bucket_id}")
# TODO: Check that bucket doesn't already exist
self.db.create_bucket(
bucket_id,
type=bucket_data["type"],
client=bucket_data["client"],
hostname=bucket_data["hostname"],
created=(
bucket_data["created"]
if isinstance(bucket_data["created"], datetime)
else iso8601.parse_date(bucket_data["created"])
),
)
# scrub IDs from events
# (otherwise causes weird bugs with no events seemingly imported when importing events exported from aw-server-rust, which contains IDs)
for event in bucket_data["events"]:
if "id" in event:
del event["id"]
self.create_events(
bucket_id,
[Event(**e) if isinstance(e, dict) else e for e in bucket_data["events"]],
)
def import_all(self, buckets: Dict[str, Any]):
for bid, bucket in buckets.items():
self.import_bucket(bucket)
def create_bucket(
self,
bucket_id: str,
event_type: str,
client: str,
hostname: str,
created: Optional[datetime] = None,
data: Optional[Dict[str, Any]] = None,
) -> bool:
"""
Create a bucket.
If hostname is "!local", the hostname and device_id will be set from the server info.
This is useful for watchers which are known/assumed to run locally but might not know their hostname (like aw-watcher-web).
Returns True if successful, otherwise false if a bucket with the given ID already existed.
"""
if created is None:
created = datetime.now()
if bucket_id in self.db.buckets():
return False
if hostname == "!local":
info = self.get_info()
if data is None:
data = {}
hostname = info["hostname"]
data["device_id"] = info["device_id"]
self.db.create_bucket(
bucket_id,
type=event_type,
client=client,
hostname=hostname,
created=created,
data=data,
)
return True
@check_bucket_exists
def update_bucket(
self,
bucket_id: str,
event_type: Optional[str] = None,
client: Optional[str] = None,
hostname: Optional[str] = None,
data: Optional[Dict[str, Any]] = None,
) -> None:
"""Update bucket metadata"""
self.db.update_bucket(
bucket_id,
type=event_type,
client=client,
hostname=hostname,
data=data,
)
return None
@check_bucket_exists
def delete_bucket(self, bucket_id: str) -> None:
"""Delete a bucket"""
self.db.delete_bucket(bucket_id)
logger.debug(f"Deleted bucket '{bucket_id}'")
return None
@check_bucket_exists
def get_event(
self,
bucket_id: str,
event_id: int,
) -> Optional[Event]:
"""Get a single event from a bucket"""
logger.debug(
f"Received get request for event {event_id} in bucket '{bucket_id}'"
)
event = self.db[bucket_id].get_by_id(event_id)
return event.to_json_dict() if event else None
@check_bucket_exists
def get_events(
self,
bucket_id: str,
limit: int = -1,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> List[Event]:
"""Get events from a bucket"""
logger.debug(f"Received get request for events in bucket '{bucket_id}'")
if limit is None: # Let limit = None also mean "no limit"
limit = -1
events = [
event.to_json_dict() for event in self.db[bucket_id].get(limit, start, end)
]
return events
@check_bucket_exists
def create_events(self, bucket_id: str, events: List[Event]) -> List[Event]:
"""Create events for a bucket. Can handle both single events and multiple ones.
Always returns a list of inserted events (matching aw-server-rust behavior).
For single events, the returned event includes the server-assigned ID.
For bulk inserts, returns empty list (events may not have IDs without a response-SQL roundtrip).
"""
if len(events) == 1:
# Pass as single Event so Bucket.insert uses insert_one (returns Event with ID)
inserted = self.db[bucket_id].insert(events[0])
return [inserted]
else:
self.db[bucket_id].insert(events)
return []
@check_bucket_exists
def get_eventcount(
self,
bucket_id: str,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> int:
"""Get eventcount from a bucket"""
logger.debug(f"Received get request for eventcount in bucket '{bucket_id}'")
return self.db[bucket_id].get_eventcount(start, end)
@check_bucket_exists
def delete_event(self, bucket_id: str, event_id) -> bool:
"""Delete a single event from a bucket"""
return self.db[bucket_id].delete(event_id)
@check_bucket_exists
def heartbeat(self, bucket_id: str, heartbeat: Event, pulsetime: float) -> Event:
"""
Heartbeats are useful when implementing watchers that simply keep
track of a state, how long it's in that state and when it changes.
A single heartbeat always has a duration of zero.
If the heartbeat was identical to the last (apart from timestamp), then the last event has its duration updated.
If the heartbeat differed, then a new event is created.
Such as:
- Active application and window title
- Example: aw-watcher-window
- Currently open document/browser tab/playing song
- Example: wakatime
- Example: aw-watcher-web
- Example: aw-watcher-spotify
- Is the user active/inactive?
Send an event on some interval indicating if the user is active or not.
- Example: aw-watcher-afk
Inspired by: https://wakatime.com/developers#heartbeats
"""
logger.debug(
"Received heartbeat in bucket '{}'\n\ttimestamp: {}, duration: {}, pulsetime: {}\n\tdata: {}".format(
bucket_id,
heartbeat.timestamp,
heartbeat.duration,
pulsetime,
heartbeat.data,
)
)
# The endtime here is set such that in the event that the heartbeat is older than an
# existing event we should try to merge it with the last event before the heartbeat instead.
# FIXME: This (the endtime=heartbeat.timestamp) gets rid of the "heartbeat was older than last event"
# warning and also causes a already existing "newer" event to be overwritten in the
# replace_last call below. This is problematic.
# Solution: This could be solved if we were able to replace arbitrary events.
# That way we could double check that the event has been applied
# and if it hasn't we simply replace it with the updated counterpart.
last_event = None
if bucket_id not in self.last_event:
last_events = self.db[bucket_id].get(limit=1)
if len(last_events) > 0:
last_event = last_events[0]
else:
last_event = self.last_event[bucket_id]
if last_event:
if last_event.data == heartbeat.data:
merged = heartbeat_merge(last_event, heartbeat, pulsetime)
if merged is not None:
# Heartbeat was merged into last_event
logger.debug(
"Received valid heartbeat, merging. (bucket: {})".format(
bucket_id
)
)
self.last_event[bucket_id] = merged
self.db[bucket_id].replace_last(merged)
return merged
else:
logger.info(
"Received heartbeat after pulse window, inserting as new event. (bucket: {})".format(
bucket_id
)
)
else:
logger.debug(
"Received heartbeat with differing data, inserting as new event. (bucket: {})".format(
bucket_id
)
)
else:
logger.info(
"Received heartbeat, but bucket was previously empty, inserting as new event. (bucket: {})".format(
bucket_id
)
)
self.db[bucket_id].insert(heartbeat)
self.last_event[bucket_id] = heartbeat
return heartbeat
def query2(self, name, query, timeperiods, cache):
result = []
for timeperiod in timeperiods:
period = timeperiod.split("/")[
:2
] # iso8601 timeperiods are separated by a slash
if len(period) != 2:
raise QueryException(
f"Invalid timeperiod '{timeperiod}': expected two ISO8601 "
"datetimes separated by a slash (start/end)"
)
try:
starttime = iso8601.parse_date(period[0])
endtime = iso8601.parse_date(period[1])
except iso8601.ParseError as e:
raise QueryException(f"Invalid timeperiod '{timeperiod}': {e}")
query = "".join(query)
result.append(query2.query(name, query, starttime, endtime, self.db))
return result
# TODO: Right now the log format on disk has to be JSON, this is hard to read by humans...
def get_log(self):
"""Get the server log in json format"""
payload = []
with open(get_log_file_path()) as log_file:
for line in log_file.readlines()[::-1]:
payload.append(json.loads(line))
return payload, 200
def get_setting(self, key):
"""Get a setting"""
return self.settings.get(key, None)
def set_setting(self, key, value):
"""Set a setting"""
self.settings[key] = value
return value

45
aw_server/config.py Normal file
View File

@@ -0,0 +1,45 @@
from aw_core.config import load_config_toml
from .profile import DEFAULT_PROFILE, is_testing
default_config = """
[server]
host = "localhost"
port = "5600"
storage = "peewee"
cors_origins = ""
[server.custom_static]
[server-testing]
host = "localhost"
port = "5666"
storage = "peewee"
cors_origins = ""
[server-testing.custom_static]
""".strip()
def load_config():
"""Load aw-server.toml from the current profile's config dir.
Must be called *after* ``export_profile`` so aw-core dirs see
``AW_PROFILE`` and isolate the file from other instances.
"""
return load_config_toml("aw-server", default_config)
def config_section(profile: str) -> str:
"""TOML section for this profile: ``server`` or ``server-<profile>``."""
return "server" if profile == DEFAULT_PROFILE else f"server-{profile}"
def default_port(profile: str) -> int:
"""Built-in port: 5666 for testing, 5600 otherwise.
Named profiles take ``port`` from their own isolated config (the
research build bakes 5667 into that file). There is no hash-to-port
table — a custom profile without a port set collides with default.
"""
return 5666 if is_testing(profile) else 5600

View File

@@ -0,0 +1,55 @@
"""
Contains endpoints, as well as utility functions for custom static content.
NOTE: Experimental, not (yet) implemented in aw-server-rust.
Idea: Allow custom watchers to extend the Web UI and to write custom visualizations completely independently and free.
Usage for the watcher developer:
- You can use Vanilla JavaScript, Vue, React, ... as long as you have static content at the end.
- Register your watcher visualization in the config:
[server.custom_static]
aw-watcher-example=/home/user/path/to/static_dir/
- Your custom static content automatically gets the data for the requested time span as GET parameter called "data".
Another parameter called "view" can be used if you want to create multiple visualizations for a single watcher.
- You can show your custom visualizations in the official Activity Watch UI using the "Custom Watcher View"
See https://github.com/ActivityWatch/activitywatch/issues/453#issuecomment-910567848
"""
from flask import (
Blueprint,
escape,
jsonify,
send_from_directory,
)
def get_custom_static_blueprint(custom_static_directories):
custom_static_blueprint = Blueprint("custom_static", __name__, url_prefix="/")
@custom_static_blueprint.route("pages/")
def custom_static_supported_pages():
"""Serves a list of all watchers that are supported / were registered successfully"""
return jsonify(list(custom_static_directories.keys()))
@custom_static_blueprint.route(
"pages/<string:name>/", defaults={"path": "index.html"}
)
@custom_static_blueprint.route("pages/<string:name>/<path:path>")
def custom_static_pages(name: str, path: str):
"""Serves the custom static content"""
if name in custom_static_directories:
return send_from_directory(custom_static_directories[name], path)
else:
return (
f"Static content: {escape(path)} of watcher: {escape(name)} not found!",
404,
)
return custom_static_blueprint

19
aw_server/exceptions.py Normal file
View File

@@ -0,0 +1,19 @@
import werkzeug.exceptions
class BadRequest(werkzeug.exceptions.BadRequest):
def __init__(self, type: str, message: str) -> None:
super().__init__(message)
self.type = type
class NotFound(werkzeug.exceptions.NotFound):
def __init__(self, type: str, message: str) -> None:
super().__init__(message)
self.type = type
class Unauthorized(werkzeug.exceptions.Unauthorized):
def __init__(self, type: str, message: str) -> None:
super().__init__(message)
self.type = type

134
aw_server/extension_cors.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Endpoint scoping for the moz-extension:// CORS wildcard.
Firefox assigns every extension its own random origin, so aw-server uses
``moz-extension://*`` to allow aw-watcher-web without knowing the ID in advance.
That wildcard also permits every other installed extension to reach the full API —
including ``/api/0/export``, ``/api/0/import``, queries, and settings — with no
host permission and therefore no install-time browser prompt naming ActivityWatch.
This module registers a ``before_request`` hook that restricts wildcard-matched
extension origins to the three endpoints aw-watcher-web actually needs:
GET /api/0/info — hostname/version detection
POST /api/0/buckets/aw-watcher-web-<id> — ensure its bucket exists
POST /api/0/buckets/aw-watcher-web-<id>/heartbeat — heartbeat recording
All other paths return 403 before the handler executes. flask-cors CORS headers are
still added by the after_request hook, but the 403 status prevents JavaScript from
treating the response as a successful cross-origin fetch (and more importantly, the
server side never processes the request).
User-configured origins (``cors_origins`` / ``cors_regex`` in config) are exempted:
those are explicit opt-ins by the server owner, unlike the built-in wildcard.
Path matching uses split segments rather than the raw path string to avoid
percent-encoding bypasses — the same bug class as aw-server-rust#588 and #636.
See also: ActivityWatch/aw-server-rust#637 (the Rust sibling of this fix).
"""
import logging
import re
from typing import List, Optional
from flask import Flask, abort, request
logger = logging.getLogger(__name__)
_EXTENSION_SCHEME = "moz-extension://"
def register(app: Flask, user_origins: List[str]) -> None:
"""Register the extension CORS scope hook on *app*.
*user_origins* — origins the user configured explicitly (captured before
the built-in ``moz-extension://*`` wildcard is appended). These are
explicit opt-ins and bypass the scope narrowing.
"""
@app.before_request
def _restrict_extension_cors() -> Optional[object]:
origin = request.headers.get("Origin", "")
if not origin.lower().startswith(_EXTENSION_SCHEME):
return None # not a moz-extension origin — let flask-cors handle it
# flask-cors 4 treats strings containing regex metacharacters as regular
# expressions and otherwise compares them case-insensitively. Keep this
# exemption consistent with that contract.
for pattern in user_origins:
if _matches_configured_origin(origin, pattern):
return None
segments = [s for s in request.path.split("/") if s]
if _is_allowed(request.method, segments):
return None
abort(403)
def _matches_configured_origin(origin: str, pattern: str) -> bool:
"""Match an origin using flask-cors 4's configured-origin semantics."""
regex_chars = "*\\]?$^[()"
if any(char in pattern for char in regex_chars):
try:
return re.match(pattern, origin, flags=re.IGNORECASE) is not None
except re.error:
return False
return origin.lower() == pattern.lower()
def _is_allowed(method: str, segments: List[str]) -> bool:
"""Return True if *method* + *segments* is a path aw-watcher-web actually uses.
For OPTIONS preflights the path is checked against the set of allowed paths
(not the Access-Control-Request-Method header) to keep the logic simple while
still blocking preflights for disallowed paths such as ``/api/0/export``.
"""
if method == "OPTIONS":
return _is_allowed_path(segments)
# GET /api/0/info
if method == "GET" and segments == ["api", "0", "info"]:
return True
# POST /api/0/buckets/aw-watcher-web-<id>
if (
method == "POST"
and len(segments) == 4
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
):
return True
# POST /api/0/buckets/aw-watcher-web-<id>/heartbeat
if (
method == "POST"
and len(segments) == 5
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
and segments[4] == "heartbeat"
):
return True
return False
def _is_allowed_path(segments: List[str]) -> bool:
"""Return True if *segments* is on any allowed endpoint (for OPTIONS checks)."""
if segments == ["api", "0", "info"]:
return True
if (
len(segments) == 4
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
):
return True
if (
len(segments) == 5
and segments[:3] == ["api", "0", "buckets"]
and segments[3].startswith("aw-watcher-web-")
and segments[4] == "heartbeat"
):
return True
return False

25
aw_server/log.py Normal file
View File

@@ -0,0 +1,25 @@
import logging
from werkzeug import serving
class FlaskLogHandler(serving.WSGIRequestHandler):
def __init__(self, *args):
self.logger = logging.getLogger("flask")
super().__init__(*args)
def log(self, levelname, message, *args):
msg = args[0]
code = int(args[1])
if code in [200, 304]:
levelname = "debug"
# type = "debug"
if levelname == "info":
levelno = logging.INFO
elif levelname == "debug":
levelno = logging.DEBUG
else:
raise Exception("Unknown level " + type)
self.logger.log(levelno, f"{code} ({self.address_string()}): {msg}")

176
aw_server/main.py Normal file
View File

@@ -0,0 +1,176 @@
import logging
import sys
from aw_core.log import setup_logging
from aw_datastore import get_storage_methods
from . import __version__
from .config import config_section, default_port, load_config
from .profile import (
DEFAULT_PROFILE,
export_profile,
is_testing,
resolve_profile,
)
from .server import _start
logger = logging.getLogger(__name__)
def main():
"""Called from the executable and __main__.py"""
settings, storage_method = parse_settings()
# FIXME: The LogResource API endpoint relies on the log being in JSON format
# at the path specified by aw_core.log.get_log_file_path(). We probably want
# to write the LogResource API so that it does not depend on any physical file
# but instead add a logging handler that it can use privately.
# That is why log_file_json=True currently.
# UPDATE: The LogResource API is no longer available so log_file_json is now False.
setup_logging(
"aw-server",
testing=settings.testing,
verbose=settings.verbose,
log_stderr=True,
log_file=True,
)
logger.info(f"Using storage method: {settings.storage}")
if settings.testing:
logger.info("Will run in testing mode")
if settings.profile != DEFAULT_PROFILE:
logger.info(f"Running with profile: {settings.profile}")
if settings.custom_static:
logger.info(f"Using custom_static: {settings.custom_static}")
logger.info("Starting up...")
_start(
host=settings.host,
port=settings.port,
testing=settings.testing,
storage_method=storage_method,
cors_origins=settings.cors_origins,
custom_static=settings.custom_static,
)
def parse_settings():
import argparse
""" CLI Arguments """
parser = argparse.ArgumentParser(description="Starts an ActivityWatch server")
parser.add_argument(
"--testing",
action="store_true",
help="Run aw-server in testing mode using different ports and database (alias for --profile testing)",
)
parser.add_argument(
"--profile",
dest="profile",
default=None,
help="Named instance profile (data, config, port and settings are isolated). --testing is an alias for --profile testing.",
)
parser.add_argument("--verbose", action="store_true", help="Be chatty.")
parser.add_argument(
"--version",
action="store_true",
help="Print version and quit",
)
parser.add_argument(
"--log-json", action="store_true", help="Output the logs in JSON format"
)
parser.add_argument(
"--host", dest="host", help="Which host address to bind the server to"
)
parser.add_argument(
"--port", dest="port", type=int, help="Which port to run the server on"
)
parser.add_argument(
"--storage",
dest="storage",
help="The method to use for storing data. Some methods (such as MongoDB) require specific Python packages to be available (in the MongoDB case: pymongo)",
)
parser.add_argument(
"--cors-origins",
dest="cors_origins",
help="CORS origins to allow (as a comma separated list)",
)
parser.add_argument(
"--custom-static",
dest="custom_static",
help="The custom static directories. Format: watcher_name=path,watcher_name2=path2,...",
)
args = parser.parse_args()
if args.version:
print(__version__)
sys.exit(0)
try:
profile = resolve_profile(args.profile, args.testing)
except ValueError as e:
parser.error(str(e))
# Export before loading config so aw-core dirs isolate this profile.
export_profile(profile)
testing = is_testing(profile)
""" Parse config file """
config = load_config()
section = config_section(profile)
if section not in config:
if profile not in (DEFAULT_PROFILE,):
logger.warning(
"Profile %s has no [%s] section, falling back to [server] "
"(port %s may collide with the default instance)",
profile,
section,
default_port(profile),
)
section = "server"
settings = argparse.Namespace()
settings.host = config[section]["host"]
settings.port = int(config[section]["port"])
settings.storage = config[section]["storage"]
settings.cors_origins = config[section]["cors_origins"]
settings.custom_static = dict(config[section]["custom_static"])
settings.profile = profile
settings.testing = testing
""" If a argument is not none, override the config value """
for key, value in vars(args).items():
if key in ("testing", "profile"):
# Resolved above; --profile testing must keep testing=True
# even when the raw --testing flag was absent.
continue
if value is not None:
if key == "custom_static":
settings.custom_static = parse_str_to_dict(value)
else:
vars(settings)[key] = value
settings.cors_origins = [o for o in settings.cors_origins.split(",") if o]
storage_methods = get_storage_methods()
storage_method = storage_methods[settings.storage]
return settings, storage_method
def parse_str_to_dict(str_value):
"""Parses a dict from a string in format: key=value,key2=value2,..."""
output = dict()
key_value_pairs = str_value.split(",")
for pair in key_value_pairs:
pair_split = pair.split("=")
if len(pair_split) != 2:
raise ValueError(f"Cannot parse key value pair: {pair}")
key, value = pair_split
output[key] = value
return output

104
aw_server/profile.py Normal file
View File

@@ -0,0 +1,104 @@
"""Profile resolution for aw-server.
A *profile* names an isolated ActivityWatch instance (data, config, port,
settings). `default` is the ordinary install, `testing` is what `--testing`
has always meant, and any other name (for example `research`) is a sibling
instance that can run at the same time as the others.
The carrier is the ``AW_PROFILE`` environment variable. aw-core's
``_get_appname()`` suffixes the platformdirs root when it is set to a
non-empty value, so exporting the profile here isolates dirs for this
process and anything it spawns — without threading a flag through the
datastore, settings, or Flask stack.
Kept in sync with aw-qt's ``aw_qt/profile.py`` (same validation rule as
aw-server-rust). One intentional difference: the default profile *unsets*
``AW_PROFILE`` instead of setting it to ``"default"``. aw-core treats any
non-empty value as a suffix, so ``AW_PROFILE=default`` would resolve to
``activitywatch-default`` and orphan an existing install.
Testing-root note (ActivityWatch/activitywatch#1399): python aw-core#149
maps ``AW_PROFILE=testing`` to ``activitywatch-testing``. The rust
isolation branch keeps testing on the bare ``activitywatch`` root so
existing ``sqlite-testing.db`` files are not orphaned. This module follows
the already-merged python dirs contract; unifying the rust testing root
is a follow-up on that isolation PR, not something to special-case here.
"""
import os
import re
from typing import Optional
DEFAULT_PROFILE = "default"
TESTING_PROFILE = "testing"
#: Same rule as aw-server-rust's `validate_profile`: lowercase alphanumeric
#: plus `-`/`_`, at most 32 chars, so a profile is always a safe path segment.
PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
ENV_VAR = "AW_PROFILE"
def validate_profile(profile: str) -> str:
"""Return the profile unchanged, or raise ValueError if it is not usable."""
if not PROFILE_RE.match(profile):
raise ValueError(
f"Invalid profile name {profile!r}: expected lowercase alphanumeric "
"with '-' or '_', at most 32 characters"
)
return profile
def resolve_profile(profile: Optional[str], testing: bool) -> str:
"""Resolve the effective profile from the CLI flags.
``--testing`` is an alias for ``--profile testing``; passing both is only
an error if they disagree.
"""
if profile is None:
return TESTING_PROFILE if testing else DEFAULT_PROFILE
profile = validate_profile(profile)
if testing and profile != TESTING_PROFILE:
raise ValueError(
f"--testing conflicts with --profile {profile}: --testing is an "
f"alias for --profile {TESTING_PROFILE}"
)
return profile
def is_testing(profile: str) -> bool:
return profile == TESTING_PROFILE
def profile_suffix(profile: str) -> str:
"""Filename suffix for a profile (``""``, ``"-testing"``, ``"-research"``)."""
return "" if profile == DEFAULT_PROFILE else f"-{profile}"
def profile_from_env(testing: bool = False) -> str:
"""Read the profile the process was started with.
Falls back to the `--testing` bool for callers that only track that, so
behaviour is unchanged when no profile was set.
"""
profile = os.environ.get(ENV_VAR)
if not profile:
return TESTING_PROFILE if testing else DEFAULT_PROFILE
try:
return validate_profile(profile)
except ValueError:
return TESTING_PROFILE if testing else DEFAULT_PROFILE
def export_profile(profile: str) -> None:
"""Publish the profile to this process and its children.
The default profile leaves ``AW_PROFILE`` unset so aw-core keeps the
bare ``activitywatch`` root. Named profiles (including ``testing``)
set the env var; children inherit it without a CLI flag.
"""
if profile == DEFAULT_PROFILE:
os.environ.pop(ENV_VAR, None)
else:
os.environ[ENV_VAR] = profile

0
aw_server/py.typed Normal file
View File

412
aw_server/rest.py Normal file
View File

@@ -0,0 +1,412 @@
import json
import traceback
from functools import wraps
from threading import Lock
from typing import Dict
import iso8601
from aw_core import schema
from aw_core.models import Event
from aw_query.exceptions import QueryException
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
request,
)
from flask_restx import Api, Resource, fields
from . import logger
from .api import ServerAPI
from .exceptions import BadRequest, Unauthorized
def host_header_check(f):
"""
Protects against DNS rebinding attacks (see https://github.com/ActivityWatch/activitywatch/security/advisories/GHSA-v9fg-6g9j-h4x4)
Some discussion in Syncthing how they do it: https://github.com/syncthing/syncthing/issues/4819
"""
@wraps(f)
def decorator(*args, **kwargs):
server_host = current_app.config["HOST"]
req_host = request.headers.get("host", None)
if server_host == "0.0.0.0":
logger.warning(
"Server is listening on 0.0.0.0, host header check is disabled (potential security issue)."
)
elif req_host is None:
return {"message": "host header is missing"}, 400
else:
if req_host.split(":")[0] not in ["localhost", "127.0.0.1", server_host]:
return {"message": f"host header is invalid (was {req_host})"}, 400
return f(*args, **kwargs)
return decorator
blueprint = Blueprint("api", __name__, url_prefix="/api")
api = Api(blueprint, doc="/", decorators=[host_header_check])
# Loads event and bucket schema from JSONSchema in aw_core
event = api.schema_model("Event", schema.get_json_schema("event"))
bucket = api.schema_model("Bucket", schema.get_json_schema("bucket"))
buckets_export = api.schema_model("Export", schema.get_json_schema("export"))
# TODO: Construct all the models from JSONSchema?
# A downside to contructing from JSONSchema: flask-restplus does not have marshalling support
info = api.model(
"Info",
{
"hostname": fields.String(),
"version": fields.String(),
"testing": fields.Boolean(),
"device_id": fields.String(),
"profile": fields.String(),
},
)
create_bucket = api.model(
"CreateBucket",
{
"client": fields.String(required=True),
"type": fields.String(required=True),
"hostname": fields.String(required=True),
},
)
update_bucket = api.model(
"UpdateBucket",
{
"client": fields.String(required=False),
"type": fields.String(required=False),
"hostname": fields.String(required=False),
"data": fields.String(required=False),
},
)
query = api.model(
"Query",
{
"timeperiods": fields.List(
fields.String, required=True, description="List of periods to query"
),
"query": fields.List(
fields.String, required=True, description="String list of query statements"
),
},
)
def copy_doc(api_method):
"""Decorator that copies another functions docstring to the decorated function.
Used to copy the docstrings in ServerAPI over to the flask-restplus Resources.
(The copied docstrings are then used by flask-restplus/swagger)"""
def decorator(f):
f.__doc__ = api_method.__doc__
return f
return decorator
# SERVER INFO
@api.route("/0/info")
class InfoResource(Resource):
@api.marshal_with(info)
@copy_doc(ServerAPI.get_info)
def get(self) -> Dict[str, Dict]:
return current_app.api.get_info()
# BUCKETS
@api.route("/0/buckets/")
class BucketsResource(Resource):
# TODO: Add response marshalling/validation
@copy_doc(ServerAPI.get_buckets)
def get(self) -> Dict[str, Dict]:
return current_app.api.get_buckets()
@api.route("/0/buckets/<string:bucket_id>")
class BucketResource(Resource):
@api.doc(model=bucket)
@copy_doc(ServerAPI.get_bucket_metadata)
def get(self, bucket_id):
return current_app.api.get_bucket_metadata(bucket_id)
@api.expect(create_bucket)
@copy_doc(ServerAPI.create_bucket)
def post(self, bucket_id):
data = request.get_json()
bucket_created = current_app.api.create_bucket(
bucket_id,
event_type=data["type"],
client=data["client"],
hostname=data["hostname"],
)
if bucket_created:
return {}, 200
else:
return {}, 304
@api.expect(update_bucket)
@copy_doc(ServerAPI.update_bucket)
def put(self, bucket_id):
data = request.get_json()
current_app.api.update_bucket(
bucket_id,
event_type=data["type"],
client=data["client"],
hostname=data["hostname"],
data=data["data"],
)
return {}, 200
@copy_doc(ServerAPI.delete_bucket)
@api.param("force", "Needs to be =1 to delete a bucket it non-testing mode")
def delete(self, bucket_id):
args = request.args
if not current_app.api.testing:
if "force" not in args or args["force"] != "1":
msg = "Deleting buckets is only permitted if aw-server is running in testing mode or if ?force=1"
raise Unauthorized("DeleteBucketUnauthorized", msg)
current_app.api.delete_bucket(bucket_id)
return {}, 200
# EVENTS
@api.route("/0/buckets/<string:bucket_id>/events")
class EventsResource(Resource):
# For some reason this doesn't work with the JSONSchema variant
# Marshalling doesn't work with JSONSchema events
# @api.marshal_list_with(event)
@api.doc(model=event)
@api.param("limit", "the maximum number of requests to get")
@api.param("start", "Start date of events")
@api.param("end", "End date of events")
@copy_doc(ServerAPI.get_events)
def get(self, bucket_id):
args = request.args
limit = int(args["limit"]) if "limit" in args else -1
start = iso8601.parse_date(args["start"]) if "start" in args else None
end = iso8601.parse_date(args["end"]) if "end" in args else None
events = current_app.api.get_events(
bucket_id, limit=limit, start=start, end=end
)
return events, 200
# TODO: How to tell expect that it could be a list of events? Until then we can't use validate.
@api.expect(event)
@copy_doc(ServerAPI.create_events)
def post(self, bucket_id):
data = request.get_json()
logger.debug(
"Received post request for event in bucket '{}' and data: {}".format(
bucket_id, data
)
)
if isinstance(data, dict):
events = [Event(**data)]
elif isinstance(data, list):
events = [Event(**e) for e in data]
else:
raise BadRequest("Invalid POST data", "")
events = current_app.api.create_events(bucket_id, events)
return [e.to_json_dict() for e in events], 200
@api.route("/0/buckets/<string:bucket_id>/events/count")
class EventCountResource(Resource):
@api.doc(model=fields.Integer)
@api.param("start", "Start date of eventcount")
@api.param("end", "End date of eventcount")
@copy_doc(ServerAPI.get_eventcount)
def get(self, bucket_id):
args = request.args
start = iso8601.parse_date(args["start"]) if "start" in args else None
end = iso8601.parse_date(args["end"]) if "end" in args else None
events = current_app.api.get_eventcount(bucket_id, start=start, end=end)
return events, 200
@api.route("/0/buckets/<string:bucket_id>/events/<int:event_id>")
class EventResource(Resource):
@api.doc(model=event)
@copy_doc(ServerAPI.get_event)
def get(self, bucket_id: str, event_id: int):
logger.debug(
f"Received get request for event with id '{event_id}' in bucket '{bucket_id}'"
)
event = current_app.api.get_event(bucket_id, event_id)
if event:
return event, 200
else:
return None, 404
@copy_doc(ServerAPI.delete_event)
def delete(self, bucket_id: str, event_id: int):
logger.debug(
"Received delete request for event with id '{}' in bucket '{}'".format(
event_id, bucket_id
)
)
success = current_app.api.delete_event(bucket_id, event_id)
return {"success": success}, 200
@api.route("/0/buckets/<string:bucket_id>/heartbeat")
class HeartbeatResource(Resource):
# Class-level lock shared across all instances.
# Flask-RESTX creates a new Resource instance per request, so an
# instance-level lock would provide no mutual exclusion.
lock = Lock()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@api.expect(event, validate=True)
@api.param(
"pulsetime", "Largest timewindow allowed between heartbeats for them to merge"
)
@copy_doc(ServerAPI.heartbeat)
def post(self, bucket_id):
heartbeat = Event(**request.get_json())
if "pulsetime" in request.args:
pulsetime = float(request.args["pulsetime"])
else:
raise BadRequest("MissingParameter", "Missing required parameter pulsetime")
# This lock is meant to ensure that only one heartbeat is processed at a time,
# as the heartbeat function is not thread-safe.
# This should maybe be moved into the api.py file instead (but would be very messy).
acquired = self.lock.acquire(timeout=10)
if not acquired:
logger.warning(
"Heartbeat lock could not be acquired within timeout, rejecting request."
)
return {"message": "Server busy, try again later"}, 503
try:
event = current_app.api.heartbeat(bucket_id, heartbeat, pulsetime)
finally:
self.lock.release()
return event.to_json_dict(), 200
# QUERY
@api.route("/0/query/")
class QueryResource(Resource):
# TODO Docs
@api.expect(query, validate=True)
@api.param("name", "Name of the query (required if using cache)")
def post(self):
name = ""
if "name" in request.args:
name = request.args["name"]
query = request.get_json()
try:
result = current_app.api.query2(
name, query["query"], query["timeperiods"], False
)
return jsonify(result)
except QueryException as qe:
traceback.print_exc()
return {"type": type(qe).__name__, "message": str(qe)}, 400
# EXPORT AND IMPORT
@api.route("/0/export")
class ExportAllResource(Resource):
@api.doc(model=buckets_export)
@copy_doc(ServerAPI.export_all)
def get(self):
buckets_export = current_app.api.export_all()
payload = {"buckets": buckets_export}
response = make_response(json.dumps(payload))
filename = "aw-buckets-export.json"
response.headers["Content-Disposition"] = "attachment; filename={}".format(
filename
)
return response
# TODO: Perhaps we don't need this, could be done with a query argument to /0/export instead
@api.route("/0/buckets/<string:bucket_id>/export")
class BucketExportResource(Resource):
@api.doc(model=buckets_export)
@copy_doc(ServerAPI.export_bucket)
def get(self, bucket_id):
bucket_export = current_app.api.export_bucket(bucket_id)
payload = {"buckets": {bucket_export["id"]: bucket_export}}
response = make_response(json.dumps(payload))
filename = "aw-bucket-export_{}.json".format(bucket_export["id"])
response.headers["Content-Disposition"] = "attachment; filename={}".format(
filename
)
return response
@api.route("/0/import")
class ImportAllResource(Resource):
@api.expect(buckets_export)
@copy_doc(ServerAPI.import_all)
def post(self):
# If import comes from a form in th web-ui
if len(request.files) > 0:
# web-ui form only allows one file, but technically it's possible to
# upload multiple files at the same time
for filename, f in request.files.items():
buckets = json.loads(f.stream.read())["buckets"]
current_app.api.import_all(buckets)
# Normal import from body
else:
buckets = request.get_json()["buckets"]
current_app.api.import_all(buckets)
return None, 200
# LOGGING
@api.route("/0/log")
class LogResource(Resource):
@copy_doc(ServerAPI.get_log)
def get(self):
return current_app.api.get_log(), 200
# SETTINGS
@api.route("/0/settings", defaults={"key": ""})
@api.route("/0/settings/<string:key>")
class SettingsResource(Resource):
def get(self, key: str):
data = current_app.api.get_setting(key)
return jsonify(data)
def post(self, key: str):
if not key:
raise BadRequest("MissingParameter", "Missing required parameter key")
data = current_app.api.set_setting(key, request.get_json())
return data

151
aw_server/server.py Normal file
View File

@@ -0,0 +1,151 @@
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, List
import aw_datastore
import flask.json.provider
from aw_datastore import Datastore
from flask import (
Blueprint,
Flask,
current_app,
send_from_directory,
)
from flask_cors import CORS
from . import extension_cors, rest
from .api import ServerAPI
from .custom_static import get_custom_static_blueprint
from .log import FlaskLogHandler
logger = logging.getLogger(__name__)
app_folder = os.path.dirname(os.path.abspath(__file__))
static_folder = os.path.join(app_folder, "static")
root = Blueprint("root", __name__, url_prefix="/")
class AWFlask(Flask):
def __init__(
self,
host: str,
testing: bool,
storage_method=None,
cors_origins=[],
custom_static=dict(),
static_folder=static_folder,
static_url_path="",
):
name = "aw-server"
self.json_provider_class = CustomJSONProvider
# only prettyprint JSON if testing (due to perf)
self.json_provider_class.compact = not testing
# Initialize Flask
Flask.__init__(
self,
name,
static_folder=static_folder,
static_url_path=static_url_path,
)
self.config["HOST"] = host # needed for host-header check
with self.app_context():
_config_cors(cors_origins, testing)
# Initialize datastore and API
if storage_method is None:
storage_method = aw_datastore.get_storage_methods()["memory"]
db = Datastore(storage_method, testing=testing)
self.api = ServerAPI(db=db, testing=testing)
self.register_blueprint(root)
self.register_blueprint(rest.blueprint)
self.register_blueprint(get_custom_static_blueprint(custom_static))
class CustomJSONProvider(flask.json.provider.DefaultJSONProvider):
# encoding/decoding of datetime as iso8601 strings
# encoding of timedelta as second floats
def default(self, obj, *args, **kwargs):
try:
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, timedelta):
return obj.total_seconds()
except TypeError:
pass
return super().default(obj)
@root.route("/")
def static_root():
return current_app.send_static_file("index.html")
@root.route("/css/<path:path>")
def static_css(path):
return send_from_directory(static_folder + "/css", path)
@root.route("/js/<path:path>")
def static_js(path):
return send_from_directory(static_folder + "/js", path)
def _config_cors(cors_origins: List[str], testing: bool):
if cors_origins:
logger.warning(
"Running with additional allowed CORS origins specified through config "
"or CLI argument (could be a security risk): {}".format(cors_origins)
)
if testing:
# Used for development of aw-webui
cors_origins.append("http://127.0.0.1:27180/*")
# Capture user-configured origins before appending the built-in wildcard.
# extension_cors uses this list to exempt explicit opt-ins from scope narrowing.
user_origins = list(cors_origins)
# TODO: This could probably be more specific
# See https://github.com/ActivityWatch/aw-server/pull/43#issuecomment-386888769
cors_origins.append("moz-extension://*")
# See: https://flask-cors.readthedocs.org/en/latest/
CORS(current_app, resources={r"/api/*": {"origins": cors_origins}})
# Narrow the moz-extension wildcard to only the endpoints aw-watcher-web needs.
# See aw_server/extension_cors.py and ActivityWatch/aw-server-rust#637.
extension_cors.register(current_app._get_current_object(), user_origins)
# Only to be called from aw_server.main function!
def _start(
storage_method,
host: str,
port: int,
testing: bool = False,
cors_origins: List[str] = [],
custom_static: Dict[str, str] = dict(),
):
app = AWFlask(
host,
testing=testing,
storage_method=storage_method,
cors_origins=cors_origins,
custom_static=custom_static,
)
try:
app.run(
debug=testing,
host=host,
port=port,
request_handler=FlaskLogHandler,
use_reloader=False,
threaded=True,
)
except OSError as e:
logger.exception(e)
raise e

46
aw_server/settings.py Normal file
View File

@@ -0,0 +1,46 @@
import json
from pathlib import Path
from aw_core.dirs import get_config_dir
from .profile import profile_from_env, profile_suffix
class Settings:
def __init__(self, testing: bool):
# Dir isolation (AW_PROFILE) already separates profiles; the filename
# suffix is the pre-profile workaround and stays so --testing still
# finds settings-testing.json. Named profiles get the same shape.
filename = f"settings{profile_suffix(profile_from_env(testing=testing))}.json"
self.config_file = Path(get_config_dir("aw-server")) / filename
self.load()
def __getitem__(self, key):
return self.get(key)
def __setitem__(self, key, value):
return self.set(key, value)
def load(self):
if self.config_file.exists():
with open(self.config_file) as f:
self.data = json.load(f)
else:
self.data = {}
def save(self):
with open(self.config_file, "w") as f:
json.dump(self.data, f, indent=4)
def get(self, key: str, default=None):
if not key:
return self.data
return self.data.get(key, default)
def set(self, key, value):
if value:
self.data[key] = value
else:
if key in self.data:
del self.data[key]
self.save()

12
misc/aw-server.service Normal file
View File

@@ -0,0 +1,12 @@
[Unit]
Description=ActivityWatch
Documentation=
After=mongodb.service
[Service]
Type=simple
ExecStart=/usr/bin/env aw-server
Restart=always
[Install]
WantedBy=default.target

13
misc/process_logs.py Normal file
View File

@@ -0,0 +1,13 @@
"""
Useful for turning JSON logs into human-readable logs.
Usage:
aw-server --log-json 2>&1 | python3 process_logs.py
"""
import json
while True:
msg = json.loads(input())
print("{asctime} [{levelname}] {message}".format(**msg))

1195
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

43
pyproject.toml Normal file
View File

@@ -0,0 +1,43 @@
[tool.poetry]
name = "aw-server"
version = "0.14.0"
description = "Server for ActivityWatch"
authors = ["Erik Bjäreholt <erik@bjareho.lt>"]
license = "MPL-2.0"
include = ["aw_server/static/*"]
[tool.poetry.scripts]
aw-server = "aw_server:main"
[tool.poetry.dependencies]
python = "^3.8"
aw-core = "^0.5.18"
aw-client = "^0.5.8"
flask = "^2.2"
flask-restx = "^1.0.3"
flask-cors = "*"
importlib-metadata = {version = "*", python = "<3.10"}
werkzeug = "^2.3.3"
[tool.poetry.group.dev.dependencies]
mypy = "*"
pytest = "^7.3"
pytest-flask = "*"
pytest-benchmark = "*"
pytest-cov = "*"
types-Werkzeug = "*"
types-setuptools = "*"
types-Flask = "*"
black = "^23.3.0"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "--cov-report=term --cov-report=xml --cov-report=html --cov=aw_server"
python_files = ["*.py",]
[tool.ruff]
ignore = ["E402", "E501"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -0,0 +1,43 @@
from time import sleep
import cProfile
import pstats
from datetime import timezone as tz
from datetime import datetime
from aw_core.models import Event
import aw_datastore
import aw_server
def benchmark():
ds = aw_datastore.Datastore(aw_datastore.storages.PeeweeStorage, testing=True)
api = aw_server.api.ServerAPI(ds, testing=True)
print(api.get_info())
bucket_id = "test-benchmark"
try:
api.create_bucket(bucket_id, "test", "test", "test")
except Exception as e:
print(e)
print("Benchmarking... this will take 30 seconds")
for i in range(120):
sleep(0.1)
api.heartbeat(
bucket_id,
Event(timestamp=datetime.now(tz=tz.utc), data={"test": str(int(i))}),
pulsetime=0.3,
)
if __name__ == "__main__":
f_bench = "benchmark.dat"
cProfile.run("benchmark()", f_bench)
p = pstats.Stats(f_bench)
p.strip_dirs()
# p.sort_stats('tottime')
p.sort_stats("cumulative")
p.print_stats(20)

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env python3
import pstats
p = pstats.Stats("profile.out")
p.sort_stats("cumulative").print_stats(100)

View File

@@ -0,0 +1,6 @@
from werkzeug.contrib.profiler import ProfilerMiddleware
from aw_server import app
app.config["PROFILE"] = True
app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[30])
app.run(debug=True)

48
tests/conftest.py Executable file
View File

@@ -0,0 +1,48 @@
import logging
import os
import pytest
from aw_client import ActivityWatchClient
from aw_server.server import AWFlask
logging.basicConfig(level=logging.WARN)
@pytest.fixture(autouse=True)
def _clear_aw_profile_after_test():
"""export_profile() writes os.environ directly; monkeypatch.delenv does
not record an undo when the var was already unset, so later tests would
inherit a leftover profile."""
yield
os.environ.pop("AW_PROFILE", None)
@pytest.fixture(scope="session")
def app():
# AWFlask does not go through parse_settings(), so a leftover AW_PROFILE
# from the environment (or a prior test) would disagree with testing=True.
old = os.environ.pop("AW_PROFILE", None)
application = AWFlask("127.0.0.1", testing=True)
if old is not None:
os.environ["AW_PROFILE"] = old
return application
@pytest.fixture(scope="session")
def flask_client(app):
yield app.test_client()
@pytest.fixture(scope="session")
def aw_client():
# TODO: Could it be possible to write a sisterclass of ActivityWatchClient
# which calls aw_server.api directly? Would it be of use? Would add another
# layer of integration tests that are actually more like unit tests.
c = ActivityWatchClient("aw-client-test", testing=True)
yield c
# Delete test buckets after all tests needing the fixture have been run
buckets = c.get_buckets()
for bucket_id in buckets:
if bucket_id.startswith("test-"):
c.delete_bucket(bucket_id)

297
tests/test_client.py Executable file
View File

@@ -0,0 +1,297 @@
import json
import logging
import random
from datetime import datetime, timedelta, timezone
from pprint import pprint
from time import sleep
import pytest
from aw_core.models import Event
logging.basicConfig(level=logging.WARN)
# TODO: Could it be possible to write a sisterclass of ActivityWatchClient
# which calls aw_server.api directly? Would it be of use? Would add another
# layer of integration tests that are actually more like unit tests.
@pytest.fixture(scope="function")
def bucket(aw_client):
bucket_id = "test-" + str(random.randint(0, 10**5))
event_type = "testevents"
aw_client.create_bucket(bucket_id, event_type, queued=False)
print(f"Created bucket {bucket_id}")
sleep(1)
yield bucket_id
aw_client.delete_bucket(bucket_id)
@pytest.fixture
def queued_bucket(aw_client, bucket):
# FIXME: We need a way to clear the failed_requests file in order
# to have tests behave reasonably between runs.
aw_client.connect()
yield bucket
aw_client.disconnect()
def test_get_info(aw_client):
info = aw_client.get_info()
assert info["testing"]
def test_export(aw_client):
export = aw_client._get("export").json()
for bucket_id, bucket in export["buckets"].items():
assert bucket["id"]
assert "events" in bucket
# print(export)
def _create_heartbeat_events(
start=datetime.now(tz=timezone.utc), delta=timedelta(seconds=1)
):
e1_ts = start
e2_ts = e1_ts + delta
# Needed since server (or underlying datastore) drops precision up to milliseconds.
# Update: Even with millisecond precision it sometimes fails. (tried using `round` and `int`)
# Now rounding down to 10ms precision to prevent random failure.
# 10ms precision at least seems to work well.
# TODO: Figure out why it sometimes fails with millisecond precision. Would probably
# be useful to find the microsecond values where it consistently always fails.
e1_ts = e1_ts.replace(microsecond=int(e1_ts.microsecond / 10000) * 100)
e2_ts = e2_ts.replace(microsecond=int(e2_ts.microsecond / 10000) * 100)
e1 = Event(timestamp=e1_ts, data={"label": "test"})
e2 = Event(timestamp=e2_ts, data={"label": "test"})
return e1, e2
def _create_periodic_events(
num_events, start=datetime.now(tz=timezone.utc), delta=timedelta(hours=1)
):
events = num_events * [None]
for i, dt in ((i, start + i * delta) for i in range(len(events))):
events[i] = Event(timestamp=dt, duration=delta, data={"label": "test"})
return events
def test_heartbeat(aw_client, bucket):
bucket_id = bucket
e1, e2 = _create_heartbeat_events()
aw_client.heartbeat(bucket_id, e1, pulsetime=0)
aw_client.heartbeat(bucket_id, e2, pulsetime=10)
event = aw_client.get_events(bucket_id, limit=1)[0]
assert event.timestamp == e1.timestamp
assert event.duration == e2.timestamp - e1.timestamp
def test_heartbeat_random_order(aw_client, bucket):
bucket_id = bucket
# All the events will have the same data
events = _create_periodic_events(100, delta=timedelta(seconds=1))
random.shuffle(events)
for e in events:
aw_client.heartbeat(bucket_id, e, pulsetime=2)
events = aw_client.get_events(bucket_id, limit=-1)
# FIXME: This should pass
# assert len(events) == 1
def test_queued_heartbeat(aw_client, queued_bucket):
bucket_id = queued_bucket
e1, e2 = _create_heartbeat_events()
aw_client.heartbeat(bucket_id, e1, pulsetime=0, queued=True)
aw_client.heartbeat(bucket_id, e2, pulsetime=10, queued=True)
# Needed because of aw_client-side heartbeat merging and delayed dispatch
aw_client.heartbeat(
bucket_id,
Event(timestamp=e2.timestamp, data={"label": "something different"}),
pulsetime=0,
queued=True,
)
# Needed since the dispatcher thread might introduce some delay
max_tries = 20
for i in range(max_tries):
events = aw_client.get_events(bucket_id, limit=1)
if len(events) > 0 and events[0].duration > timedelta(seconds=0):
break
sleep(0.5)
assert i != max_tries - 1
print(f"Done on the {i + 1}th try")
assert len(events) == 1
event = events[0]
assert event.timestamp == e1.timestamp
assert event.duration == e2.timestamp - e1.timestamp
def test_list_buckets(aw_client, bucket):
buckets = aw_client.get_buckets()
assert bucket in buckets.keys()
def test_insert_event(aw_client, bucket):
event = Event(timestamp=datetime.now(tz=timezone.utc), data={"label": "test"})
aw_client.insert_event(bucket, event)
recv_events = aw_client.get_events(bucket, limit=1)
assert recv_events == [event]
def test_insert_events(aw_client, bucket):
events = _create_periodic_events(
2, start=datetime.now(tz=timezone.utc) - timedelta(days=1)
)
aw_client.insert_events(bucket, events)
recv_events = aw_client.get_events(bucket)
# Why isn't reverse=True needed here?
assert events == sorted(recv_events, key=lambda e: e.timestamp)
def test_get_event_single(aw_client, bucket):
start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50)
delta = timedelta(hours=1)
events = _create_periodic_events(10, delta=delta, start=start_dt)
aw_client.insert_events(bucket, events)
events = aw_client.get_events(bucket)
for e in events:
e2 = aw_client.get_event(bucket, e.id)
assert e.id == e2.id
assert e.timestamp == e2.timestamp
assert e.duration == e2.duration
assert e.data == e2.data
def test_get_events_interval(aw_client, bucket):
start_dt = datetime.now(tz=timezone.utc) - timedelta(days=50)
end_dt = start_dt + timedelta(days=1)
delta = timedelta(hours=1)
events = _create_periodic_events(1000, delta=delta, start=start_dt)
aw_client.insert_events(bucket, events)
# start kwarg isn't currently range-inclusive
recv_events = aw_client.get_events(bucket, limit=50, start=start_dt, end=end_dt)
assert len(recv_events) == 25
# drop IDs
for e in recv_events:
e.id = None
print(recv_events)
src_events = sorted(events[:25], reverse=True, key=lambda e: e.timestamp)
# The first event gets cut off
assert recv_events[0].timestamp == src_events[0].timestamp
assert recv_events[0].data == src_events[0].data
# This fails due to microsecond precision issues
assert _round_td(recv_events[0].duration) == _round_td(
end_dt - recv_events[0].timestamp
)
# NOTE: Duration can differ by a few microseconds, so we round durations off to 10ms
recv_events = [_round_durations(e) for e in recv_events]
src_events = [_round_durations(e) for e in src_events]
assert recv_events[1:25] == src_events[1:25]
def _round_td(td: timedelta) -> timedelta:
return timedelta(seconds=round(td.total_seconds(), 2))
def _round_durations(e: Event):
e.duration = _round_td(e.duration)
return e
def test_store_many_events(aw_client, bucket):
events = _create_periodic_events(
1000, start=datetime.now(tz=timezone.utc) - timedelta(days=50)
)
aw_client.insert_events(bucket, events)
recv_events = aw_client.get_events(bucket, limit=-1)
assert len(events) == len(recv_events)
assert recv_events == sorted(events, reverse=True, key=lambda e: e.timestamp)
def test_midnight(aw_client, bucket):
start_dt = datetime.now() - timedelta(days=1)
midnight = start_dt.replace(hour=23, minute=50)
events = _create_periodic_events(100, start=midnight, delta=timedelta(minutes=1))
aw_client.insert_events(bucket, events)
recv_events = aw_client.get_events(bucket, limit=-1)
assert len(recv_events) == len(events)
def test_midnight_heartbeats(aw_client, bucket):
now = datetime.now(tz=timezone.utc) - timedelta(days=1)
# Zero out seconds/microseconds for deterministic midnight boundary.
# Without this, residual microseconds cause non-deterministic boundary
# matching when querying events around midnight (the endtime >= start_query
# condition can include/exclude boundary events depending on microseconds).
midnight = now.replace(hour=23, minute=50, second=0, microsecond=0)
events = _create_periodic_events(20, start=midnight, delta=timedelta(minutes=1))
label_ring = ["1", "1", "2", "3", "4"]
for i, e in enumerate(events):
e.data["label"] = label_ring[i % len(label_ring)]
aw_client.heartbeat(bucket, e, pulsetime=90)
recv_events_merged = aw_client.get_events(bucket, limit=-1)
assert len(recv_events_merged) == 4 / 5 * len(events)
# Query from midnight (00:00), which is exactly midnight + 10 minutes from
# the 23:50 start. Use a small offset to avoid boundary ambiguity where
# events ending exactly at midnight could be included by the >= condition.
query_start = midnight + timedelta(minutes=10, milliseconds=1)
recv_events_after_midnight = aw_client.get_events(bucket, start=query_start)
pprint(recv_events_after_midnight)
assert len(recv_events_after_midnight) == int(len(recv_events_merged) / 2)
def test_settings(aw_client):
aw_client.set_setting("test", "test")
aw_client.set_setting("list", json.dumps([1, 2, 3]))
aw_client.set_setting("dict", json.dumps({"a": 1, "b": 2}))
# check set
assert aw_client.get_setting("test") == "test"
assert json.loads(aw_client.get_setting("list")) == [1, 2, 3]
assert json.loads(aw_client.get_setting("dict")) == {"a": 1, "b": 2}
# check unset
assert aw_client.get_setting("test2") is None
# check get all
settings = aw_client.get_setting()
assert settings["test"] == "test"
assert json.loads(settings["list"]) == [1, 2, 3]
assert json.loads(settings["dict"]) == {"a": 1, "b": 2}

View File

@@ -0,0 +1,171 @@
"""Tests for moz-extension CORS endpoint scoping (extension_cors module)."""
import pytest
from aw_server.extension_cors import (
_is_allowed,
_is_allowed_path,
_matches_configured_origin,
)
from aw_server.server import AWFlask
_EXT_ORIGIN = "moz-extension://aabbccddeeff00112233445566778899"
_HOST = "127.0.0.1"
@pytest.fixture(scope="module")
def client():
app = AWFlask(_HOST, testing=True)
return app.test_client()
# ---------------------------------------------------------------------------
# Unit tests for the path-matching helpers
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"method,path,expected",
[
("GET", "/api/0/info", True),
("POST", "/api/0/buckets/aw-watcher-web-hostname", True),
("POST", "/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
# Blocked paths
("GET", "/api/0/export", False),
("POST", "/api/0/export", False),
("GET", "/api/0/buckets/", False),
("GET", "/api/0/buckets/aw-watcher-web-hostname/events", False),
("POST", "/api/0/query/", False),
("GET", "/api/0/settings", False),
# Non-watcher bucket
("POST", "/api/0/buckets/aw-watcher-window-hostname", False),
# Heartbeat on non-watcher bucket
("POST", "/api/0/buckets/aw-watcher-afk-hostname/heartbeat", False),
# Percent-encoding should not bypass via raw path (segments are used)
("GET", "/api/0/%65xport", False), # %65 = 'e', decodes to 'export'
],
)
def test_is_allowed_unit(method, path, expected):
segments = [s for s in path.split("/") if s]
assert _is_allowed(method, segments) is expected
@pytest.mark.parametrize(
"path,expected",
[
("/api/0/info", True),
("/api/0/buckets/aw-watcher-web-hostname", True),
("/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
("/api/0/export", False),
("/api/0/buckets/", False),
],
)
def test_is_allowed_path_unit(path, expected):
segments = [s for s in path.split("/") if s]
assert _is_allowed_path(segments) is expected
# ---------------------------------------------------------------------------
# Integration tests via Flask test client
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"method,path",
[
("GET", "/api/0/info"),
# Bucket creation: 400 without body is expected but NOT 403
("POST", "/api/0/buckets/aw-watcher-web-testhost"),
# Heartbeat: 404 (bucket doesn't exist) is expected but NOT 403
("POST", "/api/0/buckets/aw-watcher-web-testhost/heartbeat"),
],
)
def test_extension_allowed(client, method, path):
"""moz-extension origins are permitted at aw-watcher-web's endpoints."""
headers = {"Origin": _EXT_ORIGIN}
r = client.open(path, method=method, headers=headers)
assert (
r.status_code != 403
), f"{method} {path} should not be blocked; got {r.status_code}"
@pytest.mark.parametrize(
"method,path",
[
("GET", "/api/0/export"),
("GET", "/api/0/buckets/"),
("POST", "/api/0/import"),
("POST", "/api/0/query/"),
("GET", "/api/0/settings"),
# Events read from a watcher bucket
("GET", "/api/0/buckets/aw-watcher-web-testhost/events"),
# Non-watcher bucket
("POST", "/api/0/buckets/aw-watcher-window-testhost"),
("POST", "/api/0/buckets/aw-watcher-afk-testhost/heartbeat"),
],
)
def test_extension_blocked(client, method, path):
"""moz-extension origins are blocked at endpoints beyond aw-watcher-web's needs."""
headers = {"Origin": _EXT_ORIGIN}
r = client.open(path, method=method, headers=headers)
assert (
r.status_code == 403
), f"{method} {path} should be blocked (403); got {r.status_code}"
@pytest.mark.parametrize(
"pattern,origin,expected",
[
("moz-extension://aabbcc", "moz-extension://aabbcc", True),
("MOZ-EXTENSION://AABBCC", "moz-extension://aabbcc", True),
(r"moz-extension://.*", "moz-extension://aabbcc", True),
(r"moz-extension://[a-f0-9]+", "moz-extension://aabbcc", True),
(r"moz-extension://[0-9]+", "moz-extension://aabbcc", False),
("moz-extension://other", "moz-extension://aabbcc", False),
],
)
def test_matches_configured_origin(pattern, origin, expected):
assert _matches_configured_origin(origin, pattern) is expected
def test_regex_configured_extension_origin_bypasses_scope_guard():
"""Owner-configured regex origins retain unrestricted endpoint access."""
app = AWFlask(_HOST, testing=False, cors_origins=[r"moz-extension://.*"])
client = app.test_client()
response = client.get("/api/0/export", headers={"Origin": _EXT_ORIGIN})
assert response.status_code != 403
def test_non_extension_origin_passthrough(client):
"""Non-moz-extension origins are not affected by the scope guard."""
headers = {"Origin": "http://127.0.0.1:27180"}
r = client.get("/api/0/info", headers=headers)
assert r.status_code != 403
def test_no_origin_passthrough(client):
"""Requests without an Origin header (native watchers, curl) are not blocked."""
r = client.get("/api/0/info")
assert r.status_code != 403
def test_options_allowed_path(client):
"""OPTIONS preflight on an allowed path is permitted."""
headers = {
"Origin": _EXT_ORIGIN,
"Access-Control-Request-Method": "GET",
}
r = client.options("/api/0/info", headers=headers)
assert r.status_code != 403
def test_options_blocked_path(client):
"""OPTIONS preflight on a blocked path is rejected."""
headers = {
"Origin": _EXT_ORIGIN,
"Access-Control-Request-Method": "GET",
}
r = client.options("/api/0/export", headers=headers)
assert r.status_code == 403

94
tests/test_profile.py Normal file
View File

@@ -0,0 +1,94 @@
"""Unit tests for profile resolution."""
import os
import pytest
from aw_server.profile import (
DEFAULT_PROFILE,
TESTING_PROFILE,
export_profile,
is_testing,
profile_from_env,
profile_suffix,
resolve_profile,
validate_profile,
)
class TestResolveProfile:
def test_no_flags_gives_default(self):
assert resolve_profile(None, False) == DEFAULT_PROFILE
def test_testing_flag_is_an_alias(self):
assert resolve_profile(None, True) == TESTING_PROFILE
def test_explicit_profile_wins(self):
assert resolve_profile("research", False) == "research"
def test_testing_with_matching_profile_is_allowed(self):
assert resolve_profile("testing", True) == TESTING_PROFILE
def test_testing_with_conflicting_profile_raises(self):
with pytest.raises(ValueError, match="conflicts"):
resolve_profile("research", True)
@pytest.mark.parametrize(
"name", ["Research", "with space", "a" * 33, "", "../escape", "-leading"]
)
def test_invalid_names_rejected(self, name):
with pytest.raises(ValueError):
validate_profile(name)
@pytest.mark.parametrize("name", ["research", "aw2", "my_profile", "my-profile"])
def test_valid_names_accepted(self, name):
assert validate_profile(name) == name
class TestProfileSuffix:
def test_default_has_no_suffix(self):
assert profile_suffix(DEFAULT_PROFILE) == ""
def test_testing_keeps_legacy_suffix(self):
assert profile_suffix(TESTING_PROFILE) == "-testing"
def test_custom_profile_suffix(self):
assert profile_suffix("research") == "-research"
def test_suffixes_are_disjoint(self):
suffixes = {profile_suffix(p) for p in ("default", "testing", "research")}
assert len(suffixes) == 3
class TestIsTesting:
def test_only_testing_profile_is_testing(self):
assert is_testing(TESTING_PROFILE)
assert not is_testing(DEFAULT_PROFILE)
assert not is_testing("research")
class TestExportProfile:
def test_exported_for_child_processes(self, monkeypatch):
monkeypatch.delenv("AW_PROFILE", raising=False)
export_profile("research")
assert os.environ["AW_PROFILE"] == "research"
def test_default_unsets_env_so_aw_core_keeps_bare_root(self, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
export_profile(DEFAULT_PROFILE)
assert "AW_PROFILE" not in os.environ
class TestProfileFromEnv:
def test_defaults_when_unset(self, monkeypatch):
monkeypatch.delenv("AW_PROFILE", raising=False)
assert profile_from_env(False) == DEFAULT_PROFILE
assert profile_from_env(True) == TESTING_PROFILE
def test_reads_exported_profile(self, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
assert profile_from_env(False) == "research"
def test_ignores_invalid_env_value(self, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "Not A Profile")
assert profile_from_env(False) == DEFAULT_PROFILE

View File

@@ -0,0 +1,127 @@
"""Port, settings, and CLI wiring for named profiles."""
import os
import sys
import pytest
from aw_server.config import config_section, default_port
from aw_server.main import parse_settings
from aw_server.profile import DEFAULT_PROFILE, TESTING_PROFILE, export_profile
from aw_server.settings import Settings
@pytest.fixture
def xdg_tmp(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
monkeypatch.delenv("AW_PROFILE", raising=False)
return tmp_path
class TestConfigHelpers:
def test_sections_are_disjoint(self):
sections = {config_section(p) for p in ("default", "testing", "research")}
assert sections == {"server", "server-testing", "server-research"}
def test_default_ports(self):
assert default_port(DEFAULT_PROFILE) == 5600
assert default_port(TESTING_PROFILE) == 5666
assert default_port("research") == 5600
class TestSettingsFilename:
def test_testing_keeps_legacy_name(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
)
monkeypatch.delenv("AW_PROFILE", raising=False)
settings = Settings(True)
assert settings.config_file.name == "settings-testing.json"
def test_default_unsuffixed(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
)
monkeypatch.delenv("AW_PROFILE", raising=False)
settings = Settings(False)
assert settings.config_file.name == "settings.json"
def test_named_profile_suffix(self, tmp_path, monkeypatch):
monkeypatch.setattr(
"aw_server.settings.get_config_dir", lambda module: str(tmp_path)
)
monkeypatch.setenv("AW_PROFILE", "research")
settings = Settings(False)
assert settings.config_file.name == "settings-research.json"
class TestParseSettings:
def test_testing_flag_selects_testing_port(self, xdg_tmp, monkeypatch):
monkeypatch.setattr(sys, "argv", ["aw-server", "--testing"])
settings, _storage = parse_settings()
assert settings.testing is True
assert settings.profile == TESTING_PROFILE
assert settings.port == 5666
assert os.environ["AW_PROFILE"] == "testing"
def test_profile_testing_is_alias_for_testing_flag(self, xdg_tmp, monkeypatch):
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "testing"])
settings, _storage = parse_settings()
assert settings.testing is True
assert settings.profile == TESTING_PROFILE
assert settings.port == 5666
def test_default_keeps_port_5600_and_unsets_env(self, xdg_tmp, monkeypatch):
monkeypatch.setenv("AW_PROFILE", "research")
monkeypatch.setattr(sys, "argv", ["aw-server"])
settings, _storage = parse_settings()
assert settings.testing is False
assert settings.profile == DEFAULT_PROFILE
assert settings.port == 5600
assert "AW_PROFILE" not in os.environ
def test_named_profile_exports_env_and_falls_back_to_server_section(
self, xdg_tmp, monkeypatch
):
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "research"])
settings, _storage = parse_settings()
assert settings.testing is False
assert settings.profile == "research"
assert settings.port == 5600
assert os.environ["AW_PROFILE"] == "research"
def test_cli_port_override(self, xdg_tmp, monkeypatch):
monkeypatch.setattr(
sys, "argv", ["aw-server", "--profile", "research", "--port", "5667"]
)
settings, _storage = parse_settings()
assert settings.port == 5667
assert settings.profile == "research"
def test_conflicting_flags_are_a_usage_error(self, xdg_tmp, monkeypatch):
monkeypatch.setattr(
sys, "argv", ["aw-server", "--testing", "--profile", "research"]
)
with pytest.raises(SystemExit):
parse_settings()
def test_invalid_profile_is_a_usage_error(self, xdg_tmp, monkeypatch):
monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "Research"])
with pytest.raises(SystemExit):
parse_settings()
def test_named_profile_config_is_isolated_from_default(xdg_tmp, monkeypatch):
"""AW_PROFILE must be exported before load_config, else both profiles
share ~/.config/activitywatch/aw-server."""
import aw_core.dirs as dirs
export_profile("research")
research_dir = dirs.get_config_dir("aw-server")
export_profile(DEFAULT_PROFILE)
default_dir = dirs.get_config_dir("aw-server")
assert research_dir != default_dir
assert "activitywatch-research" in research_dir
assert "activitywatch-research" not in default_dir

184
tests/test_server.py Normal file
View File

@@ -0,0 +1,184 @@
import random
from datetime import datetime, timedelta
import pytest
@pytest.fixture()
def bucket(flask_client):
"Context manager for creating and deleting a testing bucket"
try:
bucket_id = "test"
r = flask_client.post(
f"/api/0/buckets/{bucket_id}",
json={"client": "test", "type": "test", "hostname": "test"},
)
assert r.status_code == 200
yield bucket_id
finally:
r = flask_client.delete(f"/api/0/buckets/{bucket_id}")
assert r.status_code == 200
def test_info(flask_client):
r = flask_client.get("/api/0/info")
assert r.status_code == 200
assert r.json["testing"]
assert r.json["profile"] == "testing"
def test_buckets(flask_client, bucket, benchmark):
@benchmark
def list_buckets():
r = flask_client.get("/api/0/buckets/")
print(r.json)
assert r.status_code == 200
assert len(r.json) == 1
def test_heartbeats(flask_client, bucket, benchmark):
# FIXME: Currently tests using the memory storage method
# TODO: Test with a longer data section and see if there's a significant difference
# TODO: Test with a larger bucket and see if there's a significant difference
@benchmark
def heartbeat():
now = datetime.now()
r = flask_client.post(
f"/api/0/buckets/{bucket}/heartbeat?pulsetime=1",
json={"timestamp": now, "duration": 0, "data": {"random": random.random()}},
)
assert r.status_code == 200
def test_get_events(flask_client, bucket, benchmark):
n_events = 100
start_time = datetime.now() - timedelta(days=100)
for i in range(n_events):
now = start_time + timedelta(hours=i)
r = flask_client.post(
f"/api/0/buckets/{bucket}/heartbeat?pulsetime=0",
json={"timestamp": now, "duration": 0, "data": {"random": random.random()}},
)
assert r.status_code == 200
@benchmark
def get_events():
r = flask_client.get(f"/api/0/buckets/{bucket}/events")
assert r.status_code == 200
assert r.json
assert len(r.json) == n_events
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=-1")
assert r.status_code == 200
assert r.json
assert len(r.json) == n_events
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=10")
assert r.status_code == 200
assert r.json
assert len(r.json) == 10
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=100")
assert r.status_code == 200
assert r.json
assert len(r.json) == n_events
r = flask_client.get(f"/api/0/buckets/{bucket}/events?limit=1000")
assert r.status_code == 200
assert r.json
assert len(r.json) == n_events
def test_insert_event_returns_list(flask_client, bucket):
"""Test that POST /events returns a list of events with IDs (matching aw-server-rust)."""
now = datetime.now()
event_data = {
"timestamp": now.isoformat(),
"duration": 0,
"data": {"label": "test"},
}
# Single event as list
r = flask_client.post(
f"/api/0/buckets/{bucket}/events",
json=[event_data],
)
assert r.status_code == 200
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
assert len(r.json) == 1
assert r.json[0]["id"] is not None
assert r.json[0]["data"] == {"label": "test"}
# Single event as dict (legacy format)
r = flask_client.post(
f"/api/0/buckets/{bucket}/events",
json=event_data,
)
assert r.status_code == 200
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
assert len(r.json) == 1
assert r.json[0]["id"] is not None
def test_insert_events_returns_list(flask_client, bucket):
"""Test that POST /events with multiple events returns a list."""
now = datetime.now()
events_data = [
{
"timestamp": (now - timedelta(hours=i)).isoformat(),
"duration": 0,
"data": {"label": f"test-{i}"},
}
for i in range(3)
]
r = flask_client.post(
f"/api/0/buckets/{bucket}/events",
json=events_data,
)
assert r.status_code == 200
assert isinstance(r.json, list), f"Expected list, got {type(r.json)}"
assert len(r.json) == 0
# TODO: Add benchmark for basic AFK-filtering query
def test_query_invalid_timeperiod(flask_client):
"""Malformed timeperiods must yield 400 (client error), not 500.
Regression test: a non-ISO8601 timeperiod previously raised an uncaught
iso8601.ParseError, surfacing as an Internal Server Error.
"""
r = flask_client.post(
"/api/0/query/",
json={"query": ["RETURN = 1;"], "timeperiods": ["not-a-valid-period"]},
)
assert r.status_code == 400
assert "not-a-valid-period" in r.json["message"]
def test_query_timeperiod_missing_slash(flask_client):
"""A timeperiod without a start/end slash separator must yield 400, not 500.
Regression test: a single ISO8601 datetime (no slash) previously raised an
uncaught IndexError when indexing the split result.
"""
r = flask_client.post(
"/api/0/query/",
json={"query": ["RETURN = 1;"], "timeperiods": ["2024-01-01T00:00:00+00:00"]},
)
assert r.status_code == 400
def test_query_valid_timeperiod(flask_client):
"""A well-formed query with a valid timeperiod still succeeds."""
r = flask_client.post(
"/api/0/query/",
json={
"query": ["RETURN = 1;"],
"timeperiods": ["2024-01-01T00:00:00+00:00/2024-01-02T00:00:00+00:00"],
},
)
assert r.status_code == 200
assert r.json == [1]