From 9d4cb5937c52a6cc72e786e5411515737ad3f242 Mon Sep 17 00:00:00 2001 From: phanirithvij Date: Thu, 5 Mar 2026 16:01:02 +0530 Subject: [PATCH] Change default datadir location Signed-off-by: phanirithvij --- requirements.txt | 1 + tests/test_config.py | 8 +- tests/test_migrations.py | 87 +++++++++++++++++++ timetagger/_config.py | 21 ++++- .../migrations/001_datadir_default_xdg.py | 51 +++++++++++ timetagger/migrations/__init__.py | 0 timetagger/server/_utils.py | 4 +- 7 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 tests/test_migrations.py create mode 100644 timetagger/migrations/001_datadir_default_xdg.py create mode 100644 timetagger/migrations/__init__.py diff --git a/requirements.txt b/requirements.txt index 9af866fa..b3c1f60c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ jinja2 markdown bcrypt iptools +platformdirs diff --git a/tests/test_config.py b/tests/test_config.py index ae8b461c..170d7f4f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,13 +4,19 @@ from timetagger import config from timetagger._config import set_config +import platformdirs + def test_config(): # Defaults default_bind = "127.0.0.1:8080" set_config([], {}) + + expected_default = str( + platformdirs.user_data_path(appname="timetagger", appauthor="Klein").resolve() + ) assert config.bind == default_bind - assert config.datadir == "~/_timetagger" + assert config.datadir == expected_default # argv set_config(["--bind=localhost:8080"], {}) diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 00000000..35ec32be --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,87 @@ +import importlib +import logging +from _common import run_tests + + +def test_migration_001_happy_path(tmp_path, monkeypatch): + """Test standard migration where old exists and new does not.""" + mig = importlib.import_module("timetagger.migrations.001_datadir_default_xdg") + + old_dir = tmp_path / "old" / "_timetagger" + new_dir = tmp_path / "new" / "timetagger" + + monkeypatch.setattr(mig, "get_old_default_dir", lambda: old_dir) + monkeypatch.setattr(mig, "get_new_default_dir", lambda: new_dir) + + old_dir.mkdir(parents=True) + (old_dir / "jwt.key").write_text("fake key") + + mig.run(str(new_dir)) + + assert not old_dir.exists() + assert new_dir.exists() + assert (new_dir / "jwt.key").read_text() == "fake key" + + +def test_migration_001_custom_path_skipped(tmp_path, monkeypatch): + """Test that custom config dirs cause the migration to safely abort.""" + mig = importlib.import_module("timetagger.migrations.001_datadir_default_xdg") + + old_dir = tmp_path / "old" / "_timetagger" + new_dir = tmp_path / "new" / "timetagger" + custom_dir = tmp_path / "custom" / "timetagger" + + monkeypatch.setattr(mig, "get_old_default_dir", lambda: old_dir) + monkeypatch.setattr(mig, "get_new_default_dir", lambda: new_dir) + + old_dir.mkdir(parents=True) + mig.run(str(custom_dir)) + + assert old_dir.exists() + assert not new_dir.exists() + + +def test_migration_001_conflict_aborts(tmp_path, monkeypatch, caplog): + """Test that migration aborts if BOTH dirs exist and new has files.""" + mig = importlib.import_module("timetagger.migrations.001_datadir_default_xdg") + + old_dir = tmp_path / "old" / "_timetagger" + new_dir = tmp_path / "new" / "timetagger" + + monkeypatch.setattr(mig, "get_old_default_dir", lambda: old_dir) + monkeypatch.setattr(mig, "get_new_default_dir", lambda: new_dir) + + old_dir.mkdir(parents=True) + new_dir.mkdir(parents=True) + (new_dir / "jwt.key").touch() + + with caplog.at_level(logging.WARNING): + mig.run(str(new_dir)) + + assert old_dir.exists() + assert new_dir.exists() + assert "Skipping automatic migration" in caplog.text + + +def test_migration_001_new_empty_proceeds(tmp_path, monkeypatch): + """Test that an accidentally created EMPTY new dir is removed safely to allow move.""" + mig = importlib.import_module("timetagger.migrations.001_datadir_default_xdg") + + old_dir = tmp_path / "old" / "_timetagger" + new_dir = tmp_path / "new" / "timetagger" + + monkeypatch.setattr(mig, "get_old_default_dir", lambda: old_dir) + monkeypatch.setattr(mig, "get_new_default_dir", lambda: new_dir) + + old_dir.mkdir(parents=True) + (old_dir / "jwt.key").touch() + new_dir.mkdir(parents=True) + + mig.run(str(new_dir)) + + assert not old_dir.exists() + assert (new_dir / "jwt.key").exists() + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/timetagger/_config.py b/timetagger/_config.py index a5d459ec..af67e03c 100644 --- a/timetagger/_config.py +++ b/timetagger/_config.py @@ -1,5 +1,7 @@ import os import sys +import importlib +import platformdirs def to_bool(value): @@ -24,7 +26,11 @@ class Config: """Object that holds config values. * `bind (str)`: the address and port to bind on. Default "127.0.0.1:8080". - * `datadir (str)`: the directory to store data. Default "~/_timetagger". + * `datadir (str)`: the directory to store data. + Default depends on the OS + - ~/.local/share/timetagger + - ~/Library/Application Support/timetagger + - C:\\Users\\\\AppData\\Local\\Klein\\timetagger The user db's are stored in `datadir/users`. * `log_level (str)`: the log level for timetagger and asgineer (not the asgi server). Default "info". @@ -57,7 +63,13 @@ class Config: _ITEMS = [ ("bind", str, "127.0.0.1:8080"), - ("datadir", str, "~/_timetagger"), + ( + "datadir", + str, + os.path.realpath( + platformdirs.user_data_dir(appname="timetagger", appauthor="Klein") + ), + ), ("log_level", str, "info"), ("credentials", str, ""), ("proxy_auth_enabled", to_bool, False), @@ -123,3 +135,8 @@ def _update_config_from_env(env): # Init config set_config() + +# Run datadir migration +importlib.import_module("timetagger.migrations.001_datadir_default_xdg").run( + config.datadir +) diff --git a/timetagger/migrations/001_datadir_default_xdg.py b/timetagger/migrations/001_datadir_default_xdg.py new file mode 100644 index 00000000..36dbe01d --- /dev/null +++ b/timetagger/migrations/001_datadir_default_xdg.py @@ -0,0 +1,51 @@ +import shutil +import logging +from pathlib import Path +import platformdirs + + +def get_new_default_dir() -> Path: + return platformdirs.user_data_path( + appname="timetagger", appauthor="Klein" + ).resolve() + + +def get_old_default_dir() -> Path: + return (Path.home() / "_timetagger").resolve() + + +def run(datadir: str): + new_default = get_new_default_dir() + old_default = get_old_default_dir() + + current_cfg = Path(datadir).expanduser().resolve() + + # skip if user is using some custom non-default path + if current_cfg != new_default: + return + + # skip if there is no old directory to migrate + if not old_default.exists() or not old_default.is_dir(): + return + + # handle conflicts if the new directory already exists + if new_default.exists(): + if any(new_default.iterdir()): + logging.warning( + f"Notice: Both old ({old_default}) and new ({new_default}) " + "data directories exist. Skipping automatic migration to prevent overwriting." + ) + return + else: + # if it's completely empty, safely remove it to allow the move + new_default.rmdir() + + try: + logging.info( + f"Migrating data from {old_default} to standard location: {new_default}..." + ) + new_default.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(old_default), str(new_default)) + logging.info("Data directory migration successful.") + except Exception as e: + logging.error(f"Failed to migrate data directory: {e}") diff --git a/timetagger/migrations/__init__.py b/timetagger/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/timetagger/server/_utils.py b/timetagger/server/_utils.py index e5e0add8..45c8f1e3 100644 --- a/timetagger/server/_utils.py +++ b/timetagger/server/_utils.py @@ -13,10 +13,10 @@ from .. import config # Init directory paths -ROOT_TT_DIR = os.path.expanduser(config.datadir) +ROOT_TT_DIR = config.datadir ROOT_USER_DIR = os.path.join(ROOT_TT_DIR, "users") if not os.path.isdir(ROOT_USER_DIR): - os.makedirs(ROOT_USER_DIR) + os.makedirs(ROOT_USER_DIR, exist_ok=True) # Init logger logger = logging.getLogger("asgineer")