From 4fcf972e1720d90f092dbc6e71d83fecb14b7f63 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Fri, 6 Dec 2024 21:30:34 -0500 Subject: [PATCH 1/2] WIP: config file support --- hamstertools/__init__.py | 55 ++++++++++++++++++++++++++++++---------- hamstertools/app.py | 17 ++++++++----- hamstertools/db.py | 19 ++++++++++++++ hamstertools/kimaiapi.py | 10 +++----- hamstertools/sync.py | 4 +-- requirements.txt | 1 + 6 files changed, 77 insertions(+), 29 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index cfcdb20..cccd688 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -1,16 +1,21 @@ #!/usr/bin/env python3.7 +import os import csv import logging from datetime import datetime from itertools import chain from pathlib import Path import sys +import tomllib import click import requests from peewee import fn, JOIN from textual.logging import TextualHandler +from xdg.BaseDirectory import xdg_config_home + + from .db import ( db, HamsterCategory, @@ -21,19 +26,30 @@ from .db import ( KimaiActivity, HamsterActivityKimaiMapping, HamsterFactKimaiImport, + HamsterFactTag, ) from .kimaiapi import KimaiAPI, Timesheet from .sync import sync +CONFIG_FILE = Path(xdg_config_home) / "hamstertools.toml" + HAMSTER_DIR = Path.home() / ".local/share/hamster" HAMSTER_FILE = HAMSTER_DIR / "hamster.db" db.init(HAMSTER_FILE) -@click.group() +@click.group(context_settings={"auto_envvar_prefix": "HAMSTERTOOL"}) @click.option("-d", "--debug", is_flag=True) -def cli(debug): +@click.option("--config", default=CONFIG_FILE, type=click.Path()) +@click.option("--kimai-api-key", envvar="KIMAI_API_KEY") +@click.option("--kimai-username", envvar="KIMAI_USERNAME") +@click.option("--kimai-api-key", envvar="KIMAI_API_KEY") +@click.pass_context +def cli(ctx, config, debug, kimai_api_url, kimai_username, kimai_api_key): + if os.path.exists(config): + with open(config, "rb") as f: + ctx.default_map = tomllib.load(f) if debug: logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) @@ -310,8 +326,12 @@ def find_duplicates(): @cli.group() -def kimai(): - pass +@click.pass_context +def kimai(ctx, kimai_username=None, kimai_api_key=None): + ctx.ensure_object(dict) + + ctx.obj['KIMAI_USERNAME'] = kimai_username + ctx.obj['KIMAI_API_KEY'] = kimai_api_key def _get_kimai_mapping_file(path, category_search=None): @@ -354,7 +374,6 @@ def _get_kimai_mapping_file(path, category_search=None): multiple=True, ) @click.argument("username") -@click.argument("api_key", envvar="KIMAI_API_KEY") @click.option("--just-errors", "just_errors", is_flag=True, help="Only display errors") @click.option("--ignore-activities", is_flag=True, help="Ignore missing activities") def check(username, api_key, just_errors, ignore_activities, mapping_path=None): @@ -643,7 +662,7 @@ def _csv( @click.argument("after") @click.argument("before") def _import(search, after, before): - api = KimaiAPI() + api = KimaiAPI(username=KIMAI_USERNAME, api_key=KIMAI_API_KEY) SEARCH = "auto" @@ -651,6 +670,8 @@ def _import(search, after, before): HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory) .join(HamsterActivity, JOIN.LEFT_OUTER) .join(HamsterCategory, JOIN.LEFT_OUTER) + .switch(HamsterFact) + .join(HamsterFactTag, JOIN.LEFT_OUTER) .where( (HamsterFact.start_time > datetime.strptime(after, "%Y-%m-%d")) & (HamsterFact.start_time < datetime.strptime(before, "%Y-%m-%d")) @@ -719,15 +740,12 @@ def _import(search, after, before): description=f.description if f.description != "" else mapping.kimai_description, - # tags=f.tags if f.tags != '' else mapping.kimai_tags + tags=" ".join([t.tag.name for t in f.tags]) if f.tags != '' else mapping.kimai_tags ) r = t.upload().json() if r.get("code", 200) != 200: print(r) print(f"{f.id} ({f.activity.category.name} ยป {f.activity.name})") - from pdb import set_trace - - set_trace() else: HamsterFactKimaiImport.create(hamster_fact=f, kimai_id=r["id"]).save() @@ -758,8 +776,12 @@ def reset(): @db_.command("sync") -def kimai_db_sync(): - sync() +@click.pass_context +def kimai_db_sync(ctx): + sync( + username=ctx.obj['KIMAI_USERNAME'], + api_key=ctx.obj['KIMAI_API_KEY'] + ) @db_.command() @@ -800,10 +822,15 @@ def mapping2db(mapping_path=None, global_=False): @cli.command() -def app(): +@click.pass_context +def app(ctx): from .app import HamsterToolsApp - app = HamsterToolsApp() + app = HamsterToolsApp( + kimai_api_url=ctx.obj["KIMAI_API_URL"], + kimai_username=ctx.obj["KIMAI_USERNAME"], + kimai_api_key=ctx.obj["KIMAI_API_KEY"] + ) app.run() diff --git a/hamstertools/app.py b/hamstertools/app.py index 3e036e7..5c00d33 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -21,20 +21,23 @@ class HamsterToolsApp(App): @property def api(self) -> KimaiAPI: if self.api_ is None: - self.api_ = KimaiAPI() + self.api_ = KimaiAPI(self.kimai_api_key) return self.api_ - def __init__(self): - self.MODES = { - "hamster": HamsterScreen(), - "kimai": KimaiScreen(), - } + def __init__(self, kimai_api_key): + self.kimai_api_key = kimai_api_key + self.add_mode("hamster", HamsterScreen()) + self.add_mode("kimai", KimaiScreen()) + # self.mode MODES = { + # "hamster": HamsterScreen(), + # "kimai": KimaiScreen(), + # } super().__init__() def on_mount(self) -> None: self.switch_mode("hamster") - def action_quit(self) -> None: + async def action_quit(self) -> None: db.close() self.exit() diff --git a/hamstertools/db.py b/hamstertools/db.py index b0bc465..0362828 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,6 +1,7 @@ from datetime import datetime from peewee import ( + CompositeKey, SqliteDatabase, Model, CharField, @@ -31,6 +32,14 @@ class HamsterActivity(Model): table_name = "activities" +class HamsterTag(Model): + name = CharField() + + class Meta: + database = db + table_name = "tags" + + class HamsterFact(Model): activity = ForeignKeyField(HamsterActivity, backref="facts") start_time = DateTimeField() @@ -42,6 +51,16 @@ class HamsterFact(Model): table_name = "facts" +class HamsterFactTag(Model): + fact = ForeignKeyField(HamsterFact, backref="tags") + tag = ForeignKeyField(HamsterTag, backref="facts") + + class Meta: + database = db + table_name = "fact_tags" + primary_key = CompositeKey('fact', 'tag') + + class KimaiCustomer(Model): visible = BooleanField(default=True) name = CharField() diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 2b49900..3f258fb 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -15,12 +15,10 @@ class KimaiAPI(object): KIMAI_API_URL = "https://kimai.autonomic.zone/api" # KIMAI_API_URL = "https://kimaitest.autonomic.zone/api" - KIMAI_USERNAME = "3wordchant" - KIMAI_API_KEY = os.environ["KIMAI_API_KEY"] - - auth_headers = {"X-AUTH-USER": KIMAI_USERNAME, "X-AUTH-TOKEN": KIMAI_API_KEY} - - def __init__(self): + def __init__(self, username=None, api_key=None): + self.auth_headers = {"X-AUTH-USER": username, "X-AUTH-TOKEN": api_key} + # NOTE: Uncomment the following line to enable requests_cache, which can make development a *lot* faster + # TODO: Add a config option or something for this # requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) self.customers_json = self.get("customers", {"visible": 3}) self.projects_json = self.get("projects", {"visible": 3, "ignoreDates": 1}) diff --git a/hamstertools/sync.py b/hamstertools/sync.py index fad0b42..d8f2080 100644 --- a/hamstertools/sync.py +++ b/hamstertools/sync.py @@ -12,8 +12,8 @@ from .db import ( ) -def sync() -> None: - api = KimaiAPI() +def sync(username, api_key) -> None: + api = KimaiAPI(username, api_key) KimaiCustomer.delete().execute() KimaiProject.delete().execute() diff --git a/requirements.txt b/requirements.txt index c66cd2b..97e3474 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ requests-cache==1.1.1 textual==0.44.1 textual-autocomplete==2.1.0b0 textual-dev==1.2.1 +xdg==6.0.0 From 69d471a8599b6c791958aa4ebfa8170d0b64d4e7 Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Wed, 6 Aug 2025 11:07:45 +0100 Subject: [PATCH 2/2] Working config loading from TOML, env var, cli args --- hamstertools/__init__.py | 36 ++++++++++++++++++------------------ hamstertools/app.py | 12 ++---------- hamstertools/kimaiapi.py | 14 +++++--------- hamstertools/sync.py | 4 +--- 4 files changed, 26 insertions(+), 40 deletions(-) diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index cccd688..aa17f1d 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -42,14 +42,15 @@ db.init(HAMSTER_FILE) @click.group(context_settings={"auto_envvar_prefix": "HAMSTERTOOL"}) @click.option("-d", "--debug", is_flag=True) @click.option("--config", default=CONFIG_FILE, type=click.Path()) -@click.option("--kimai-api-key", envvar="KIMAI_API_KEY") +@click.option("--kimai-api-url", envvar="KIMAI_API_URL") @click.option("--kimai-username", envvar="KIMAI_USERNAME") @click.option("--kimai-api-key", envvar="KIMAI_API_KEY") @click.pass_context -def cli(ctx, config, debug, kimai_api_url, kimai_username, kimai_api_key): +def cli(ctx, config, debug, kimai_api_url=None, kimai_username=None, kimai_api_key=None): + file_config = {} if os.path.exists(config): with open(config, "rb") as f: - ctx.default_map = tomllib.load(f) + file_config = tomllib.load(f) if debug: logging.basicConfig() logging.getLogger().setLevel(logging.DEBUG) @@ -62,6 +63,12 @@ def cli(ctx, config, debug, kimai_api_url, kimai_username, kimai_api_key): requests_log.setLevel(logging.DEBUG) requests_log.propagate = True + ctx.obj = KimaiAPI( + kimai_username if kimai_username is not None else file_config.get("kimai_username"), + kimai_api_key if kimai_api_key is not None else file_config.get("kimai_api_key"), + kimai_api_url if kimai_api_url is not None else file_config.get("kimai_api_url"), + ) + @cli.group() def categories(): @@ -326,12 +333,8 @@ def find_duplicates(): @cli.group() -@click.pass_context -def kimai(ctx, kimai_username=None, kimai_api_key=None): - ctx.ensure_object(dict) - - ctx.obj['KIMAI_USERNAME'] = kimai_username - ctx.obj['KIMAI_API_KEY'] = kimai_api_key +def kimai(): + pass def _get_kimai_mapping_file(path, category_search=None): @@ -776,11 +779,10 @@ def reset(): @db_.command("sync") -@click.pass_context -def kimai_db_sync(ctx): +@click.pass_obj +def kimai_db_sync(api): sync( - username=ctx.obj['KIMAI_USERNAME'], - api_key=ctx.obj['KIMAI_API_KEY'] + api ) @@ -822,14 +824,12 @@ def mapping2db(mapping_path=None, global_=False): @cli.command() -@click.pass_context -def app(ctx): +@click.pass_obj +def app(kimai_api): from .app import HamsterToolsApp app = HamsterToolsApp( - kimai_api_url=ctx.obj["KIMAI_API_URL"], - kimai_username=ctx.obj["KIMAI_USERNAME"], - kimai_api_key=ctx.obj["KIMAI_API_KEY"] + kimai_api ) app.run() diff --git a/hamstertools/app.py b/hamstertools/app.py index 5c00d33..2f4ff15 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -16,22 +16,14 @@ class HamsterToolsApp(App): ("q", "quit", "Quit"), ] - api_ = None - - @property - def api(self) -> KimaiAPI: - if self.api_ is None: - self.api_ = KimaiAPI(self.kimai_api_key) - return self.api_ - - def __init__(self, kimai_api_key): - self.kimai_api_key = kimai_api_key + def __init__(self, kimai_api=None): self.add_mode("hamster", HamsterScreen()) self.add_mode("kimai", KimaiScreen()) # self.mode MODES = { # "hamster": HamsterScreen(), # "kimai": KimaiScreen(), # } + self.api = kimai_api super().__init__() diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 3f258fb..43276db 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -1,7 +1,5 @@ from datetime import datetime import requests -import requests_cache -import os from dataclasses import dataclass, field @@ -11,14 +9,12 @@ class NotFound(Exception): class KimaiAPI(object): - # temporary hardcoded config - KIMAI_API_URL = "https://kimai.autonomic.zone/api" - # KIMAI_API_URL = "https://kimaitest.autonomic.zone/api" - - def __init__(self, username=None, api_key=None): + def __init__(self, username=None, api_key=None, api_url=None): self.auth_headers = {"X-AUTH-USER": username, "X-AUTH-TOKEN": api_key} + self.kimai_api_url = api_url # NOTE: Uncomment the following line to enable requests_cache, which can make development a *lot* faster # TODO: Add a config option or something for this + # import requests_cache # requests_cache.install_cache("kimai", backend="sqlite", expire_after=1800) self.customers_json = self.get("customers", {"visible": 3}) self.projects_json = self.get("projects", {"visible": 3, "ignoreDates": 1}) @@ -27,7 +23,7 @@ class KimaiAPI(object): def get(self, endpoint, params=None): result = requests.get( - f"{self.KIMAI_API_URL}/{endpoint}", params=params, headers=self.auth_headers + f"{self.kimai_api_url}/{endpoint}", params=params, headers=self.auth_headers ).json() try: if result["code"] != 200: @@ -38,7 +34,7 @@ class KimaiAPI(object): def post(self, endpoint, data): return requests.post( - f"{self.KIMAI_API_URL}/{endpoint}", json=data, headers=self.auth_headers + f"{self.kimai_api_url}/{endpoint}", json=data, headers=self.auth_headers ) diff --git a/hamstertools/sync.py b/hamstertools/sync.py index d8f2080..1089ee2 100644 --- a/hamstertools/sync.py +++ b/hamstertools/sync.py @@ -12,9 +12,7 @@ from .db import ( ) -def sync(username, api_key) -> None: - api = KimaiAPI(username, api_key) - +def sync(api) -> None: KimaiCustomer.delete().execute() KimaiProject.delete().execute() KimaiActivity.delete().execute()