From f8c26abeea1deee38d188cf3fe44d7784c2490ae Mon Sep 17 00:00:00 2001 From: Yehuda Deutsch Date: Mon, 11 Nov 2024 22:47:28 -0500 Subject: [PATCH] Use uv and typer --- README.md | 14 +++ pyproject.toml | 9 ++ todo.md | 17 ++-- todo.py | 225 +++++++++++++++++++++++++------------------------ 4 files changed, 148 insertions(+), 117 deletions(-) create mode 100644 pyproject.toml diff --git a/README.md b/README.md index 054b20b..6acea3a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ # Ajal Todo CLI A todo utility - enhanced + +## Usage + +You should have `uv` installed or have `virtualenv` or `venv` already set up, and using python 3.12+ + +Run (assuming you are using `uv`): + +```shell +uv venv +source .venv/bin/activate +uv sync +``` + +Now you can run commands, start by running `uv run todo.py --help` and continue from there diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..92b933a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "ajal-todo-cli" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "typer>=0.13.0", +] diff --git a/todo.md b/todo.md index 27d00d1..cdd6f17 100644 --- a/todo.md +++ b/todo.md @@ -1,10 +1,11 @@ # A ToDo list -- Buy: - - Milk - - Eggs - - Coffee - - Wine -- Clean - - Gutters - +- [ ] Buy: + - [ ] Milk + - [ ] Eggs + - [ ] Coffee + - [ ] Wine +- [ ] Clean + - [ ] Gutters +- [x] Love + - [x] Myself diff --git a/todo.py b/todo.py index b529938..1899381 100755 --- a/todo.py +++ b/todo.py @@ -1,12 +1,18 @@ #!/usr/bin/env python3 -import argparse import re from dataclasses import dataclass, field from pathlib import Path +from typing import Optional +import typer + +cli = typer.Typer() base_dir = Path(__file__).absolute().parent +# == Helper classes and functions == + + @dataclass class LeafTask: completed: bool = False @@ -17,133 +23,134 @@ class RootTask(LeafTask): tasks: dict[str, LeafTask] = field(default_factory=dict) -@dataclass -class Command: - func: callable - read_only: bool = False - help: str | None = None +def todo_parse(todo_file: Path) -> dict[str, RootTask]: + root_task = None + tasks = {} + for line in todo_file.read_text().splitlines(): + if matches := re.match(r"^-\s+(\[(?P[x ])]\s+)?(?P.+)$", line.rstrip()): + task_name = matches.group("task") + root_task = RootTask(completed=matches.group("status") == "x") + tasks[task_name] = root_task + elif matches := re.match(r"^\s+-\s+(\[(?P[x ])]\s+)?(?P.+)$", line.rstrip()): + root_task.tasks[matches.group("task")] = LeafTask(completed=matches.group("status") == "x") + return tasks -@dataclass -class CommandList: - commands: dict[str, Command] = field(default_factory=dict) - - def mark(self, read_only: bool = False): - def wrap(func): - self.commands[func.__name__] = Command(func=func, read_only=read_only, help=func.__doc__) - return func - return wrap - - @property - def read_list(self): - return [(command, cmd) for command, cmd in self.commands.items() if cmd.read_only] - - @property - def write_list(self): - return [(command, cmd) for command, cmd in self.commands.items() if not cmd.read_only] +def todo_render(tasks: dict[str, RootTask]) -> str: + lines = ["# A ToDo list", ""] + for root_task_name, root_task in tasks.items(): # type: str, RootTask + lines.append(f"- [{'x' if root_task.completed else ' '}] {root_task_name}") + for leaf_task_name, task in root_task.tasks.items(): + lines.append(f" - [{'x' if task.completed else ' '}] {leaf_task_name}") + lines += [""] + return "\n".join(lines) -class ToDo: - args: argparse.Namespace - tasks: dict[str, RootTask] = {} - commands: CommandList = CommandList() +def todo_write(todo_file: Path, todo: dict[str, RootTask]): + todo_file.write_text(todo_render(tasks=todo)) - def __init__(self): - self.args = self.parse_args() - self.tasks_root = self.tasks - self.task_class = RootTask - def parse_args(self): - parser = argparse.ArgumentParser(description="Manages todo.md file as a ToDo file") - parser.add_argument("--todo-file", "-f", help="Path to the todo file.", default=base_dir / "todo.md") +# == CLI helpers == - sub_parsers = parser.add_subparsers(dest="command", help="Command to run", required=True) - for command, cmd in self.commands.write_list: - sub_parser = sub_parsers.add_parser(command, help=cmd.help.lstrip().splitlines()[0], description=cmd.help) - sub_parser.add_argument("--root", "-r", help="Root task") - sub_parser.add_argument("task", help="Task name") - for command, cmd in self.commands.read_list: - sub_parser = sub_parsers.add_parser(command, help=cmd.help.lstrip().splitlines()[0], description=cmd.help) - sub_parser.add_argument("--root", "-r", help="Root task") - return parser.parse_args() +def root_callback(ctx: typer.Context, root: str = None) -> str | None: + if root is None: + return root - @property - def command(self) -> Command: - return self.commands.commands[self.args.command] + if root not in ctx.obj["tasks"]: + typer.echo(f"Root task '{root}' does not exist", err=True) + raise typer.Exit(1) + else: + ctx.obj.update({ + "tasks_root": ctx.obj["tasks"][root].tasks, + "tasks_class": LeafTask, + }) - def run(self) -> None: - self.parse() - self.augment_root() - self.command.func(self) - if not self.command.read_only: - self.write() - self.list() + return root - def parse(self): - root_task = None - for line in self.args.todo_file.read_text().splitlines(): - if matches := re.match(r"^-\s+(\[(?P[x ])]\s+)?(?P.+)$", line.rstrip()): - task_name = matches.group("task") - root_task = RootTask(completed=matches.group("status") == "x") - self.tasks[task_name] = root_task - elif matches := re.match(r"^\s+-\s+(\[(?P[x ])]\s+)?(?P.+)$", line.rstrip()): - root_task.tasks[matches.group("task")] = LeafTask(completed=matches.group("status") == "x") - def render(self, tasks: dict | None = None): - lines = ["# A ToDo list", ""] - for root_task_name, root_task in (tasks or self.tasks).items(): # type: str, RootTask - lines.append(f"- [{'x' if root_task.completed else ' '}] {root_task_name}") - for leaf_task_name, task in root_task.tasks.items(): - lines.append(f" - [{'x' if task.completed else ' '}] {leaf_task_name}") - lines += [""] - return "\n".join(lines) +@cli.callback() +def main_callback( + ctx: typer.Context, + todo_file: Path = lambda: base_dir / "todo.md", + verbose: bool = typer.Option(False, "--verbose", "-v", help="Set verbosity"), +): + ctx.obj = dict(ctx.params) + if not todo_file.exists(): + typer.echo(f"--todo-file '{todo_file}' does not exist", err=True) + raise typer.Exit(1) + tasks = todo_parse(todo_file) + ctx.obj.update({ + "tasks": tasks, + "tasks_root": tasks, + "task_class": RootTask, + }) - def write(self, todo: dict | None = None): - self.args.todo_file.write_text(self.render(tasks=todo)) - def augment_root(self): - if self.args.root: - if self.args.root not in self.tasks: - print(f"Root task '{self.args.root}' does not exist") - exit(1) - else: - self.tasks_root = self.tasks[self.args.root].tasks - self.task_class = LeafTask +# == CLI definitions == - @commands.mark(read_only=True) - def list(self): - """ - List all tasks in todo.md - Returns all tasks if no root task is passed, otherwise only the root task and subtasks - """ - print(f"Listing tasks in '{self.args.root if self.args.root else 'root'}'") - print(self.render(tasks={self.args.root: self.tasks[self.args.root]} if self.args.root else None)) +@cli.command(name="list") +def task_list( + ctx: typer.Context, + root: Optional[str] = typer.Option(None, "--root", "-r", help="Root task", callback=root_callback), +): + """ + List all tasks in todo.md - @commands.mark() - def add(self): - """ - Add a task to todo.md - """ - print(f"Adding task '{self.args.task}' to '{self.args.root if self.args.root else 'root'}'") - self.tasks_root[self.args.task] = self.task_class() + Returns all tasks if no root task is passed, otherwise only the root task and subtasks + """ + ctx.obj["verbose"] and typer.echo(f"Listing tasks in '{root if root else 'root'}'") + if root is None: + typer.echo(todo_render(ctx.obj["tasks"])) + else: + typer.echo(todo_render({root: ctx.obj["tasks"][root]})) - @commands.mark() - def complete(self): - """ - Mark a task in todo.md as completed - """ - print(f"Marking task '{self.args.task}' in '{self.args.root if self.args.root else 'root'}' as completed") - self.tasks_root[self.args.task].completed = True - @commands.mark() - def remove(self): - """ - Remove a task from todo.md - """ - del self.tasks_root[self.args.task] +@cli.command(name="add") +def task_add( + ctx: typer.Context, + task: str = typer.Argument(..., help="Task name"), + root: Optional[str] = typer.Option(None, "--root", "-r", help="Root task", callback=root_callback), +): + """ + Add a task to todo.md + """ + ctx.obj["verbose"] and typer.echo(f"Adding task '{task}' to '{root if root else 'root'}'") + ctx.obj["tasks_root"][task] = ctx.obj["task_class"]() + todo_write(ctx.obj["todo_file"], ctx.obj["tasks"]) + typer.echo(todo_render(ctx.obj["tasks"])) + + +@cli.command(name="complete") +def task_complete( + ctx: typer.Context, + task: str = typer.Argument(..., help="Task name"), + root: Optional[str] = typer.Option(None, "--root", "-r", help="Root task", callback=root_callback), +): + """ + Mark a task in todo.md as completed + """ + ctx.obj["verbose"] and typer.echo(f"Marking task '{task}' in '{root if root else 'root'}' as completed") + ctx.obj["tasks_root"][task].completed = True + todo_write(ctx.obj["todo_file"], ctx.obj["tasks"]) + typer.echo(todo_render(ctx.obj["tasks"])) + + +@cli.command(name="remove") +def task_remove( + ctx: typer.Context, + task: str = typer.Argument(..., help="Task name"), + root: Optional[str] = typer.Option(None, "--root", "-r", help="Root task", callback=root_callback), +): + """ + Remove a task from todo.md + """ + ctx.obj["verbose"] and typer.echo(f"Removing task '{task}' from '{root if root else 'root'}'") + del ctx.obj["tasks_root"][task] + todo_write(ctx.obj["todo_file"], ctx.obj["tasks"]) + typer.echo(todo_render(ctx.obj["tasks"])) if __name__ == "__main__": - ToDo().run() + cli()