Use uv and typer

This commit is contained in:
Yehuda Deutsch 2024-11-11 22:47:28 -05:00
parent df0d8c5113
commit 319e32ad34
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

225
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: root_task = None
func: callable tasks = {}
read_only: bool = False for line in todo_file.read_text().splitlines():
help: str | None = None if matches := re.match(r"^-\s+(\[(?P<status>[x ])]\s+)?(?P<task>.+)$", 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<status>[x ])]\s+)?(?P<task>.+)$", line.rstrip()):
root_task.tasks[matches.group("task")] = LeafTask(completed=matches.group("status") == "x")
return tasks
@dataclass def todo_render(tasks: dict[str, RootTask]) -> str:
class CommandList: lines = ["# A ToDo list", ""]
commands: dict[str, Command] = field(default_factory=dict) for root_task_name, root_task in tasks.items(): # type: str, RootTask
lines.append(f"- [{'x' if root_task.completed else ' '}] {root_task_name}")
def mark(self, read_only: bool = False): for leaf_task_name, task in root_task.tasks.items():
def wrap(func): lines.append(f" - [{'x' if task.completed else ' '}] {leaf_task_name}")
self.commands[func.__name__] = Command(func=func, read_only=read_only, help=func.__doc__) lines += [""]
return func return "\n".join(lines)
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: def todo_write(todo_file: Path, todo: dict[str, RootTask]):
args: argparse.Namespace todo_file.write_text(todo_render(tasks=todo))
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): # == CLI helpers ==
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() def root_callback(ctx: typer.Context, root: str = None) -> str | None:
if root is None:
return root
@property if root not in ctx.obj["tasks"]:
def command(self) -> Command: typer.echo(f"Root task '{root}' does not exist", err=True)
return self.commands.commands[self.args.command] raise typer.Exit(1)
else:
ctx.obj.update({
"tasks_root": ctx.obj["tasks"][root].tasks,
"tasks_class": LeafTask,
})
def run(self) -> None: return root
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
for line in self.args.todo_file.read_text().splitlines():
if matches := re.match(r"^-\s+(\[(?P<status>[x ])]\s+)?(?P<task>.+)$", 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<status>[x ])]\s+)?(?P<task>.+)$", line.rstrip()):
root_task.tasks[matches.group("task")] = LeafTask(completed=matches.group("status") == "x")
def render(self, tasks: dict | None = None): @cli.callback()
lines = ["# A ToDo list", ""] def main_callback(
for root_task_name, root_task in (tasks or self.tasks).items(): # type: str, RootTask ctx: typer.Context,
lines.append(f"- [{'x' if root_task.completed else ' '}] {root_task_name}") todo_file: Path = lambda: base_dir / "todo.md",
for leaf_task_name, task in root_task.tasks.items(): verbose: bool = typer.Option(False, "--verbose", "-v", help="Set verbosity"),
lines.append(f" - [{'x' if task.completed else ' '}] {leaf_task_name}") ):
lines += [""] ctx.obj = dict(ctx.params)
return "\n".join(lines) 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): # == CLI definitions ==
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
@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 @cli.command(name="list")
""" def task_list(
print(f"Listing tasks in '{self.args.root if self.args.root else 'root'}'") ctx: typer.Context,
print(self.render(tasks={self.args.root: self.tasks[self.args.root]} if self.args.root else None)) root: Optional[str] = typer.Option(None, "--root", "-r", help="Root task", callback=root_callback),
):
"""
List all tasks in todo.md
@commands.mark() Returns all tasks if no root task is passed, otherwise only the root task and subtasks
def add(self): """
""" ctx.obj["verbose"] and typer.echo(f"Listing tasks in '{root if root else 'root'}'")
Add a task to todo.md if root is None:
""" typer.echo(todo_render(ctx.obj["tasks"]))
print(f"Adding task '{self.args.task}' to '{self.args.root if self.args.root else 'root'}'") else:
self.tasks_root[self.args.task] = self.task_class() 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() @cli.command(name="add")
def remove(self): def task_add(
""" ctx: typer.Context,
Remove a task from todo.md task: str = typer.Argument(..., help="Task name"),
""" root: Optional[str] = typer.Option(None, "--root", "-r", help="Root task", callback=root_callback),
del self.tasks_root[self.args.task] ):
"""
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__": if __name__ == "__main__":
ToDo().run() cli()