Use uv and typer

This commit is contained in:
Yehuda Deutsch 2024-11-11 22:47:28 -05:00
parent e4ca23901b
commit f8c26abeea
Signed by: uda
GPG key ID: 8EF44B89374262A5
4 changed files with 148 additions and 117 deletions

View file

@ -1,3 +1,17 @@
# Ajal Todo CLI # Ajal Todo CLI
A todo utility - enhanced 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

9
pyproject.toml Normal file
View file

@ -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",
]

17
todo.md
View file

@ -1,10 +1,11 @@
# A ToDo list # A ToDo list
- Buy: - [ ] Buy:
- Milk - [ ] Milk
- Eggs - [ ] Eggs
- Coffee - [ ] Coffee
- Wine - [ ] Wine
- Clean - [ ] Clean
- Gutters - [ ] Gutters
- [x] Love
- [x] Myself

195
todo.py
View file

@ -1,12 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Optional
import typer
cli = typer.Typer()
base_dir = Path(__file__).absolute().parent base_dir = Path(__file__).absolute().parent
# == Helper classes and functions ==
@dataclass @dataclass
class LeafTask: class LeafTask:
completed: bool = False completed: bool = False
@ -17,133 +23,134 @@ class RootTask(LeafTask):
tasks: dict[str, LeafTask] = field(default_factory=dict) tasks: dict[str, LeafTask] = field(default_factory=dict)
@dataclass def todo_parse(todo_file: Path) -> dict[str, RootTask]:
class Command:
func: callable
read_only: bool = False
help: str | None = None
@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]
class ToDo:
args: argparse.Namespace
tasks: dict[str, RootTask] = {}
commands: CommandList = CommandList()
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")
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()
@property
def command(self) -> Command:
return self.commands.commands[self.args.command]
def run(self) -> None:
self.parse()
self.augment_root()
self.command.func(self)
if not self.command.read_only:
self.write()
self.list()
def parse(self):
root_task = None root_task = None
for line in self.args.todo_file.read_text().splitlines(): tasks = {}
for line in todo_file.read_text().splitlines():
if matches := re.match(r"^-\s+(\[(?P<status>[x ])]\s+)?(?P<task>.+)$", line.rstrip()): if matches := re.match(r"^-\s+(\[(?P<status>[x ])]\s+)?(?P<task>.+)$", line.rstrip()):
task_name = matches.group("task") task_name = matches.group("task")
root_task = RootTask(completed=matches.group("status") == "x") root_task = RootTask(completed=matches.group("status") == "x")
self.tasks[task_name] = root_task tasks[task_name] = root_task
elif matches := re.match(r"^\s+-\s+(\[(?P<status>[x ])]\s+)?(?P<task>.+)$", line.rstrip()): elif matches := re.match(r"^\s+-\s+(\[(?P<status>[x ])]\s+)?(?P<task>.+)$", line.rstrip()):
root_task.tasks[matches.group("task")] = LeafTask(completed=matches.group("status") == "x") root_task.tasks[matches.group("task")] = LeafTask(completed=matches.group("status") == "x")
return tasks
def render(self, tasks: dict | None = None):
def todo_render(tasks: dict[str, RootTask]) -> str:
lines = ["# A ToDo list", ""] lines = ["# A ToDo list", ""]
for root_task_name, root_task in (tasks or self.tasks).items(): # type: str, RootTask for root_task_name, root_task in tasks.items(): # type: str, RootTask
lines.append(f"- [{'x' if root_task.completed else ' '}] {root_task_name}") lines.append(f"- [{'x' if root_task.completed else ' '}] {root_task_name}")
for leaf_task_name, task in root_task.tasks.items(): for leaf_task_name, task in root_task.tasks.items():
lines.append(f" - [{'x' if task.completed else ' '}] {leaf_task_name}") lines.append(f" - [{'x' if task.completed else ' '}] {leaf_task_name}")
lines += [""] lines += [""]
return "\n".join(lines) return "\n".join(lines)
def write(self, todo: dict | None = None):
self.args.todo_file.write_text(self.render(tasks=todo))
def augment_root(self): def todo_write(todo_file: Path, todo: dict[str, RootTask]):
if self.args.root: todo_file.write_text(todo_render(tasks=todo))
if self.args.root not in self.tasks:
print(f"Root task '{self.args.root}' does not exist")
exit(1) # == CLI helpers ==
def root_callback(ctx: typer.Context, root: str = None) -> str | None:
if root is None:
return root
if root not in ctx.obj["tasks"]:
typer.echo(f"Root task '{root}' does not exist", err=True)
raise typer.Exit(1)
else: else:
self.tasks_root = self.tasks[self.args.root].tasks ctx.obj.update({
self.task_class = LeafTask "tasks_root": ctx.obj["tasks"][root].tasks,
"tasks_class": LeafTask,
})
@commands.mark(read_only=True) return root
def list(self):
@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,
})
# == CLI definitions ==
@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 List all tasks in todo.md
Returns all tasks if no root task is passed, otherwise only the root task and subtasks 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'}'") ctx.obj["verbose"] and typer.echo(f"Listing tasks in '{root if root else 'root'}'")
print(self.render(tasks={self.args.root: self.tasks[self.args.root]} if self.args.root else None)) if root is None:
typer.echo(todo_render(ctx.obj["tasks"]))
else:
typer.echo(todo_render({root: ctx.obj["tasks"][root]}))
@commands.mark()
def add(self): @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 Add a task to todo.md
""" """
print(f"Adding task '{self.args.task}' to '{self.args.root if self.args.root else 'root'}'") ctx.obj["verbose"] and typer.echo(f"Adding task '{task}' to '{root if root else 'root'}'")
self.tasks_root[self.args.task] = self.task_class() 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"]))
@commands.mark()
def complete(self): @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 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") ctx.obj["verbose"] and typer.echo(f"Marking task '{task}' in '{root if root else 'root'}' as completed")
self.tasks_root[self.args.task].completed = True ctx.obj["tasks_root"][task].completed = True
todo_write(ctx.obj["todo_file"], ctx.obj["tasks"])
typer.echo(todo_render(ctx.obj["tasks"]))
@commands.mark()
def remove(self): @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 Remove a task from todo.md
""" """
del self.tasks_root[self.args.task] 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__": if __name__ == "__main__":
ToDo().run() cli()