diff --git a/.gitignore b/.gitignore index 505a3b1..17942b4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ wheels/ # Virtual environments .venv + +# Local config (contains secrets) +timesheets.toml diff --git a/AGENTS.md b/AGENTS.md index 75195f6..d7fd446 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ timesheets/ ├── parser.py # markdown table parsing, aggregation, date filtering ├── projects.py # project_map.json loading and key resolution ├── 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) └── utils.py # shared low-level helpers (duration parsing, formatting, etc.) ``` @@ -28,6 +29,7 @@ tests/ ├── test_utils.py ├── test_parser.py ├── test_projects.py +├── test_config.py ├── test_output.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 The `--joplin` flag expects the following notebook hierarchy in Joplin: diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index 3b18259..5c7c2ef 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -3,6 +3,7 @@ import os import sys 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 .parser import aggregate_rows, filter_rows_by_date, parse_document 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( "--token", help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.", @@ -68,8 +74,8 @@ def build_parser() -> argparse.ArgumentParser: return parser -def _resolve_token(args: argparse.Namespace) -> str: - token = args.token or os.environ.get("JOPLIN_TOKEN") +def _resolve_token(args: argparse.Namespace, config: dict) -> str: + token = args.token or get_token(config) or os.environ.get("JOPLIN_TOKEN") if not token: print( "Error: Joplin API token required. " @@ -83,6 +89,10 @@ def _resolve_token(args: argparse.Namespace) -> str: def main() -> None: 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: target_date = date.today() else: @@ -101,7 +111,7 @@ def main() -> None: # Late import so joppy is only required when --joplin is used from .joplin import fetch_week_note - token = _resolve_token(args) + token = _resolve_token(args, config) try: content = fetch_week_note(token, target_date) except RuntimeError as e: @@ -129,8 +139,8 @@ def main() -> None: aggregated = aggregate_rows(rows) - # Resolve project map: explicit --map flag, else project_map.json in cwd - map_path = args.map + # Resolve project map: CLI flag > config file > project_map.json in cwd + map_path = args.map or get_map_path(config) if map_path is None: default_map = os.path.join(os.getcwd(), "project_map.json") if os.path.exists(default_map): diff --git a/src/timesheets/config.py b/src/timesheets/config.py new file mode 100644 index 0000000..1f28add --- /dev/null +++ b/src/timesheets/config.py @@ -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") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..aa46dd4 --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/timesheets.example.toml b/timesheets.example.toml new file mode 100644 index 0000000..7b723f9 --- /dev/null +++ b/timesheets.example.toml @@ -0,0 +1,5 @@ +[joplin] +token = "your_api_token_here" + +[projects] +map = "/path/to/project_map.json"