Use uv and typer
This commit is contained in:
parent
e4ca23901b
commit
f8c26abeea
4 changed files with 148 additions and 117 deletions
14
README.md
14
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
|
||||
|
|
|
|||
9
pyproject.toml
Normal file
9
pyproject.toml
Normal 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
17
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
|
||||
|
|
|
|||
225
todo.py
225
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<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
|
||||
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<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):
|
||||
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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue