diff --git a/topen.py b/topen.py index 1da9452..dcfb350 100755 --- a/topen.py +++ b/topen.py @@ -12,7 +12,7 @@ You can make use of the open editor and utility functions to find and edit notes, either filling in the required configuration manually or passing around a TConf configuration object containing them all. If choosing the latter, you can read the configuration in part from a `taskrc` file using the utility function -`parse_rc()`. +`parse_conf()`. """ @@ -21,6 +21,7 @@ import configparser import os import subprocess import sys +from collections import namedtuple from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Self @@ -30,7 +31,7 @@ from tasklib import Task, TaskWarrior NON_EXISTENT_PATH = Path("%%%%I_DONT_EXIST_%%%%") -def main(cfg: "TConf | None" = None): +def main(): """Runs the cli interface. First sets up the correct options, with overrides in the following order: @@ -43,8 +44,10 @@ def main(cfg: "TConf | None" = None): If the task does not yet have a note annotation it also adds it automatically. """ - if not cfg: - cfg = build_config() + opts_override = {"task_rc": TConf(0).task_rc} | parse_env() | parse_cli() + conf_file = _real_path(opts_override["task_rc"]) + opts: dict = parse_conf(conf_file) | opts_override + cfg = TConf.from_dict(opts) if not cfg.task_id: _ = sys.stderr.write("Please provide task ID as argument.\n") @@ -109,83 +112,6 @@ def add_annotation_if_missing(task: Task, annotation_content: str) -> None: _ = whisper(f"Added annotation: {annotation_content}") -@dataclass() -class Opt: - """Assembled metadata for a single configuration option.""" - - cli: tuple[str, ...] | None - env: str | None - rc: str | None - default: Any = None - metavar: str | None = None - cast: type = str - help_text: str = "" - - -OPTIONS: dict[str, Opt] = { - "task_id": Opt(None, None, None, default=None), - "task_rc": Opt( - ("--task-rc",), - "TASKRC", - None, # taskrc has no key for this - default=Path("~/.taskrc"), - metavar="FILE", - cast=Path, - help_text="Location of taskwarrior config file", - ), - "task_data": Opt( - ("--task-data",), - "TASKDATA", - "data.location", - default=Path("~/.task"), - metavar="DIR", - cast=Path, - help_text="Location of taskwarrior data directory", - ), - "notes_dir": Opt( - ("-d", "--notes-dir"), - "TOPEN_NOTES_DIR", - "notes.dir", - default=None, # resolved later in TConf.__post_init__ - metavar="DIR", - cast=Path, - help_text="Location of topen notes files", - ), - "notes_ext": Opt( - ("--extension",), - "TOPEN_NOTES_EXT", - "notes.ext", - default="md", - metavar="EXT", - help_text="Extension of note files", - ), - "notes_annot": Opt( - ("--annotation",), - "TOPEN_NOTES_ANNOT", - "notes.annot", - default="Note", - metavar="NOTE", - help_text="Annotation content to set within taskwarrior", - ), - "notes_editor": Opt( - ("--editor",), - "TOPEN_NOTES_EDITOR", - "notes.editor", - default=os.getenv("EDITOR") or os.getenv("VISUAL") or "nano", - metavar="CMD", - help_text="Program to open note files with", - ), - "notes_quiet": Opt( - ("--quiet",), - "TOPEN_NOTES_QUIET", - "notes.quiet", - default=False, - cast=bool, - help_text="Silence any verbose displayed information", - ), -} - - @dataclass() class TConf: """Topen Configuration @@ -245,23 +171,6 @@ class TConf: return cls(**d) -def build_config() -> TConf: - """Return final configuration object.""" - cli = parse_cli() - env = parse_env() - rc_path = Path( - cli.get("task_rc") or env.get("task_rc") or TConf(0).task_rc - ).expanduser() - rc = parse_rc(rc_path) if rc_path.exists() else {} - - # we use the 'parsed' XDG-included taskrc locations for defaults - defaults = {k: opt.default for k, opt in OPTIONS.items()} - defaults["task_rc"] = rc_path - - merged = defaults | rc | env | cli # later wins - return TConf.from_dict({k: v for k, v in merged.items() if v is not None}) - - def parse_cli() -> dict: """Parse cli options and arguments. @@ -280,50 +189,93 @@ you view the task. _ = parser.add_argument( "id", help="The id/uuid of the taskwarrior task for which we edit notes" ) - for key, opt in OPTIONS.items(): - if opt.cli is None: - continue - parser.add_argument( - *opt.cli, - dest=key, - metavar=opt.metavar, - help=opt.help_text, - default=None, - ) - args = parser.parse_args() - cli_vals = {k: v for k, v in vars(args).items() if v is not None} - cli_vals["task_id"] = cli_vals.pop("id") - return cli_vals + _ = parser.add_argument( + "-d", + "--notes-dir", + metavar="DIR", + help="Location of topen notes files", + ) + _ = parser.add_argument( + "--quiet", + action="store_true", + help="Silence any verbose displayed information", + ) + _ = parser.add_argument( + "--extension", metavar="EXT", help="Extension of note files" + ) + _ = parser.add_argument( + "--annotation", + metavar="NOTE", + help="Annotation content to set within taskwarrior", + ) + _ = parser.add_argument( + "--editor", metavar="CMD", help="Program to open note files with" + ) + _ = parser.add_argument( + "--task-rc", metavar="FILE", help="Location of taskwarrior config file" + ) + _ = parser.add_argument( + "--task-data", metavar="DIR", help="Location of taskwarrior data directory" + ) + + p = parser.parse_args() + return _filtered_dict( + { + "task_id": p.id, + "task_rc": p.task_rc, + "task_data": p.task_data, + "notes_dir": p.notes_dir, + "notes_ext": p.extension, + "notes_annot": p.annotation, + "notes_editor": p.editor, + "notes_quiet": p.quiet, + } + ) -def parse_env() -> dict[str, Any]: +def parse_env() -> dict: """Parse environment variable options. Returns them as a simple dict object. """ - out: dict[str, Any] = {} - for key, opt in OPTIONS.items(): - if opt.env and (val := os.getenv(opt.env)) is not None: - out[key] = opt.cast(val) - return out + return _filtered_dict( + { + "task_rc": os.getenv("TASKRC"), + "task_data": os.getenv("TASKDATA"), + "notes_dir": os.getenv("TOPEN_NOTES_DIR"), + "notes_ext": os.getenv("TOPEN_NOTES_EXT"), + "notes_annot": os.getenv("TOPEN_NOTES_ANNOT"), + "notes_editor": os.getenv("TOPEN_NOTES_EDITOR"), + "notes_quiet": os.getenv("TOPEN_NOTES_QUIET"), + } + ) -def parse_rc(rc_path: Path) -> dict: +def parse_conf(conf_file: Path) -> dict: """Parse taskrc configuration file options. Returns them as a simple dict object. Uses dot.annotation for options just like taskwarrior settings. """ - cfg = configparser.ConfigParser(allow_unnamed_section=True, allow_no_value=True) - with rc_path.expanduser().open() as fr: - cfg.read_string("[GENERAL]\n" + fr.read()) + c = configparser.ConfigParser(allow_unnamed_section=True, allow_no_value=True) + with open(conf_file.expanduser()) as f: + c.read_string("[GENERAL]\n" + f.read()) - out: dict[str, Any] = {} - for key, opt in OPTIONS.items(): - if opt.rc and cfg.has_option("GENERAL", opt.rc): - raw = cfg.get("GENERAL", opt.rc) - out[key] = opt.cast(raw) - return out + ConfTrans = namedtuple("ParsedToTConf", ["name", "tconf_name"]) + return _filtered_dict( + { + opt.tconf_name: c.get("GENERAL", opt.name) + for opt in [ + ConfTrans("data.location", "task_data"), + ConfTrans("notes.dir", "notes_dir"), + ConfTrans("notes.ext", "notes_ext"), + ConfTrans("notes.annot", "notes_annot"), + ConfTrans("notes.editor", "notes_editor"), + ConfTrans("notes.quiet", "notes_quiet"), + ] + if c.has_option("GENERAL", opt.name) + } + ) IS_QUIET = False @@ -338,5 +290,11 @@ def _real_path(p: Path | str) -> Path: return Path(os.path.expandvars(p)).expanduser() +# A None-filtered dict which only contains +# keys which have a value. +def _filtered_dict(d: dict) -> dict: + return {k: v for (k, v) in d.items() if v} + + if __name__ == "__main__": main()