From 7feddbdebdb60193deeb83a07ef8283e52f0fb04 Mon Sep 17 00:00:00 2001 From: Eleni Markou Date: Tue, 12 May 2026 21:31:34 +0300 Subject: [PATCH 1/6] feat: respect $XDG_CONFIG_HOME for config directory Default config location is now $XDG_CONFIG_HOME/devskim (falling back to ~/.config/devskim per XDG spec). Existing installs with ~/.devskim are detected automatically and continue to work unchanged. --- README.md | 6 +++--- devskim/config.py | 18 ++++++++++++++++-- tests/test_config.py | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ea49014..e2b4bbc 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,10 @@ pip install -e . --- -On first run, a config file is created at `~/.devskim/config.toml`. Edit it to change subreddits: +On first run, a config file is created at `$XDG_CONFIG_HOME/devskim/config.toml` (defaults to `~/.config/devskim/config.toml`). Edit it to change subreddits: ```bash -nano ~/.devskim/config.toml +nano ~/.config/devskim/config.toml ``` ```toml @@ -108,7 +108,7 @@ Run `devskim` — changes take effect on next launch or press `r` to refresh. ## Config -`~/.devskim/config.toml` — created on first run with defaults. +`$XDG_CONFIG_HOME/devskim/config.toml` (default: `~/.config/devskim/config.toml`) — created on first run with defaults. If `~/.devskim` exists and the XDG path does not, the legacy location is used automatically. | Key | Default | Description | |-----|---------|-------------| diff --git a/devskim/config.py b/devskim/config.py index cb92c50..3cd95cd 100644 --- a/devskim/config.py +++ b/devskim/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import time try: @@ -10,7 +11,20 @@ from dataclasses import dataclass, field from pathlib import Path -CONFIG_DIR = Path.home() / ".devskim" + +def _resolve_config_dir() -> Path: + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + return Path(xdg) / "devskim" + xdg_default = Path.home() / ".config" / "devskim" + legacy = Path.home() / ".devskim" + # Keep existing installs working if they have ~/.devskim and no XDG dir yet. + if legacy.exists() and not xdg_default.exists(): + return legacy + return xdg_default + + +CONFIG_DIR = _resolve_config_dir() CONFIG_PATH = CONFIG_DIR / "config.toml" CACHE_PATH = CONFIG_DIR / "cache.json" @@ -28,7 +42,7 @@ @dataclass class Config: - """Runtime settings loaded from ~/.devskim/config.toml.""" + """Runtime settings loaded from $XDG_CONFIG_HOME/devskim/config.toml.""" subreddits: list[str] = field( default_factory=lambda: ["programming", "python", "machinelearning"] diff --git a/tests/test_config.py b/tests/test_config.py index 9b9bdc4..e73a4e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,8 +1,47 @@ import json +import os import time from unittest.mock import patch -from devskim.config import Config, load_cache, load_config, save_cache +from devskim.config import Config, _resolve_config_dir, load_cache, load_config, save_cache + + +def test_resolve_config_dir_xdg_env(tmp_path): + with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}, clear=False): + result = _resolve_config_dir() + assert result == tmp_path / "devskim" + + +def test_resolve_config_dir_xdg_default(tmp_path): + env = {k: v for k, v in os.environ.items() if k != "XDG_CONFIG_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_home = tmp_path + mock_path.home.return_value = mock_home + result = _resolve_config_dir() + assert result == mock_home / ".config" / "devskim" + + +def test_resolve_config_dir_legacy_fallback(tmp_path): + legacy = tmp_path / ".devskim" + legacy.mkdir() + env = {k: v for k, v in os.environ.items() if k != "XDG_CONFIG_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_path.home.return_value = tmp_path + result = _resolve_config_dir() + assert result == legacy + + +def test_resolve_config_dir_xdg_takes_priority_over_legacy(tmp_path): + legacy = tmp_path / ".devskim" + legacy.mkdir() + xdg_dir = tmp_path / "xdg" + xdg_dir.mkdir() + (xdg_dir / "devskim").mkdir() + with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg_dir)}, clear=False): + result = _resolve_config_dir() + assert result == xdg_dir / "devskim" def test_config_defaults(): From 9458fe97afd72508e4845630c60f5b0a620914a4 Mon Sep 17 00:00:00 2001 From: Eleni Markou Date: Tue, 12 May 2026 21:42:56 +0300 Subject: [PATCH 2/6] fix: ignore non-absolute XDG_CONFIG_HOME values --- devskim/config.py | 2 +- tests/test_config.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/devskim/config.py b/devskim/config.py index 3cd95cd..d3cd776 100644 --- a/devskim/config.py +++ b/devskim/config.py @@ -14,7 +14,7 @@ def _resolve_config_dir() -> Path: xdg = os.environ.get("XDG_CONFIG_HOME") - if xdg: + if xdg and Path(xdg).is_absolute(): return Path(xdg) / "devskim" xdg_default = Path.home() / ".config" / "devskim" legacy = Path.home() / ".devskim" diff --git a/tests/test_config.py b/tests/test_config.py index e73a4e8..2ba0837 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,12 @@ def test_resolve_config_dir_xdg_env(tmp_path): assert result == tmp_path / "devskim" +def test_resolve_config_dir_xdg_relative_ignored(): + with patch.dict(os.environ, {"XDG_CONFIG_HOME": "relative/path"}, clear=False): + result = _resolve_config_dir() + assert not str(result).startswith("relative") + + def test_resolve_config_dir_xdg_default(tmp_path): env = {k: v for k, v in os.environ.items() if k != "XDG_CONFIG_HOME"} with patch.dict(os.environ, env, clear=True): From 5af96afecd2f72b9e0ab0a598db128c54f758bbc Mon Sep 17 00:00:00 2001 From: Eleni Markou Date: Tue, 12 May 2026 21:44:10 +0300 Subject: [PATCH 3/6] feat: use $XDG_CACHE_HOME for cache directory Cache files now live under $XDG_CACHE_HOME/devskim (default ~/.cache/devskim) instead of the config directory. Adds _resolve_cache_dir() mirroring the existing config resolver pattern. --- devskim/config.py | 12 ++++++++++-- tests/test_config.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/devskim/config.py b/devskim/config.py index d3cd776..171e710 100644 --- a/devskim/config.py +++ b/devskim/config.py @@ -24,9 +24,17 @@ def _resolve_config_dir() -> Path: return xdg_default +def _resolve_cache_dir() -> Path: + xdg = os.environ.get("XDG_CACHE_HOME") + if xdg and Path(xdg).is_absolute(): + return Path(xdg) / "devskim" + return Path.home() / ".cache" / "devskim" + + CONFIG_DIR = _resolve_config_dir() CONFIG_PATH = CONFIG_DIR / "config.toml" -CACHE_PATH = CONFIG_DIR / "cache.json" +CACHE_DIR = _resolve_cache_dir() +CACHE_PATH = CACHE_DIR / "cache.json" DEFAULT_CONFIG = """\ subreddits = ["programming", "ClaudeAI", "machinelearning"] @@ -93,7 +101,7 @@ def load_cache(ttl_minutes: int) -> list[dict] | None: def save_cache(items: list[dict]) -> None: """Write feed items to disk with a current timestamp.""" try: - CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_PATH.write_text(json.dumps({"ts": time.time(), "items": items})) except Exception: pass diff --git a/tests/test_config.py b/tests/test_config.py index 2ba0837..d916486 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,14 @@ import time from unittest.mock import patch -from devskim.config import Config, _resolve_config_dir, load_cache, load_config, save_cache +from devskim.config import ( + Config, + _resolve_cache_dir, + _resolve_config_dir, + load_cache, + load_config, + save_cache, +) def test_resolve_config_dir_xdg_env(tmp_path): @@ -114,7 +121,7 @@ def test_save_and_reload_cache(tmp_path): items = [{"title": "test", "source": "HN"}] with ( patch("devskim.config.CACHE_PATH", cache_path), - patch("devskim.config.CONFIG_DIR", tmp_path), + patch("devskim.config.CACHE_DIR", tmp_path), ): save_cache(items) result = load_cache(10) @@ -126,8 +133,29 @@ def test_save_cache_writes_timestamp(tmp_path): before = time.time() with ( patch("devskim.config.CACHE_PATH", cache_path), - patch("devskim.config.CONFIG_DIR", tmp_path), + patch("devskim.config.CACHE_DIR", tmp_path), ): save_cache([]) data = json.loads(cache_path.read_text()) assert abs(data["ts"] - before) < 5 + + +def test_resolve_cache_dir_xdg_env(tmp_path): + with patch.dict(os.environ, {"XDG_CACHE_HOME": str(tmp_path)}, clear=False): + result = _resolve_cache_dir() + assert result == tmp_path / "devskim" + + +def test_resolve_cache_dir_xdg_relative_ignored(): + with patch.dict(os.environ, {"XDG_CACHE_HOME": "relative/cache"}, clear=False): + result = _resolve_cache_dir() + assert not str(result).startswith("relative") + + +def test_resolve_cache_dir_default(tmp_path): + env = {k: v for k, v in os.environ.items() if k != "XDG_CACHE_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_path.home.return_value = tmp_path + result = _resolve_cache_dir() + assert result == tmp_path / ".cache" / "devskim" From 3fc48ce10ae0a7506a9fbd59ff67f1ac15028de2 Mon Sep 17 00:00:00 2001 From: Eleni Markou Date: Tue, 12 May 2026 21:46:56 +0300 Subject: [PATCH 4/6] feat: move seen.json to $XDG_DATA_HOME/devskim Persistent read-state belongs under XDG_DATA_HOME (~/.local/share/devskim) not the config directory. Adds _resolve_data_dir() and DATA_DIR constant, updates seen.py to use DATA_DIR. --- devskim/config.py | 8 ++++++++ devskim/seen.py | 6 +++--- tests/test_config.py | 22 ++++++++++++++++++++++ tests/test_seen.py | 10 +++++----- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/devskim/config.py b/devskim/config.py index 171e710..65cef45 100644 --- a/devskim/config.py +++ b/devskim/config.py @@ -31,10 +31,18 @@ def _resolve_cache_dir() -> Path: return Path.home() / ".cache" / "devskim" +def _resolve_data_dir() -> Path: + xdg = os.environ.get("XDG_DATA_HOME") + if xdg and Path(xdg).is_absolute(): + return Path(xdg) / "devskim" + return Path.home() / ".local" / "share" / "devskim" + + CONFIG_DIR = _resolve_config_dir() CONFIG_PATH = CONFIG_DIR / "config.toml" CACHE_DIR = _resolve_cache_dir() CACHE_PATH = CACHE_DIR / "cache.json" +DATA_DIR = _resolve_data_dir() DEFAULT_CONFIG = """\ subreddits = ["programming", "ClaudeAI", "machinelearning"] diff --git a/devskim/seen.py b/devskim/seen.py index 6e2a129..1cdd41b 100644 --- a/devskim/seen.py +++ b/devskim/seen.py @@ -3,9 +3,9 @@ import json import time -from .config import CONFIG_DIR +from .config import DATA_DIR -SEEN_PATH = CONFIG_DIR / "seen.json" +SEEN_PATH = DATA_DIR / "seen.json" SEEN_TTL_SECONDS = 86_400 # 1 day @@ -44,7 +44,7 @@ def load_seen() -> set[str]: def mark_seen(post_id: str) -> bool: """Record post_id as seen, pruning entries older than the TTL. Returns False on write failure.""" try: - CONFIG_DIR.mkdir(parents=True, exist_ok=True) + DATA_DIR.mkdir(parents=True, exist_ok=True) cutoff = time.time() - SEEN_TTL_SECONDS data = {pid: ts for pid, ts in _load_raw().items() if ts >= cutoff} data[post_id] = time.time() diff --git a/tests/test_config.py b/tests/test_config.py index d916486..1980772 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,6 +7,7 @@ Config, _resolve_cache_dir, _resolve_config_dir, + _resolve_data_dir, load_cache, load_config, save_cache, @@ -159,3 +160,24 @@ def test_resolve_cache_dir_default(tmp_path): mock_path.home.return_value = tmp_path result = _resolve_cache_dir() assert result == tmp_path / ".cache" / "devskim" + + +def test_resolve_data_dir_xdg_env(tmp_path): + with patch.dict(os.environ, {"XDG_DATA_HOME": str(tmp_path)}, clear=False): + result = _resolve_data_dir() + assert result == tmp_path / "devskim" + + +def test_resolve_data_dir_xdg_relative_ignored(): + with patch.dict(os.environ, {"XDG_DATA_HOME": "relative/data"}, clear=False): + result = _resolve_data_dir() + assert not str(result).startswith("relative") + + +def test_resolve_data_dir_default(tmp_path): + env = {k: v for k, v in os.environ.items() if k != "XDG_DATA_HOME"} + with patch.dict(os.environ, env, clear=True): + with patch("devskim.config.Path") as mock_path: + mock_path.home.return_value = tmp_path + result = _resolve_data_dir() + assert result == tmp_path / ".local" / "share" / "devskim" diff --git a/tests/test_seen.py b/tests/test_seen.py index 1e3b4cb..96733ec 100644 --- a/tests/test_seen.py +++ b/tests/test_seen.py @@ -114,7 +114,7 @@ def test_load_seen_empty_file(monkeypatch, tmp_path): def test_mark_seen_creates_file(monkeypatch, tmp_path): seen_path = tmp_path / "seen.json" monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) assert mark_seen("HN:42") is True assert seen_path.exists() @@ -122,7 +122,7 @@ def test_mark_seen_creates_file(monkeypatch, tmp_path): def test_mark_seen_persists_id(monkeypatch, tmp_path): seen_path = tmp_path / "seen.json" monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) mark_seen("HN:99") data = json.loads(seen_path.read_text()) assert "HN:99" in data @@ -133,7 +133,7 @@ def test_mark_seen_prunes_expired(monkeypatch, tmp_path): old_ts = time.time() - 90_000 seen_path.write_text(json.dumps({"HN:old": old_ts})) monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) mark_seen("HN:new") data = json.loads(seen_path.read_text()) assert "HN:old" not in data @@ -145,7 +145,7 @@ def test_mark_seen_overwrites_existing_id(monkeypatch, tmp_path): old_ts = time.time() - 100 seen_path.write_text(json.dumps({"HN:1": old_ts})) monkeypatch.setattr(seen_module, "SEEN_PATH", seen_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) mark_seen("HN:1") data = json.loads(seen_path.read_text()) assert data["HN:1"] > old_ts @@ -154,5 +154,5 @@ def test_mark_seen_overwrites_existing_id(monkeypatch, tmp_path): def test_mark_seen_returns_false_on_write_failure(monkeypatch, tmp_path): # SEEN_PATH points to an existing directory — write_text will fail monkeypatch.setattr(seen_module, "SEEN_PATH", tmp_path) - monkeypatch.setattr(seen_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(seen_module, "DATA_DIR", tmp_path) assert mark_seen("HN:42") is False From 4fdbfb133ee4debef82725007cff4f527aa766b2 Mon Sep 17 00:00:00 2001 From: Eleni Markou Date: Wed, 13 May 2026 11:08:39 +0300 Subject: [PATCH 5/6] docs: use XDG shell fallback syntax in README config paths --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2b4bbc..79b05c2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - Split view with post body / repo stats and threaded comments or README side by side - Filter feed by source, refresh on demand, paginate with `m` - Keyword search with `/`, copy URL with `y`, mark seen posts dimmed automatically -- Config file at `~/.devskim/config.toml` — created automatically on first run +- Config file at `${XDG_CONFIG_HOME:-~/.config}/devskim/config.toml` — created automatically on first run ## Install @@ -60,7 +60,7 @@ pip install -e . On first run, a config file is created at `$XDG_CONFIG_HOME/devskim/config.toml` (defaults to `~/.config/devskim/config.toml`). Edit it to change subreddits: ```bash -nano ~/.config/devskim/config.toml +nano "${XDG_CONFIG_HOME:-~/.config}/devskim/config.toml" ``` ```toml From 4bada1a28c982f1072fb81df1c2432c05145cf42 Mon Sep 17 00:00:00 2001 From: Eleni Markou Date: Wed, 13 May 2026 11:11:51 +0300 Subject: [PATCH 6/6] fix: require legacy config path to be a dir; assert absolute paths in tests --- devskim/config.py | 2 +- tests/test_config.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/devskim/config.py b/devskim/config.py index 65cef45..32ba09a 100644 --- a/devskim/config.py +++ b/devskim/config.py @@ -19,7 +19,7 @@ def _resolve_config_dir() -> Path: xdg_default = Path.home() / ".config" / "devskim" legacy = Path.home() / ".devskim" # Keep existing installs working if they have ~/.devskim and no XDG dir yet. - if legacy.exists() and not xdg_default.exists(): + if legacy.is_dir() and not xdg_default.exists(): return legacy return xdg_default diff --git a/tests/test_config.py b/tests/test_config.py index 1980772..2b93e26 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,7 +23,7 @@ def test_resolve_config_dir_xdg_env(tmp_path): def test_resolve_config_dir_xdg_relative_ignored(): with patch.dict(os.environ, {"XDG_CONFIG_HOME": "relative/path"}, clear=False): result = _resolve_config_dir() - assert not str(result).startswith("relative") + assert result.is_absolute() def test_resolve_config_dir_xdg_default(tmp_path): @@ -150,7 +150,7 @@ def test_resolve_cache_dir_xdg_env(tmp_path): def test_resolve_cache_dir_xdg_relative_ignored(): with patch.dict(os.environ, {"XDG_CACHE_HOME": "relative/cache"}, clear=False): result = _resolve_cache_dir() - assert not str(result).startswith("relative") + assert result.is_absolute() def test_resolve_cache_dir_default(tmp_path): @@ -171,7 +171,7 @@ def test_resolve_data_dir_xdg_env(tmp_path): def test_resolve_data_dir_xdg_relative_ignored(): with patch.dict(os.environ, {"XDG_DATA_HOME": "relative/data"}, clear=False): result = _resolve_data_dir() - assert not str(result).startswith("relative") + assert result.is_absolute() def test_resolve_data_dir_default(tmp_path):