feat(config): add TOML config file support

- Add config.py with load_config(), find_default_config(), get_token(),
  and get_map_path()
- Auto-discover timesheets.toml in cwd; override with --config flag
- Priority: CLI flag > config file > env var / cwd default
- Add timesheets.example.toml as a committed reference template
- Add timesheets.toml to .gitignore to prevent accidental secret leakage
- Document config file format in AGENTS.md
This commit is contained in:
Jef Roosens 2026-05-22 10:47:05 +02:00
parent 267ad5b1b5
commit 29698b1241
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
6 changed files with 183 additions and 5 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
# Local config (contains secrets)
timesheets.toml

View file

@ -17,6 +17,7 @@ timesheets/
├── parser.py # markdown table parsing, aggregation, date filtering ├── parser.py # markdown table parsing, aggregation, date filtering
├── projects.py # project_map.json loading and key resolution ├── projects.py # project_map.json loading and key resolution
├── output.py # CSV writing and summary printing ├── output.py # CSV writing and summary printing
├── config.py # TOML config file loading and key extraction
├── joplin.py # Joplin API integration (notebook traversal, note fetching) ├── joplin.py # Joplin API integration (notebook traversal, note fetching)
└── utils.py # shared low-level helpers (duration parsing, formatting, etc.) └── utils.py # shared low-level helpers (duration parsing, formatting, etc.)
``` ```
@ -28,6 +29,7 @@ tests/
├── test_utils.py ├── test_utils.py
├── test_parser.py ├── test_parser.py
├── test_projects.py ├── test_projects.py
├── test_config.py
├── test_output.py ├── test_output.py
└── test_joplin.py └── test_joplin.py
``` ```
@ -95,6 +97,22 @@ The API token can be provided via:
--- ---
## Config file
Create `timesheets.toml` in your working directory (or pass `--config /path/to/file.toml`):
```toml
[joplin]
token = "your_api_token_here"
[projects]
map = "/path/to/project_map.json"
```
Priority order for each value: **CLI flag > config file > environment variable / default**.
---
## Joplin notebook structure ## Joplin notebook structure
The `--joplin` flag expects the following notebook hierarchy in Joplin: The `--joplin` flag expects the following notebook hierarchy in Joplin:

View file

