From 1ec375534426f3423821b365fb03ddeacc3427d5 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 09:27:15 +0200 Subject: [PATCH 01/13] chore: Simplify funcs by removing default args In preparation for reading the config also from a taskrc file I want to simplify the configuration management beforehand. There should be one place where we grab it from the environment, one place to grab from CLI and then one place from conf file. This helps with simplifying a little by not again injecting defaults at the mid-point. Ultimately, we should create one 'config' data structure, probably dict or NameSpace which we pass around and each function receives it. --- topen.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/topen.py b/topen.py index 870746d..280545a 100755 --- a/topen.py +++ b/topen.py @@ -100,7 +100,7 @@ def main(): add_annotation_if_missing(task, annotation_content=args.annotation) -def get_task(id: str, data_location: str = TASK_DATA_DIR) -> Task: +def get_task(id: str, data_location: str) -> Task: tw = TaskWarrior(data_location) try: t = tw.tasks.get(id=id) @@ -110,21 +110,17 @@ def get_task(id: str, data_location: str = TASK_DATA_DIR) -> Task: return t -def get_notes_file( - uuid: str, notes_dir: str = TOPEN_DIR, notes_ext: str = TOPEN_EXT -) -> Path: +def get_notes_file(uuid: str, notes_dir: str, notes_ext: str) -> Path: return Path(notes_dir).joinpath(f"{uuid}.{notes_ext}") -def open_editor(file: Path, editor: str = TOPEN_EDITOR) -> None: +def open_editor(file: Path, editor: str) -> None: _ = whisper(f"Editing note: {file}") proc = subprocess.Popen(f"{editor} {file}", shell=True) _ = proc.wait() -def add_annotation_if_missing( - task: Task, annotation_content: str = TOPEN_ANNOT -) -> None: +def add_annotation_if_missing(task: Task, annotation_content: str) -> None: for annot in task["annotations"] or []: if annot["description"] == annotation_content: return From f1f30419280f8a1beb998713c55210b575b1f38d Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 09:44:06 +0200 Subject: [PATCH 02/13] feat: Add taskrc parsing We first parse the taskrc file now, before overwriting those options with ones given on the command line. --- topen.py | 96 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 28 deletions(-) diff --git a/topen.py b/topen.py index 280545a..418673a 100755 --- a/topen.py +++ b/topen.py @@ -8,6 +8,7 @@ # It currently assumes an XDG-compliant taskwarrior configuration by default. import argparse +import configparser import os import subprocess import sys @@ -20,14 +21,43 @@ from tasklib import Task, TaskWarrior TASK_RC = os.getenv("TASKRC", "~/.config/task/taskrc") TASK_DATA_DIR = os.getenv("TASKDATA", "~/.local/share/task") -TOPEN_DIR = os.getenv("TOPEN_DIR", "~/.local/share/task/notes") -TOPEN_EXT = os.getenv("TOPEN_EXT", "md") -TOPEN_ANNOT = os.getenv("TOPEN_ANNOT", "Note") -TOPEN_EDITOR = os.getenv("EDITOR") or os.getenv("VISUAL", "nano") -TOPEN_QUIET = os.getenv("TOPEN_QUIET", False) +TOPEN_DIR = os.getenv("TOPEN_DIR") +TOPEN_EXT = os.getenv("TOPEN_EXT") +TOPEN_ANNOT = os.getenv("TOPEN_ANNOT") +TOPEN_EDITOR = os.getenv("EDITOR") or os.getenv("VISUAL") +TOPEN_QUIET = os.getenv("TOPEN_QUIET") + +DEFAULTS_DICT = { + "notes_dir": "~/.local/share/task/notes", + "notes_ext": "md", + "notes_annot": "Note", + "notes_editor": "nano", + "notes_quiet": "False", +} -def parse_cli() -> argparse.Namespace: +def parse_conf(conf_file: Path) -> dict: + c = configparser.ConfigParser( + defaults=DEFAULTS_DICT, allow_unnamed_section=True, allow_no_value=True + ) + with open(conf_file.expanduser()) as f: + c.read_string("[DEFAULT]\n" + f.read()) + + res = { + "notes_dir": c.get("DEFAULT", "notes_dir"), + "notes_ext": c.get("DEFAULT", "notes_ext"), + "notes_annot": c.get("DEFAULT", "notes_annot"), + "notes_editor": c.get("DEFAULT", "notes_editor"), + "notes_quiet": c.get("DEFAULT", "notes_quiet"), + "task_data": c.get("DEFAULT", "data.location"), + } + return _filtered_dict(res) + + +def parse_env() -> dict: ... + + +def parse_cli() -> dict: parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, description="Taskwarrior note editing made easy.", @@ -44,31 +74,32 @@ you view the task. _ = parser.add_argument( "-d", "--notes-dir", - default=TOPEN_DIR, help="Location of topen notes files", ) _ = parser.add_argument( "--quiet", - default=TOPEN_QUIET, action="store_true", help="Silence any verbose displayed information", ) - _ = parser.add_argument( - "--extension", default=TOPEN_EXT, help="Extension of note files" - ) + _ = parser.add_argument("--extension", help="Extension of note files") _ = parser.add_argument( "--annotation", - default=TOPEN_ANNOT, help="Annotation content to set within taskwarrior", ) - _ = parser.add_argument( - "--task-data", default=TASK_DATA_DIR, help="Location of taskwarrior data" - ) - _ = parser.add_argument( - "--editor", default=TOPEN_EDITOR, help="Program to open note files with" - ) + _ = parser.add_argument("--editor", help="Program to open note files with") + _ = parser.add_argument("--task-data", help="Location of taskwarrior data") - return parser.parse_args() + p = parser.parse_args() + res = { + "task_id": p.id, + "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, + } + return _filtered_dict(res) IS_QUIET = False @@ -80,28 +111,31 @@ def whisper(text: str) -> None: def main(): - args = parse_cli() + # TODO: Don't forget to expand user (path.expanduser) and expand vars (os.path.expandvars) + # Should probably be done when 'parsing' option object initially + cfg = parse_conf(Path(TASK_RC)) | parse_cli() - if not args.id: + if not cfg["task_id"]: _ = sys.stderr.write("Please provide task ID as argument.\n") - if args.quiet: + if cfg["notes_quiet"]: global IS_QUIET IS_QUIET = True - task = get_task(id=args.id, data_location=args.task_data) + task = get_task(id=cfg["task_id"], data_location=cfg["task_data"]) uuid = task["uuid"] if not uuid: - _ = sys.stderr.write(f"Could not find task for ID: {args.id}.") + _ = sys.stderr.write(f"Could not find task for ID: {cfg['task_id']}.") sys.exit(1) - fname = get_notes_file(uuid, notes_dir=args.notes_dir, notes_ext=args.extension) + fname = get_notes_file(uuid, notes_dir=cfg["notes_dir"], notes_ext=cfg["notes_ext"]) - open_editor(fname, editor=args.editor) + open_editor(fname, editor=cfg["notes_editor"]) - add_annotation_if_missing(task, annotation_content=args.annotation) + add_annotation_if_missing(task, annotation_content=cfg["notes_annot"]) def get_task(id: str, data_location: str) -> Task: - tw = TaskWarrior(data_location) + # FIXME: This expansion should not be done here + tw = TaskWarrior(os.path.expandvars(data_location)) try: t = tw.tasks.get(id=id) except Task.DoesNotExist: @@ -128,5 +162,11 @@ def add_annotation_if_missing(task: Task, annotation_content: str) -> None: _ = whisper(f"Added annotation: {annotation_content}") +# 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 96422d254b1f1d8b5c9bcdfd30504f7e30a91a22 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 11:26:01 +0200 Subject: [PATCH 03/13] fix: Reintegrate env var parsing Was removed from parsing when conf parsing was added, now re-added into configuration parsing. --- topen.py | 72 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/topen.py b/topen.py index 418673a..f016aca 100755 --- a/topen.py +++ b/topen.py @@ -16,18 +16,9 @@ from pathlib import Path from tasklib import Task, TaskWarrior -# TODO: This should not assume XDG compliance for -# no-setup TW instances. -TASK_RC = os.getenv("TASKRC", "~/.config/task/taskrc") -TASK_DATA_DIR = os.getenv("TASKDATA", "~/.local/share/task") - -TOPEN_DIR = os.getenv("TOPEN_DIR") -TOPEN_EXT = os.getenv("TOPEN_EXT") -TOPEN_ANNOT = os.getenv("TOPEN_ANNOT") -TOPEN_EDITOR = os.getenv("EDITOR") or os.getenv("VISUAL") -TOPEN_QUIET = os.getenv("TOPEN_QUIET") - DEFAULTS_DICT = { + "task_rc": "~/.config/task/taskrc", + "task_data": "~/.local/share/task", "notes_dir": "~/.local/share/task/notes", "notes_ext": "md", "notes_annot": "Note", @@ -43,18 +34,32 @@ def parse_conf(conf_file: Path) -> dict: with open(conf_file.expanduser()) as f: c.read_string("[DEFAULT]\n" + f.read()) - res = { - "notes_dir": c.get("DEFAULT", "notes_dir"), - "notes_ext": c.get("DEFAULT", "notes_ext"), - "notes_annot": c.get("DEFAULT", "notes_annot"), - "notes_editor": c.get("DEFAULT", "notes_editor"), - "notes_quiet": c.get("DEFAULT", "notes_quiet"), - "task_data": c.get("DEFAULT", "data.location"), - } - return _filtered_dict(res) + return _filtered_dict( + { + "notes_dir": c.get("DEFAULT", "notes_dir"), + "notes_ext": c.get("DEFAULT", "notes_ext"), + "notes_annot": c.get("DEFAULT", "notes_annot"), + "notes_editor": c.get("DEFAULT", "notes_editor"), + "notes_quiet": c.get("DEFAULT", "notes_quiet"), + "task_data": c.get("DEFAULT", "data.location"), + } + ) -def parse_env() -> dict: ... +def parse_env() -> dict: + # TODO: This should not assume XDG compliance for + # no-setup TW instances. + return _filtered_dict( + { + "task_rc": os.getenv("TASKRC"), + "task_data": os.getenv("TASKDATA"), + "notes_dir": os.getenv("TOPEN_DIR"), + "notes_ext": os.getenv("TOPEN_EXT"), + "notes_annot": os.getenv("TOPEN_ANNOT"), + "notes_editor": os.getenv("TOPEN_EDITOR") or os.getenv("EDITOR") or os.getenv("VISUAL"), + "notes_quiet": os.getenv("TOPEN_QUIET"), + } + ) def parse_cli() -> dict: @@ -90,16 +95,17 @@ you view the task. _ = parser.add_argument("--task-data", help="Location of taskwarrior data") p = parser.parse_args() - res = { - "task_id": p.id, - "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, - } - return _filtered_dict(res) + return _filtered_dict( + { + "task_id": p.id, + "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, + } + ) IS_QUIET = False @@ -113,7 +119,9 @@ def whisper(text: str) -> None: def main(): # TODO: Don't forget to expand user (path.expanduser) and expand vars (os.path.expandvars) # Should probably be done when 'parsing' option object initially - cfg = parse_conf(Path(TASK_RC)) | parse_cli() + pre_cfg = parse_env() | parse_cli() + conf_file = Path(pre_cfg.get("task_rc", DEFAULTS_DICT["task_rc"])) + cfg = parse_conf(conf_file) | pre_cfg if not cfg["task_id"]: _ = sys.stderr.write("Please provide task ID as argument.\n") From 3fded7315c3ea8140bc96d6cbd49ab40862a6d8f Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 11:26:01 +0200 Subject: [PATCH 04/13] fix: Parse EDITOR and VISUAL env vars with lower precedence Since the precedence now goes (from lowest to highest): defaults -> conf_file -> env -> cli existing EDITOR or VISUAL variables would overwrite those explicitly set in the conf file. While we do want the general precedence of env over conf file, this is a special case where we get the value of the _DEFAULT_ variable from the env, and only then start parsing our own options. So now, for these vars we do: default -> env (EDITOR/VISUAL) -> conf_file -> env (TOPEN_EDITOR) -> cli --- topen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/topen.py b/topen.py index f016aca..4f1721d 100755 --- a/topen.py +++ b/topen.py @@ -22,7 +22,7 @@ DEFAULTS_DICT = { "notes_dir": "~/.local/share/task/notes", "notes_ext": "md", "notes_annot": "Note", - "notes_editor": "nano", + "notes_editor": os.getenv("EDITOR") or os.getenv("VISUAL") or "nano", "notes_quiet": "False", } @@ -56,7 +56,7 @@ def parse_env() -> dict: "notes_dir": os.getenv("TOPEN_DIR"), "notes_ext": os.getenv("TOPEN_EXT"), "notes_annot": os.getenv("TOPEN_ANNOT"), - "notes_editor": os.getenv("TOPEN_EDITOR") or os.getenv("EDITOR") or os.getenv("VISUAL"), + "notes_editor": os.getenv("TOPEN_EDITOR"), "notes_quiet": os.getenv("TOPEN_QUIET"), } ) From c6f05d0b64cee53e91af8d368796ee58eff001e5 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 11:26:01 +0200 Subject: [PATCH 05/13] feat: Specify taskrc as cli option In addition to task-data we also allow specifying the taskrc file on the command line. --- topen.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/topen.py b/topen.py index 4f1721d..20fcdfc 100755 --- a/topen.py +++ b/topen.py @@ -92,12 +92,16 @@ you view the task. help="Annotation content to set within taskwarrior", ) _ = parser.add_argument("--editor", help="Program to open note files with") - _ = parser.add_argument("--task-data", help="Location of taskwarrior data") + _ = parser.add_argument("--task-rc", help="Location of taskwarrior config file") + _ = parser.add_argument( + "--task-data", 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, From 0e167cf08af394acb696085007f93e33f3c1cbe0 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 12:01:44 +0200 Subject: [PATCH 06/13] ref: Turn dict into conf obj --- topen.py | 45 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/topen.py b/topen.py index 20fcdfc..d4196c6 100755 --- a/topen.py +++ b/topen.py @@ -12,6 +12,7 @@ import configparser import os import subprocess import sys +from dataclasses import dataclass from pathlib import Path from tasklib import Task, TaskWarrior @@ -27,6 +28,32 @@ DEFAULTS_DICT = { } +@dataclass() +class TConf: + task_rc: str + task_data: str + task_id: int + + notes_dir: str + notes_ext: str + notes_annot: str + notes_editor: str + notes_quiet: bool + + +def conf_from_dict(d: dict) -> TConf: + return TConf( + task_rc=d["task_rc"], + task_data=d["task_data"], + task_id=d["task_id"], + notes_dir=d["notes_dir"], + notes_ext=d["notes_ext"], + notes_annot=d["notes_annot"], + notes_editor=d["notes_editor"], + notes_quiet=d["notes_quiet"], + ) + + def parse_conf(conf_file: Path) -> dict: c = configparser.ConfigParser( defaults=DEFAULTS_DICT, allow_unnamed_section=True, allow_no_value=True @@ -125,27 +152,27 @@ def main(): # Should probably be done when 'parsing' option object initially pre_cfg = parse_env() | parse_cli() conf_file = Path(pre_cfg.get("task_rc", DEFAULTS_DICT["task_rc"])) - cfg = parse_conf(conf_file) | pre_cfg + cfg = conf_from_dict(parse_conf(conf_file) | pre_cfg) - if not cfg["task_id"]: + if not cfg.task_id: _ = sys.stderr.write("Please provide task ID as argument.\n") - if cfg["notes_quiet"]: + if cfg.notes_quiet: global IS_QUIET IS_QUIET = True - task = get_task(id=cfg["task_id"], data_location=cfg["task_data"]) + task = get_task(id=cfg.task_id, data_location=cfg.task_data) uuid = task["uuid"] if not uuid: - _ = sys.stderr.write(f"Could not find task for ID: {cfg['task_id']}.") + _ = sys.stderr.write(f"Could not find task for ID: {cfg.task_id}.") sys.exit(1) - fname = get_notes_file(uuid, notes_dir=cfg["notes_dir"], notes_ext=cfg["notes_ext"]) + fname = get_notes_file(uuid, notes_dir=cfg.notes_dir, notes_ext=cfg.notes_ext) - open_editor(fname, editor=cfg["notes_editor"]) + open_editor(fname, editor=cfg.notes_editor) - add_annotation_if_missing(task, annotation_content=cfg["notes_annot"]) + add_annotation_if_missing(task, annotation_content=cfg.notes_annot) -def get_task(id: str, data_location: str) -> Task: +def get_task(id: str | int, data_location: str) -> Task: # FIXME: This expansion should not be done here tw = TaskWarrior(os.path.expandvars(data_location)) try: From cf7e9dd5fe4366146efc8b6094b500c420440208 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 12:41:07 +0200 Subject: [PATCH 07/13] ref: Ensure paths are Path objects --- topen.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/topen.py b/topen.py index d4196c6..fd66550 100755 --- a/topen.py +++ b/topen.py @@ -30,23 +30,27 @@ DEFAULTS_DICT = { @dataclass() class TConf: - task_rc: str - task_data: str + task_rc: Path + task_data: Path task_id: int - notes_dir: str + notes_dir: Path notes_ext: str notes_annot: str notes_editor: str notes_quiet: bool +def _real_path(p: Path | str) -> Path: + return Path(os.path.expandvars(p)).expanduser() + + def conf_from_dict(d: dict) -> TConf: return TConf( - task_rc=d["task_rc"], - task_data=d["task_data"], + task_rc=_real_path(d["task_rc"]), + task_data=_real_path(d["task_data"]), task_id=d["task_id"], - notes_dir=d["notes_dir"], + notes_dir=_real_path(d["notes_dir"]), notes_ext=d["notes_ext"], notes_annot=d["notes_annot"], notes_editor=d["notes_editor"], @@ -148,11 +152,10 @@ def whisper(text: str) -> None: def main(): - # TODO: Don't forget to expand user (path.expanduser) and expand vars (os.path.expandvars) - # Should probably be done when 'parsing' option object initially - pre_cfg = parse_env() | parse_cli() - conf_file = Path(pre_cfg.get("task_rc", DEFAULTS_DICT["task_rc"])) - cfg = conf_from_dict(parse_conf(conf_file) | pre_cfg) + opts_overwrite = {"task_rc": DEFAULTS_DICT["task_rc"]} | parse_env() | parse_cli() + conf_file = _real_path(opts_overwrite["task_rc"]) + opts: dict = parse_conf(conf_file) | opts_overwrite + cfg = conf_from_dict(opts) if not cfg.task_id: _ = sys.stderr.write("Please provide task ID as argument.\n") @@ -172,9 +175,8 @@ def main(): add_annotation_if_missing(task, annotation_content=cfg.notes_annot) -def get_task(id: str | int, data_location: str) -> Task: - # FIXME: This expansion should not be done here - tw = TaskWarrior(os.path.expandvars(data_location)) +def get_task(id: str | int, data_location: Path) -> Task: + tw = TaskWarrior(data_location) try: t = tw.tasks.get(id=id) except Task.DoesNotExist: @@ -183,7 +185,7 @@ def get_task(id: str | int, data_location: str) -> Task: return t -def get_notes_file(uuid: str, notes_dir: str, notes_ext: str) -> Path: +def get_notes_file(uuid: str, notes_dir: Path, notes_ext: str) -> Path: return Path(notes_dir).joinpath(f"{uuid}.{notes_ext}") From 8c3ecfa4318a656f6819d308ab7be3f8babc0412 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 12:54:28 +0200 Subject: [PATCH 08/13] chore: Restructure script --- topen.py | 194 +++++++++++++++++++++++++++---------------------------- 1 file changed, 97 insertions(+), 97 deletions(-) diff --git a/topen.py b/topen.py index fd66550..7db56e4 100755 --- a/topen.py +++ b/topen.py @@ -41,10 +41,6 @@ class TConf: notes_quiet: bool -def _real_path(p: Path | str) -> Path: - return Path(os.path.expandvars(p)).expanduser() - - def conf_from_dict(d: dict) -> TConf: return TConf( task_rc=_real_path(d["task_rc"]), @@ -58,99 +54,6 @@ def conf_from_dict(d: dict) -> TConf: ) -def parse_conf(conf_file: Path) -> dict: - c = configparser.ConfigParser( - defaults=DEFAULTS_DICT, allow_unnamed_section=True, allow_no_value=True - ) - with open(conf_file.expanduser()) as f: - c.read_string("[DEFAULT]\n" + f.read()) - - return _filtered_dict( - { - "notes_dir": c.get("DEFAULT", "notes_dir"), - "notes_ext": c.get("DEFAULT", "notes_ext"), - "notes_annot": c.get("DEFAULT", "notes_annot"), - "notes_editor": c.get("DEFAULT", "notes_editor"), - "notes_quiet": c.get("DEFAULT", "notes_quiet"), - "task_data": c.get("DEFAULT", "data.location"), - } - ) - - -def parse_env() -> dict: - # TODO: This should not assume XDG compliance for - # no-setup TW instances. - return _filtered_dict( - { - "task_rc": os.getenv("TASKRC"), - "task_data": os.getenv("TASKDATA"), - "notes_dir": os.getenv("TOPEN_DIR"), - "notes_ext": os.getenv("TOPEN_EXT"), - "notes_annot": os.getenv("TOPEN_ANNOT"), - "notes_editor": os.getenv("TOPEN_EDITOR"), - "notes_quiet": os.getenv("TOPEN_QUIET"), - } - ) - - -def parse_cli() -> dict: - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description="Taskwarrior note editing made easy.", - epilog="""Provide a taskwarrior task id or uuid and topen creates a -new note file for or lets you edit an existing one. -Additionally it adds a small annotation to the task -to let you see that there exists a note file next time -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", - help="Location of topen notes files", - ) - _ = parser.add_argument( - "--quiet", - action="store_true", - help="Silence any verbose displayed information", - ) - _ = parser.add_argument("--extension", help="Extension of note files") - _ = parser.add_argument( - "--annotation", - help="Annotation content to set within taskwarrior", - ) - _ = parser.add_argument("--editor", help="Program to open note files with") - _ = parser.add_argument("--task-rc", help="Location of taskwarrior config file") - _ = parser.add_argument( - "--task-data", 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, - } - ) - - -IS_QUIET = False - - -def whisper(text: str) -> None: - if not IS_QUIET: - print(text) - - def main(): opts_overwrite = {"task_rc": DEFAULTS_DICT["task_rc"]} | parse_env() | parse_cli() conf_file = _real_path(opts_overwrite["task_rc"]) @@ -203,6 +106,103 @@ def add_annotation_if_missing(task: Task, annotation_content: str) -> None: _ = whisper(f"Added annotation: {annotation_content}") +def parse_cli() -> dict: + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description="Taskwarrior note editing made easy.", + epilog="""Provide a taskwarrior task id or uuid and topen creates a +new note file for or lets you edit an existing one. +Additionally it adds a small annotation to the task +to let you see that there exists a note file next time +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", + help="Location of topen notes files", + ) + _ = parser.add_argument( + "--quiet", + action="store_true", + help="Silence any verbose displayed information", + ) + _ = parser.add_argument("--extension", help="Extension of note files") + _ = parser.add_argument( + "--annotation", + help="Annotation content to set within taskwarrior", + ) + _ = parser.add_argument("--editor", help="Program to open note files with") + _ = parser.add_argument("--task-rc", help="Location of taskwarrior config file") + _ = parser.add_argument( + "--task-data", 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: + # TODO: This should not assume XDG compliance for + # no-setup TW instances. + return _filtered_dict( + { + "task_rc": os.getenv("TASKRC"), + "task_data": os.getenv("TASKDATA"), + "notes_dir": os.getenv("TOPEN_DIR"), + "notes_ext": os.getenv("TOPEN_EXT"), + "notes_annot": os.getenv("TOPEN_ANNOT"), + "notes_editor": os.getenv("TOPEN_EDITOR"), + "notes_quiet": os.getenv("TOPEN_QUIET"), + } + ) + + +def parse_conf(conf_file: Path) -> dict: + c = configparser.ConfigParser( + defaults=DEFAULTS_DICT, allow_unnamed_section=True, allow_no_value=True + ) + with open(conf_file.expanduser()) as f: + c.read_string("[DEFAULT]\n" + f.read()) + + return _filtered_dict( + { + "notes_dir": c.get("DEFAULT", "notes_dir"), + "notes_ext": c.get("DEFAULT", "notes_ext"), + "notes_annot": c.get("DEFAULT", "notes_annot"), + "notes_editor": c.get("DEFAULT", "notes_editor"), + "notes_quiet": c.get("DEFAULT", "notes_quiet"), + "task_data": c.get("DEFAULT", "data.location"), + } + ) + + +IS_QUIET = False + + +def whisper(text: str) -> None: + if not IS_QUIET: + print(text) + + +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: From 6e1761e690bd10a15ead3abe6bfc07738fb5d7dc Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 12:57:48 +0200 Subject: [PATCH 09/13] chore: Fit env var names to conf option names --- topen.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/topen.py b/topen.py index 7db56e4..6d77041 100755 --- a/topen.py +++ b/topen.py @@ -163,11 +163,11 @@ def parse_env() -> dict: { "task_rc": os.getenv("TASKRC"), "task_data": os.getenv("TASKDATA"), - "notes_dir": os.getenv("TOPEN_DIR"), - "notes_ext": os.getenv("TOPEN_EXT"), - "notes_annot": os.getenv("TOPEN_ANNOT"), - "notes_editor": os.getenv("TOPEN_EDITOR"), - "notes_quiet": os.getenv("TOPEN_QUIET"), + "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"), } ) From 0f10789e9c11e14cf3ff6091b7f751c98e1e292d Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 5 Apr 2025 12:59:40 +0200 Subject: [PATCH 10/13] chore: Change conf options to dot-notation --- topen.py | 78 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/topen.py b/topen.py index 6d77041..a350c1c 100755 --- a/topen.py +++ b/topen.py @@ -18,13 +18,13 @@ from pathlib import Path from tasklib import Task, TaskWarrior DEFAULTS_DICT = { - "task_rc": "~/.config/task/taskrc", - "task_data": "~/.local/share/task", - "notes_dir": "~/.local/share/task/notes", - "notes_ext": "md", - "notes_annot": "Note", - "notes_editor": os.getenv("EDITOR") or os.getenv("VISUAL") or "nano", - "notes_quiet": "False", + "task.rc": "~/.config/task/taskrc", + "task.data": "~/.local/share/task", + "notes.dir": "~/.local/share/task/notes", + "notes.ext": "md", + "notes.annot": "Note", + "notes.editor": os.getenv("EDITOR") or os.getenv("VISUAL") or "nano", + "notes.quiet": "False", } @@ -43,21 +43,21 @@ class TConf: def conf_from_dict(d: dict) -> TConf: return TConf( - task_rc=_real_path(d["task_rc"]), - task_data=_real_path(d["task_data"]), - task_id=d["task_id"], - notes_dir=_real_path(d["notes_dir"]), - notes_ext=d["notes_ext"], - notes_annot=d["notes_annot"], - notes_editor=d["notes_editor"], - notes_quiet=d["notes_quiet"], + task_rc=_real_path(d["task.rc"]), + task_data=_real_path(d["task.data"]), + task_id=d["task.id"], + notes_dir=_real_path(d["notes.dir"]), + notes_ext=d["notes.ext"], + notes_annot=d["notes.annot"], + notes_editor=d["notes.editor"], + notes_quiet=d["notes.quiet"], ) def main(): - opts_overwrite = {"task_rc": DEFAULTS_DICT["task_rc"]} | parse_env() | parse_cli() - conf_file = _real_path(opts_overwrite["task_rc"]) - opts: dict = parse_conf(conf_file) | opts_overwrite + opts_override = {"task.rc": DEFAULTS_DICT["task.rc"]} | parse_env() | parse_cli() + conf_file = _real_path(opts_override["task.rc"]) + opts: dict = parse_conf(conf_file) | opts_override cfg = conf_from_dict(opts) if not cfg.task_id: @@ -144,14 +144,14 @@ you view the task. 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, + "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, } ) @@ -161,13 +161,13 @@ def parse_env() -> dict: # no-setup TW instances. 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"), + "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"), } ) @@ -181,12 +181,12 @@ def parse_conf(conf_file: Path) -> dict: return _filtered_dict( { - "notes_dir": c.get("DEFAULT", "notes_dir"), - "notes_ext": c.get("DEFAULT", "notes_ext"), - "notes_annot": c.get("DEFAULT", "notes_annot"), - "notes_editor": c.get("DEFAULT", "notes_editor"), - "notes_quiet": c.get("DEFAULT", "notes_quiet"), - "task_data": c.get("DEFAULT", "data.location"), + "task.data": c.get("DEFAULT", "data.location"), + "notes.dir": c.get("DEFAULT", "notes.dir"), + "notes.ext": c.get("DEFAULT", "notes.ext"), + "notes.annot": c.get("DEFAULT", "notes.annot"), + "notes.editor": c.get("DEFAULT", "notes.editor"), + "notes.quiet": c.get("DEFAULT", "notes.quiet"), } ) From 0277f15ca241b7f7a9516ccad54d4f66a8af6d95 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Mon, 7 Apr 2025 10:08:28 +0200 Subject: [PATCH 11/13] doc: Add docstring documentation --- README.md | 6 ++- topen.py | 130 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 100 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 8ae3e50..e8203f9 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,11 @@ Focuses on letting you quickly: - create notes for taskwarrior tasks - edit notes for taskwarrior tasks -Does both by simply being invoked with `topen `. +It does both by simply being invoked with `topen `. -Automatically appends a small 'Note' annotation to your task so you know you already have notes for it. +Provide a taskwarrior task id or uuid and topen creates a new note file or lets +you edit an existing one. Additionally it adds a small annotation to the task +to let you see that there exists a note file next time you view the task. Should just work as-is without additional configuration in most modern taskwarrior setups.[^moderntw] diff --git a/topen.py b/topen.py index a350c1c..3e5fad1 100755 --- a/topen.py +++ b/topen.py @@ -1,11 +1,20 @@ #!/usr/bin/env python -# Open or create a note file -# for a taskwarrior task. -# Takes a taskwarrior ID or UUID for a single task. -# Edits an existing task note file, -# or creates a new one. +""" +.. include:: ./README.md -# It currently assumes an XDG-compliant taskwarrior configuration by default. +# Usage as library + +While normal operation is intended through the commandline to open or create +note files for taskwarrior tasks, the topen.py file can be used as a library to +open and edit taskwarrior notes programmatically. + +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()`. + +""" import argparse import configparser @@ -28,33 +37,19 @@ DEFAULTS_DICT = { } -@dataclass() -class TConf: - task_rc: Path - task_data: Path - task_id: int - - notes_dir: Path - notes_ext: str - notes_annot: str - notes_editor: str - notes_quiet: bool - - -def conf_from_dict(d: dict) -> TConf: - return TConf( - task_rc=_real_path(d["task.rc"]), - task_data=_real_path(d["task.data"]), - task_id=d["task.id"], - notes_dir=_real_path(d["notes.dir"]), - notes_ext=d["notes.ext"], - notes_annot=d["notes.annot"], - notes_editor=d["notes.editor"], - notes_quiet=d["notes.quiet"], - ) - - def main(): + """Runs the cli interface. + + First sets up the correct options, with overrides in the following order: + `defaults -> taskrc -> env vars -> cli opts` + with cli options having the highest priority. + + Then uses those options to get the task corresponding to the task id passed + in as an argument, finds the matching notes file path and opens an editor + pointing to the file. + + If the task does not yet have a note annotation it also adds it automatically. + """ opts_override = {"task.rc": DEFAULTS_DICT["task.rc"]} | parse_env() | parse_cli() conf_file = _real_path(opts_override["task.rc"]) opts: dict = parse_conf(conf_file) | opts_override @@ -79,6 +74,10 @@ def main(): def get_task(id: str | int, data_location: Path) -> Task: + """Finds a taskwarrior task from an id. + + `id` can be either a taskwarrior id or uuid. + """ tw = TaskWarrior(data_location) try: t = tw.tasks.get(id=id) @@ -89,16 +88,24 @@ def get_task(id: str | int, data_location: Path) -> Task: def get_notes_file(uuid: str, notes_dir: Path, notes_ext: str) -> Path: + """Finds the notes file corresponding to a taskwarrior task.""" return Path(notes_dir).joinpath(f"{uuid}.{notes_ext}") def open_editor(file: Path, editor: str) -> None: + """Opens a file with the chosen editor.""" _ = whisper(f"Editing note: {file}") proc = subprocess.Popen(f"{editor} {file}", shell=True) _ = proc.wait() def add_annotation_if_missing(task: Task, annotation_content: str) -> None: + """Conditionally adds an annotation to a task. + + Only adds the annotation if the task does not yet have an + annotation with exactly that content (i.e. avoids + duplication). + """ for annot in task["annotations"] or []: if annot["description"] == annotation_content: return @@ -106,7 +113,55 @@ def add_annotation_if_missing(task: Task, annotation_content: str) -> None: _ = whisper(f"Added annotation: {annotation_content}") +@dataclass() +class TConf: + """Topen Configuration + + Contains all the configuration options that can affect Topen note creation. + """ + + task_rc: Path + """The path to the taskwarrior taskrc file.""" + task_data: Path + """The path to the taskwarrior data directory.""" + task_id: int + """The id (or uuid) of the task to edit a note for.""" + + notes_dir: Path + """The path to the notes directory.""" + notes_ext: str + """The extension of note files.""" + notes_annot: str + """The annotation to add to taskwarrior tasks with notes.""" + notes_editor: str + """The editor to open note files with.""" + notes_quiet: bool + """If set topen will give no feedback on note editing.""" + + +def conf_from_dict(d: dict) -> TConf: + """Generate a TConf class from a dictionary. + + Turns a dictionary containing all the necessary entries into a TConf configuration file. + Will error if one any of the entries are missing. + """ + return TConf( + task_rc=_real_path(d["task.rc"]), + task_data=_real_path(d["task.data"]), + task_id=d["task.id"], + notes_dir=_real_path(d["notes.dir"]), + notes_ext=d["notes.ext"], + notes_annot=d["notes.annot"], + notes_editor=d["notes.editor"], + notes_quiet=d["notes.quiet"], + ) + + def parse_cli() -> dict: + """Parse cli options and arguments. + + Returns them as a simple dict object. + """ parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, description="Taskwarrior note editing made easy.", @@ -157,8 +212,10 @@ you view the task. def parse_env() -> dict: - # TODO: This should not assume XDG compliance for - # no-setup TW instances. + """Parse environment variable options. + + Returns them as a simple dict object. + """ return _filtered_dict( { "task.rc": os.getenv("TASKRC"), @@ -173,6 +230,11 @@ def parse_env() -> 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. + """ c = configparser.ConfigParser( defaults=DEFAULTS_DICT, allow_unnamed_section=True, allow_no_value=True ) From ee6667844e75885138fd1a097c1f2d514fb47c17 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Mon, 7 Apr 2025 10:08:28 +0200 Subject: [PATCH 12/13] doc: Add pdoc documentation generator --- pyproject.toml | 5 ++++ uv.lock | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e5de0c0..2374df0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,3 +16,8 @@ topen = "topen:main" [tool.pyright] typeCheckingMode = "standard" + +[dependency-groups] +dev = [ + "pdoc>=15.0.1", +] diff --git a/uv.lock b/uv.lock index 7119a94..13bd029 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,69 @@ version = 1 revision = 1 requires-python = ">=3.13" +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "pdoc" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/16/1b542af6f18a27de059f722c487a596681127897b6d31f78e46d6e5bf2fe/pdoc-15.0.1.tar.gz", hash = "sha256:3b08382c9d312243ee6c2a1813d0ff517a6ab84d596fa2c6c6b5255b17c3d666", size = 154174 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/4d/60d856a1b12fbf6ac1539efccfa138e57c6b88675c9867d84bbb46455cc1/pdoc-15.0.1-py3-none-any.whl", hash = "sha256:fd437ab8eb55f9b942226af7865a3801e2fb731665199b74fd9a44737dbe20f9", size = 144186 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + [[package]] name = "tasklib" version = "2.5.1" @@ -16,5 +79,13 @@ dependencies = [ { name = "tasklib" }, ] +[package.dev-dependencies] +dev = [ + { name = "pdoc" }, +] + [package.metadata] requires-dist = [{ name = "tasklib", specifier = ">=2.5.1" }] + +[package.metadata.requires-dev] +dev = [{ name = "pdoc", specifier = ">=15.0.1" }] From 28e214c3591612a3278dad5dc1f2020aafc5a0d5 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Mon, 7 Apr 2025 10:44:54 +0200 Subject: [PATCH 13/13] doc: Auto deploy to github pages --- .github/workflows/docs.yaml | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/docs.yaml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..bcc50e8 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,48 @@ +name: website + +# build documentation for new commits on main +on: + push: + branches: + - main + # Alternative: only build for tags. + # tags: + # - '*' + +permissions: + contents: read + +jobs: + # Build the documentation and upload the static HTML files as an artifact. + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v5 + + # ADJUST THIS: build your documentation into docs/. + # We use a custom build script for pdoc itself, ideally you just run `pdoc -o docs/ ...` here. + - run: uv run pdoc -o docs/ + + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/ + + # Deploy the artifact to GitHub pages. + # This is a separate job so that only actions/deploy-pages has the necessary permissions. + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4