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:
parent
267ad5b1b5
commit
29698b1241
6 changed files with 183 additions and 5 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ wheels/
|
|||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Local config (contains secrets)
|
||||
timesheets.toml
|
||||
|
|
|
|||
18
AGENTS.md
18
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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
54
src/timesheets/config.py
Normal file
54
src/timesheets/config.py
Normal 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
88
tests/test_config.py
Normal 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
5
timesheets.example.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[joplin]
|
||||
token = "your_api_token_here"
|
||||
|
||||
[projects]
|
||||
map = "/path/to/project_map.json"
|
||||
Loading…
Add table
Add a link
Reference in a new issue