@ -3,6 +3,7 @@ import os
import sys import sys
from datetime import date from datetime import date
from .config import find_default_config, get_map_path, get_token, load_config
from .output import print_summary, write_csv from .output import print_summary, write_csv
from .parser import aggregate_rows, filter_rows_by_date, parse_document from .parser import aggregate_rows, filter_rows_by_date, parse_document
from .projects import load_project_map from .projects import load_project_map
@ -32,6 +33,11 @@ def build_parser() -> argparse.ArgumentParser:
), ),
) )
parser.add_argument(
"--config",
help="Path to a TOML config file. Defaults to timesheets.toml in the current working directory if it exists.",
default=None,
)
parser.add_argument( parser.add_argument(
"--token", "--token",
help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.", help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.",
@ -68,8 +74,8 @@ def build_parser() -> argparse.ArgumentParser:
return parser return parser
def _resolve_token(args: argparse.Namespace) -> str: def _resolve_token(args: argparse.Namespace, config: dict) -> str:
token = args.token or os.environ.get("JOPLIN_TOKEN") token = args.token or get_token(config) or os.environ.get("JOPLIN_TOKEN")
if not token: if not token:
print( print(
"Error: Joplin API token required. " "Error: Joplin API token required. "
@ -83,6 +89,10 @@ def _resolve_token(args: argparse.Namespace) -> str:
def main() -> None: def main() -> None:
args = build_parser().parse_args() args = build_parser().parse_args()
# Load config file: explicit --config flag, else auto-discover timesheets.toml
config_path = args.config if args.config is not None else find_default_config()
config = load_config(config_path)
if args.day is None: if args.day is None:
target_date = date.today() target_date = date.today()
else: else:
@ -101,7 +111,7 @@ def main() -> None:
# Late import so joppy is only required when --joplin is used # Late import so joppy is only required when --joplin is used
from .joplin import fetch_week_note from .joplin import fetch_week_note
token = _resolve_token(args) token = _resolve_token(args, config)
try: try:
content = fetch_week_note(token, target_date) content = fetch_week_note(token, target_date)
except RuntimeError as e: except RuntimeError as e:
@ -129,8 +139,8 @@ def main() -> None:
aggregated = aggregate_rows(rows) aggregated = aggregate_rows(rows)
# Resolve project map: explicit --map flag, else project_map.json in cwd # Resolve project map: CLI flag > config file > project_map.json in cwd
map_path = args.map map_path = args.map or get_map_path(config)
if map_path is None: if map_path is None:
default_map = os.path.join(os.getcwd(), "project_map.json") default_map = os.path.join(os.getcwd(), "project_map.json")
if os.path.exists(default_map): if os.path.exists(default_map):

54
src/timesheets/config.py Normal file
View file

@ -0,0 +1,54 @@
"""
TOML config file support.
Default filename: timesheets.toml in the current working directory.
Supported keys:
[joplin]
token = "..."
[projects]
map = "/path/to/project_map.json"
"""
import sys
import tomllib
from pathlib import Path
DEFAULT_CONFIG_FILENAME = "timesheets.toml"
def find_default_config() -> Path | None:
"""Return the default config path if it exists, else None."""
p = Path.cwd() / DEFAULT_CONFIG_FILENAME
return p if p.exists() else None
def load_config(path: Path | str | None) -> dict:
"""
Load a TOML config file and return its contents as a dict.
Returns an empty dict if path is None.
Exits with an error message if the file is missing or malformed.
"""
if path is None:
return {}
p = Path(path)
try:
with p.open("rb") as f:
return tomllib.load(f)
except FileNotFoundError:
print(f"Error: config file not found: {p}", file=sys.stderr)
sys.exit(1)
except tomllib.TOMLDecodeError as e:
print(f"Error: could not parse config file {p}: {e}", file=sys.stderr)
sys.exit(1)
def get_token(config: dict) -> str | None:
"""Extract joplin.token from a loaded config dict, or None."""
return config.get("joplin", {}).get("token")
def get_map_path(config: dict) -> str | None:
"""Extract projects.map from a loaded config dict, or None."""
return config.get("projects", {}).get("map")

88
tests/test_config.py Normal file
View file

@ -0,0 +1,88 @@
import sys
from pathlib import Path
import pytest
from timesheets.config import (
DEFAULT_CONFIG_FILENAME,
find_default_config,
get_map_path,
get_token,
load_config,
)
# ---------------------------------------------------------------------------
# load_config
# ---------------------------------------------------------------------------
class TestLoadConfig:
def test_none_returns_empty(self):
assert load_config(None) == {}
def test_loads_valid_toml(self, tmp_path):
f = tmp_path / "timesheets.toml"
f.write_text('[joplin]\ntoken = "abc123"\n')
result = load_config(f)
assert result == {"joplin": {"token": "abc123"}}
def test_missing_file_exits(self, tmp_path, capsys):
with pytest.raises(SystemExit):
load_config(tmp_path / "nonexistent.toml")
assert "not found" in capsys.readouterr().err
def test_invalid_toml_exits(self, tmp_path, capsys):
f = tmp_path / "bad.toml"
f.write_text("not = valid = toml\n")
with pytest.raises(SystemExit):
load_config(f)
assert "could not parse" in capsys.readouterr().err
def test_accepts_str_path(self, tmp_path):
f = tmp_path / "timesheets.toml"
f.write_text('[projects]\nmap = "/some/path.json"\n')
result = load_config(str(f))
assert result["projects"]["map"] == "/some/path.json"
# ---------------------------------------------------------------------------
# find_default_config
# ---------------------------------------------------------------------------
class TestFindDefaultConfig:
def test_returns_none_when_absent(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert find_default_config() is None
def test_returns_path_when_present(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
cfg = tmp_path / DEFAULT_CONFIG_FILENAME
cfg.write_text("")
result = find_default_config()
assert result == cfg
# ---------------------------------------------------------------------------
# get_token / get_map_path
# ---------------------------------------------------------------------------
class TestGetters:
def test_get_token_present(self):
assert get_token({"joplin": {"token": "xyz"}}) == "xyz"
def test_get_token_missing_section(self):
assert get_token({}) is None
def test_get_token_missing_key(self):
assert get_token({"joplin": {}}) is None
def test_get_map_path_present(self):
assert get_map_path({"projects": {"map": "/a/b.json"}}) == "/a/b.json"
def test_get_map_path_missing_section(self):
assert get_map_path({}) is None
def test_get_map_path_missing_key(self):
assert get_map_path({"projects": {}}) is None

5
timesheets.example.toml Normal file
View file

@ -0,0 +1,5 @@
[joplin]
token = "your_api_token_here"
[projects]
map = "/path/to/project_map.json"