diff --git a/Makefile b/Makefile index 28518c6..61d6742 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ test-integration: test-examples: cd examples; pytest -v *.py cd examples; yes | python3 load_dataframe.py - cd examples; python3 working_hours.py + cd examples; python3 working_hours.py 'activitywatch|aw-|github.com' fakedata typecheck: poetry run mypy diff --git a/examples/load_dataframe.py b/examples/load_dataframe.py index 2d99220..7b8a27a 100644 --- a/examples/load_dataframe.py +++ b/examples/load_dataframe.py @@ -1,6 +1,8 @@ """ Load ActivityWatch data into a dataframe, and export as CSV. """ +import os +import socket from datetime import datetime, timedelta, timezone import iso8601 @@ -11,10 +13,11 @@ def build_query() -> str: + hostname = "fakedata" if os.getenv("CI") else socket.gethostname() canonicalQuery = canonicalEvents( DesktopQueryParams( - bid_window="aw-watcher-window_", - bid_afk="aw-watcher-afk_", + bid_window=f"aw-watcher-window_{hostname}", + bid_afk=f"aw-watcher-afk_{hostname}", classes=default_classes, ) ) diff --git a/examples/working_hours.py b/examples/working_hours.py index 72a5323..d20627d 100644 --- a/examples/working_hours.py +++ b/examples/working_hours.py @@ -5,22 +5,25 @@ """ import json -import re +import logging import os -from datetime import datetime, timedelta, time -from typing import List, Tuple, Dict - -from tabulate import tabulate +import re +import socket +import sys +from datetime import datetime, time, timedelta +from typing import Dict, List, Tuple import aw_client from aw_client import queries from aw_core import Event from aw_transform import flood +from tabulate import tabulate - -EXAMPLE_REGEX = r"activitywatch|algobit|defiarb|github.com" OUTPUT_HTML = os.environ.get("OUTPUT_HTML", "").lower() == "true" +td1d = timedelta(days=1) +day_offset = timedelta(hours=4) + def _pretty_timedelta(td: timedelta) -> str: s = str(td) @@ -47,19 +50,11 @@ def generous_approx(events: List[dict], max_break: float) -> timedelta: ) -def query(regex: str = EXAMPLE_REGEX, save=True): +def query(regex: str, timeperiods, hostname: str): print("Querying events...") - td1d = timedelta(days=1) - day_offset = timedelta(hours=4) print(f" Day offset: {day_offset}") print("") - now = datetime.now().astimezone() - today = (datetime.combine(now.date(), time()) + day_offset).astimezone() - - timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(5)] - timeperiods.reverse() - categories: List[Tuple[List[str], Dict]] = [ ( ["Work"], @@ -75,8 +70,8 @@ def query(regex: str = EXAMPLE_REGEX, save=True): canonicalQuery = queries.canonicalEvents( queries.DesktopQueryParams( - bid_window="aw-watcher-window_", - bid_afk="aw-watcher-afk_", + bid_window=f"aw-watcher-window_{hostname}", + bid_afk=f"aw-watcher-afk_{hostname}", classes=categories, filter_classes=[["Work"]], ) @@ -89,16 +84,38 @@ def query(regex: str = EXAMPLE_REGEX, save=True): res = aw.query(query, timeperiods) - for break_time in [0, 5 * 60, 10 * 60, 15 * 60]: - _print( - timeperiods, res, break_time, {"category_rule": categories[0][1]["regex"]} - ) + return res - if save: - fn = "working_hours_events.json" - with open(fn, "w") as f: - print(f"Saving to {fn}...") - json.dump(res, f, indent=2) + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 working_hours.py [hostname]") + exit(1) + + regex = sys.argv[1] + print(f"Using regex: {regex}") + + if len(sys.argv) > 2: + hostname = sys.argv[2] + print(f"Using hostname: {hostname}") + else: + hostname = socket.gethostname() + + now = datetime.now().astimezone() + today = (datetime.combine(now.date(), time()) + day_offset).astimezone() + + timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(5)] + timeperiods.reverse() + + res = query(regex, timeperiods, hostname) + + for break_time in [0, 5 * 60, 15 * 60]: + _print(timeperiods, res, break_time, {"regex": regex}) + + fn = "working_hours_events.json" + with open(fn, "w") as f: + print(f"Saving to {fn}...") + json.dump(res, f, indent=2) def _print(timeperiods, res, break_time, params: dict): @@ -134,4 +151,7 @@ def _print(timeperiods, res, break_time, params: dict): if __name__ == "__main__": - query() + # ignore log warnings in aw_transform + logging.getLogger("aw_transform").setLevel(logging.ERROR) + + main() diff --git a/examples/working_hours_gspread.py b/examples/working_hours_gspread.py new file mode 100644 index 0000000..689913d --- /dev/null +++ b/examples/working_hours_gspread.py @@ -0,0 +1,122 @@ +""" +This script uses ActivityWatch events to updates a Google Sheet +with the any events matching a regex for the last `days_back` days. + +It uses the `working_hours.py` example to calculate the working hours +and the `gspread` library to interact with Google Sheets. + +The Google Sheet is identified by its key, which is hardcoded in the script. +The script uses a service account for authentication with the Google Sheets API. + +The script assumes that the Google Sheet has a worksheet for each hostname, named "worked-{hostname}". +If such a worksheet does not exist, the script will fail. + +The working hours are calculated generously, meaning that if the time between two consecutive +events is less than `break_time` (10 minutes by default), it is considered as working time. + +Usage: + python3 working_hours_gspread.py +""" +import socket +import sys +from datetime import datetime, time, timedelta + +import gspread + +import working_hours + +td1d = timedelta(days=1) +break_time = 10 * 60 + + +def update_sheet(sheet_key: str, regex: str): + """ + Update the Google Sheet with the working hours for the last `days_back` days. + + 1. Open the sheet and get the last entry + 2. Query the working hours for the days since the last entry + 3. Update the last entry in the Google Sheet (if any) + 4. Append any new entries + """ + + hostname = socket.gethostname() + hostname_display = hostname.replace(".localdomain", "").replace(".local", "") + + try: + gc = gspread.service_account() + except Exception as e: + print(e) + print( + "Failed to authenticate with Google Sheets API.\n" + "Make sure you have a service account key in ~/.config/gspread/service_account.json\n" + "See https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account" + ) + exit(1) + + # Open the sheet + sh = gc.open_by_key(sheet_key) + print(f"Updating document: {sh.title}") + worksheet = sh.worksheet(f"worked-{hostname_display}") + print(f"Updating worksheet: {worksheet.title}") + + # Get the most recent entry from the Google Sheet + values = worksheet.get_all_values() + if values: + last_row = values[-1] + last_date = datetime.strptime(last_row[0], "%Y-%m-%d").date() + else: + last_date = None + + last_datetime = ( + (datetime.combine(last_date, time()) + working_hours.day_offset).astimezone() + if last_date + else None + ) + + if last_datetime: + print(f"Last entry: {last_datetime}") + + now = datetime.now().astimezone() + today = ( + datetime.combine(now.date(), time()) + working_hours.day_offset + ).astimezone() + + # Create a list of time periods to query, from last_date or days_back_on_new back if None + days_back_on_new = 30 + days_back = (today - last_datetime).days + 1 if last_datetime else days_back_on_new + timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(days_back)] + timeperiods.reverse() + + # Run the query function from the original script and get the result + res = working_hours.query(regex, timeperiods, hostname) + + # Iterate over the result and update or append the data to the Google Sheet + for tp, r in zip(timeperiods, res): + date = tp[0].date() + duration = ( + working_hours.generous_approx(r["events"], break_time).total_seconds() + / 3600 + ) + row = [str(date), duration] + + # If the date is the same as the last entry, update it + if last_date and date == last_date: + print(f"Updating {row}") + worksheet.update_cell(len(worksheet.get_all_values()), 2, duration) + # If the date is later than the last entry, append it + elif not last_date or date > last_date: + print(f"Appending {row}") + worksheet.append_row(row, value_input_option="USER_ENTERED") + else: + print(f"Skipping {row}") + + +if __name__ == "__main__": + if len(sys.argv) == 3: + sheet_key = sys.argv[1] + regex = sys.argv[2] + else: + print("Usage: python3 working_hours_gspread.py ") + exit(1) + + update_sheet(sheet_key, regex) diff --git a/poetry.lock b/poetry.lock index b4d7456..b8f465d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -96,6 +96,17 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cachetools" +version = "5.3.2" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"}, + {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"}, +] + [[package]] name = "certifi" version = "2023.11.17" @@ -340,6 +351,62 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "google-auth" +version = "2.26.1" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-auth-2.26.1.tar.gz", hash = "sha256:54385acca5c0fbdda510cd8585ba6f3fcb06eeecf8a6ecca39d3ee148b092590"}, + {file = "google_auth-2.26.1-py2.py3-none-any.whl", hash = "sha256:2c8b55e3e564f298122a02ab7b97458ccfcc5617840beb5d0ac757ada92c9780"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography (==36.0.2)", "pyopenssl (==22.0.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "google-auth-oauthlib-1.2.0.tar.gz", hash = "sha256:292d2d3783349f2b0734a0a0207b1e1e322ac193c2c09d8f7c613fb7cc501ea8"}, + {file = "google_auth_oauthlib-1.2.0-py2.py3-none-any.whl", hash = "sha256:297c1ce4cb13a99b5834c74a1fe03252e1e499716718b190f56bcb9c4abc4faf"}, +] + +[package.dependencies] +google-auth = ">=2.15.0" +requests-oauthlib = ">=0.7.0" + +[package.extras] +tool = ["click (>=6.0.0)"] + +[[package]] +name = "gspread" +version = "5.12.4" +description = "Google Spreadsheets Python API" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gspread-5.12.4-py3-none-any.whl", hash = "sha256:1e453d87e0fde23bc5546e33eb684cf8b8c26540615f2f1ae004a9084a29051d"}, + {file = "gspread-5.12.4.tar.gz", hash = "sha256:3fcef90183f15d3c9233b4caa021a83682f2b2ee678340c42d7ca7d8be98c6d1"}, +] + +[package.dependencies] +google-auth = ">=1.12.0" +google-auth-oauthlib = ">=0.4.1" + [[package]] name = "idna" version = "3.4" @@ -515,6 +582,22 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + [[package]] name = "packaging" version = "23.2" @@ -602,6 +685,31 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pyasn1" +version = "0.5.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pyasn1-0.5.1-py2.py3-none-any.whl", hash = "sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58"}, + {file = "pyasn1-0.5.1.tar.gz", hash = "sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.3.0" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "pyasn1_modules-0.3.0-py2.py3-none-any.whl", hash = "sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d"}, + {file = "pyasn1_modules-0.3.0.tar.gz", hash = "sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.6.0" + [[package]] name = "pylint" version = "3.0.2" @@ -722,6 +830,24 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, + {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -844,6 +970,20 @@ files = [ {file = "rpds_py-0.13.1.tar.gz", hash = "sha256:264f3a5906c62b9df3a00ad35f6da1987d321a053895bd85f9d5c708de5c0fbf"}, ] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "six" version = "1.16.0" @@ -993,4 +1133,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "92ebb539035569f86494c77fb421e9638bb0e96d4f41f810ab404f40cb0a22ad" +content-hash = "acb1bef87759cbd72721488b8f8ffcb5cc375a8eb090558e99327b59c8f30701" diff --git a/pyproject.toml b/pyproject.toml index 49a2b66..e2b07c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ click = "^8.0" tabulate = "*" typing-extensions = "*" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "^7.0" pytest-cov = "*" mypy = "*" @@ -33,6 +33,7 @@ types-requests = "*" types-tabulate = "*" pyupgrade = "*" black = "*" +gspread = "^5.12.4" # used in examples [tool.mypy] files = ["aw_client", "tests", "examples"]