From fccfb8502625ed3f619934210ccaafb24d740a9f Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 26 Nov 2025 18:38:18 +0100 Subject: [PATCH 1/4] ref: Rename configparser to cfg --- topen.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/topen.py b/topen.py index dcfb350..49fc6cf 100755 --- a/topen.py +++ b/topen.py @@ -257,14 +257,14 @@ def parse_conf(conf_file: Path) -> dict: Returns them as a simple dict object. Uses dot.annotation for options just like taskwarrior settings. """ - c = configparser.ConfigParser(allow_unnamed_section=True, allow_no_value=True) + cfg = configparser.ConfigParser(allow_unnamed_section=True, allow_no_value=True) with open(conf_file.expanduser()) as f: - c.read_string("[GENERAL]\n" + f.read()) + cfg.read_string("[GENERAL]\n" + f.read()) ConfTrans = namedtuple("ParsedToTConf", ["name", "tconf_name"]) return _filtered_dict( { - opt.tconf_name: c.get("GENERAL", opt.name) + opt.tconf_name: cfg.get("GENERAL", opt.name) for opt in [ ConfTrans("data.location", "task_data"), ConfTrans("notes.dir", "notes_dir"), @@ -273,7 +273,7 @@ def parse_conf(conf_file: Path) -> dict: ConfTrans("notes.editor", "notes_editor"), ConfTrans("notes.quiet", "notes_quiet"), ] - if c.has_option("GENERAL", opt.name) + if cfg.has_option("GENERAL", opt.name) } ) From b20d56a007a5d4df55fa2b45adb076bf57666fb9 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 26 Nov 2025 18:38:18 +0100 Subject: [PATCH 2/4] ref: Load all options from single dictionary source --- topen.py | 185 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 106 insertions(+), 79 deletions(-) diff --git a/topen.py b/topen.py index 49fc6cf..666f0e1 100755 --- a/topen.py +++ b/topen.py @@ -21,7 +21,6 @@ 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 @@ -112,6 +111,83 @@ 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 @@ -189,93 +265,50 @@ you view the task. _ = parser.add_argument( "id", help="The id/uuid of the taskwarrior task for which we edit notes" ) - _ = 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, - } - ) + 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 -def parse_env() -> dict: +def parse_env() -> dict[str, Any]: """Parse environment variable options. Returns them as a simple dict object. """ - 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"), - } - ) + 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 -def parse_conf(conf_file: Path) -> dict: +def parse_conf(rc_path: 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 open(conf_file.expanduser()) as f: - cfg.read_string("[GENERAL]\n" + f.read()) + with rc_path.expanduser().open() as fr: + cfg.read_string("[GENERAL]\n" + fr.read()) - ConfTrans = namedtuple("ParsedToTConf", ["name", "tconf_name"]) - return _filtered_dict( - { - opt.tconf_name: cfg.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 cfg.has_option("GENERAL", opt.name) - } - ) + 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 IS_QUIET = False @@ -290,11 +323,5 @@ 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() From c164be29d3435d17830193fcb624935bbe313f1e Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 26 Nov 2025 23:04:15 +0100 Subject: [PATCH 3/4] ref: Rename taskrc parsing function to parse_rc --- topen.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/topen.py b/topen.py index 666f0e1..eeb45eb 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_conf()`. +`parse_rc()`. """ @@ -45,7 +45,7 @@ def main(): """ 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 + opts: dict = parse_rc(conf_file) | opts_override cfg = TConf.from_dict(opts) if not cfg.task_id: @@ -293,7 +293,7 @@ def parse_env() -> dict[str, Any]: return out -def parse_conf(rc_path: Path) -> dict: +def parse_rc(rc_path: Path) -> dict: """Parse taskrc configuration file options. Returns them as a simple dict object. From 0d2e68a03d647f384776a3f6ca5b4d72c57dbfc6 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 26 Nov 2025 23:15:53 +0100 Subject: [PATCH 4/4] ref: Extract configuration assembly into separate function --- topen.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/topen.py b/topen.py index eeb45eb..1da9452 100755 --- a/topen.py +++ b/topen.py @@ -30,7 +30,7 @@ from tasklib import Task, TaskWarrior NON_EXISTENT_PATH = Path("%%%%I_DONT_EXIST_%%%%") -def main(): +def main(cfg: "TConf | None" = None): """Runs the cli interface. First sets up the correct options, with overrides in the following order: @@ -43,10 +43,8 @@ def main(): If the task does not yet have a note annotation it also adds it automatically. """ - opts_override = {"task_rc": TConf(0).task_rc} | parse_env() | parse_cli() - conf_file = _real_path(opts_override["task_rc"]) - opts: dict = parse_rc(conf_file) | opts_override - cfg = TConf.from_dict(opts) + if not cfg: + cfg = build_config() if not cfg.task_id: _ = sys.stderr.write("Please provide task ID as argument.\n") @@ -247,6 +245,23 @@ 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.