Compare commits

...

4 commits

Author SHA1 Message Date
0d2e68a03d
ref: Extract configuration assembly into separate function
Some checks failed
website / build (push) Has been cancelled
website / deploy (push) Has been cancelled
2025-11-26 23:18:48 +01:00
c164be29d3
ref: Rename taskrc parsing function to parse_rc 2025-11-26 23:18:47 +01:00
b20d56a007
ref: Load all options from single dictionary source 2025-11-26 23:18:47 +01:00
fccfb85026
ref: Rename configparser to cfg 2025-11-26 23:18:46 +01:00

214
topen.py
View file

@ -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 notes, either filling in the required configuration manually or passing around
a TConf configuration object containing them all. If choosing the latter, you can 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 read the configuration in part from a `taskrc` file using the utility function
`parse_conf()`. `parse_rc()`.
""" """
@ -21,7 +21,6 @@ import configparser
import os import os
import subprocess import subprocess
import sys import sys
from collections import namedtuple
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Self from typing import Any, Self
@ -31,7 +30,7 @@ from tasklib import Task, TaskWarrior
NON_EXISTENT_PATH = Path("%%%%I_DONT_EXIST_%%%%") NON_EXISTENT_PATH = Path("%%%%I_DONT_EXIST_%%%%")
def main(): def main(cfg: "TConf | None" = None):
"""Runs the cli interface. """Runs the cli interface.
First sets up the correct options, with overrides in the following order: First sets up the correct options, with overrides in the following order:
@ -44,10 +43,8 @@ def main():
If the task does not yet have a note annotation it also adds it automatically. 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() if not cfg:
conf_file = _real_path(opts_override["task_rc"]) cfg = build_config()
opts: dict = parse_conf(conf_file) | opts_override
cfg = TConf.from_dict(opts)
if not cfg.task_id: if not cfg.task_id:
_ = sys.stderr.write("Please provide task ID as argument.\n") _ = sys.stderr.write("Please provide task ID as argument.\n")
@ -112,6 +109,83 @@ def add_annotation_if_missing(task: Task, annotation_content: str) -> None:
_ = whisper(f"Added annotation: {annotation_content}") _ = 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() @dataclass()
class TConf: class TConf:
"""Topen Configuration """Topen Configuration
@ -171,6 +245,23 @@ class TConf:
return cls(**d) 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: def parse_cli() -> dict:
"""Parse cli options and arguments. """Parse cli options and arguments.
@ -189,93 +280,50 @@ you view the task.
_ = parser.add_argument( _ = parser.add_argument(
"id", help="The id/uuid of the taskwarrior task for which we edit notes" "id", help="The id/uuid of the taskwarrior task for which we edit notes"
) )
_ = parser.add_argument( for key, opt in OPTIONS.items():
"-d", if opt.cli is None:
"--notes-dir", continue
metavar="DIR", parser.add_argument(
help="Location of topen notes files", *opt.cli,
) dest=key,
_ = parser.add_argument( metavar=opt.metavar,
"--quiet", help=opt.help_text,
action="store_true", default=None,
help="Silence any verbose displayed information", )
) args = parser.parse_args()
_ = parser.add_argument( cli_vals = {k: v for k, v in vars(args).items() if v is not None}
"--extension", metavar="EXT", help="Extension of note files" cli_vals["task_id"] = cli_vals.pop("id")
) return cli_vals
_ = 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: def parse_env() -> dict[str, Any]:
"""Parse environment variable options. """Parse environment variable options.
Returns them as a simple dict object. Returns them as a simple dict object.
""" """
return _filtered_dict( out: dict[str, Any] = {}
{ for key, opt in OPTIONS.items():
"task_rc": os.getenv("TASKRC"), if opt.env and (val := os.getenv(opt.env)) is not None:
"task_data": os.getenv("TASKDATA"), out[key] = opt.cast(val)
"notes_dir": os.getenv("TOPEN_NOTES_DIR"), return out
"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_conf(conf_file: Path) -> dict: def parse_rc(rc_path: Path) -> dict:
"""Parse taskrc configuration file options. """Parse taskrc configuration file options.
Returns them as a simple dict object. Returns them as a simple dict object.
Uses dot.annotation for options just like taskwarrior settings. 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: with rc_path.expanduser().open() as fr:
c.read_string("[GENERAL]\n" + f.read()) cfg.read_string("[GENERAL]\n" + fr.read())
ConfTrans = namedtuple("ParsedToTConf", ["name", "tconf_name"]) out: dict[str, Any] = {}
return _filtered_dict( for key, opt in OPTIONS.items():
{ if opt.rc and cfg.has_option("GENERAL", opt.rc):
opt.tconf_name: c.get("GENERAL", opt.name) raw = cfg.get("GENERAL", opt.rc)
for opt in [ out[key] = opt.cast(raw)
ConfTrans("data.location", "task_data"), return out
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 IS_QUIET = False
@ -290,11 +338,5 @@ def _real_path(p: Path | str) -> Path:
return Path(os.path.expandvars(p)).expanduser() 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__": if __name__ == "__main__":
main() main()