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
|
# Virtual environments
|
||||||
.venv
|
.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
|
├── 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:
|
||||||
|
|
|
||||||
|
|
@ -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
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