Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions gemini-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# How to Use Google's Gemini CLI for AI Code Assistance

This folder contains code associated with the Real Python tutorial [How to Use Google's Gemini CLI for AI Code Assistance](https://realpython.com/how-to-use-gemini-cli/).
19 changes: 19 additions & 0 deletions gemini-cli/todolist/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[build-system]
requires = ["uv_build>=0.9.5"]
build-backend = "uv_build"

[project]
name = "todolist"
version = "0.1.0"
description = "A command-line todo app"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"openai>=2.6.1",
"peewee>=3.18.2",
"platformdirs>=4.5.0",
"rich>=14.2.0",
]

[project.scripts]
todo = "todolist.__main__:main"
Empty file.
124 changes: 124 additions & 0 deletions gemini-cli/todolist/src/todolist/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from collections.abc import Iterable

from rich import print
from rich.console import Console
from rich.prompt import Confirm

from todolist.cli import Callbacks, process_cli
from todolist.database import Task, TaskList
from todolist.emojis import find_matching_emojis, has_emoji_support
from todolist.exporter import export_database_to_json
from todolist.renderer import render_long, render_short
from todolist.status import TaskListStatus


def main() -> None:
process_cli(
Callbacks(add, remove, done, undo, rename, show, clear, lists, export)
)


def add(list_name: str, tasks: Iterable[str]) -> None:
"""Add one or more tasks to a list"""
task_list, _ = TaskList.get_or_create(name=list_name)
created_tasks = []
for task_name in tasks:
task, created = Task.get_or_create(name=task_name, task_list=task_list)
if created:
created_tasks.append(task)
if created_tasks and has_emoji_support():
with Console().status("Choosing emoji...", spinner="arc"):
task_names = tuple(task.name for task in created_tasks)
emojis = find_matching_emojis(task_names)
for task, emoji in zip(created_tasks, emojis):
task.emoji = emoji
task.save()
show(list_name)


def remove(list_name: str, tasks: Iterable[str]) -> None:
"""Remove tasks from a list"""
if task_list := TaskList.get_or_none(name=list_name):
for task_name in tasks:
Task.delete().where(
(Task.name == task_name) & (Task.task_list == task_list)
).execute()
show(list_name)
else:
print(f"List not found: {list_name!r}")


def done(list_name: str, tasks: Iterable[str]) -> None:
"""Mark tasks as completed"""
_mark(list_name, tasks, is_done=True)


def undo(list_name: str, tasks: Iterable[str]) -> None:
"""Mark tasks as pending"""
_mark(list_name, tasks, is_done=False)


def rename(list_name: str, old: str, new: str) -> None:
"""Rename a task on the given list"""
if task_list := TaskList.get_or_none(TaskList.name == list_name):
if task := Task.get_or_none(task_list=task_list, name=old):
task.name = new
task.save()
show(list_name)
else:
print(f"Task not found: {old!r}")
else:
print(f"List not found: {list_name!r}")


def show(list_name: str) -> None:
"""Show the status of tasks"""
if status := TaskListStatus.find_one(list_name):
render_long(status)
else:
if list_name.lower() == "default":
print("You're all caught up :sparkles:")
else:
print(f"List not found: {list_name!r}")


def clear(list_name: str) -> None:
"""Clear all tasks from a list"""
if task_list := TaskList.get_or_none(TaskList.name == list_name):
prompt = f"Are you sure to remove the {list_name!r} list?"
if Confirm.ask(prompt, default=False):
task_list.delete_instance(recursive=True)
else:
print(f"List not found: {list_name!r}")


def lists() -> None:
"""Display the available task lists"""
for status in TaskListStatus.find_all():
render_short(status)


def export() -> None:
"""Dump all task lists to JSON"""
export_database_to_json()


def _mark(list_name: str, tasks: Iterable[str], is_done: bool) -> None:
if task_list := TaskList.get_or_none(name=list_name):
for task_name in tasks:
if instance := Task.get_or_none(
task_list=task_list, name=task_name
):
instance.done = is_done
instance.save()
else:
print(f"Task not found: {task_name!r}")
break
else:
show(list_name)
else:
print(f"List not found: {list_name!r}")


if __name__ == "__main__":
main()
113 changes: 113 additions & 0 deletions gemini-cli/todolist/src/todolist/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from argparse import ArgumentParser
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from inspect import signature

type PlainCallback = Callable[[], None]
type ListCallback = Callable[[str], None]
type TaskCallback = Callable[[str, Iterable[str]], None]


@dataclass(frozen=True)
class Callbacks:
add: TaskCallback
remove: TaskCallback
done: TaskCallback
undo: TaskCallback
rename: Callable[[str, str, str], None]
show: ListCallback
clear: ListCallback
lists: PlainCallback
export: PlainCallback

@property
def task_callbacks(self) -> tuple[TaskCallback, ...]:
return (
self.add,
self.remove,
self.done,
self.undo,
)

@property
def list_callbacks(self) -> tuple[ListCallback, ...]:
return (
self.show,
self.clear,
)

@property
def plain_callbacks(self) -> tuple[PlainCallback, ...]:
return (
self.lists,
self.export,
)


def process_cli(callbacks: Callbacks) -> None:
parser = build_parser(callbacks)
args = parser.parse_args()
if args.command:
args.callback(
**{
name: getattr(args, name)
for name in signature(args.callback).parameters
if hasattr(args, name)
}
)
else:
parser.print_help()


def build_parser(callbacks: Callbacks) -> ArgumentParser:
parser = ArgumentParser(description="A command-line task manager")
subparsers = parser.add_subparsers(title="commands", dest="command")

for cb in callbacks.task_callbacks:
subparser = subparsers.add_parser(cb.__name__, help=cb.__doc__)
subparser.set_defaults(callback=cb)
add_tasks_positional(subparser)
add_list_option(subparser)

# Rename
subparser = subparsers.add_parser("rename", help=callbacks.rename.__doc__)
subparser.add_argument("old", type=normalize, help="original task name")
subparser.add_argument("new", type=normalize, help="new task name")
subparser.set_defaults(callback=callbacks.rename)
add_list_option(subparser)

for cb in callbacks.list_callbacks:
subparser = subparsers.add_parser(cb.__name__, help=cb.__doc__)
subparser.set_defaults(callback=cb)
add_list_option(subparser)

for cb in callbacks.plain_callbacks:
subparser = subparsers.add_parser(cb.__name__, help=cb.__doc__)
subparser.set_defaults(callback=cb)

return parser


def add_tasks_positional(parser: ArgumentParser) -> None:
parser.add_argument(
"tasks",
nargs="+",
type=normalize,
help="one or more tasks (e.g., 'eggs', 'bacon')",
)


def add_list_option(parser: ArgumentParser) -> None:
parser.add_argument(
"-l",
"--list",
dest="list_name",
metavar="name",
help="optional name of the task list (e.g., 'shopping')",
default="default",
type=normalize,
)


def normalize(name: str) -> str:
return name.strip().title()
44 changes: 44 additions & 0 deletions gemini-cli/todolist/src/todolist/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from functools import cached_property

from peewee import (
BooleanField,
ForeignKeyField,
Model,
SqliteDatabase,
TextField,
)
from platformdirs import user_cache_path

db_file = user_cache_path() / __package__ / f"{__package__}.db"
db_file.parent.mkdir(parents=True, exist_ok=True)

db = SqliteDatabase(db_file)


class TaskList(Model):
name = TextField(null=False, unique=True)

class Meta:
database = db
table_name = "lists"


class Task(Model):
emoji = TextField(null=True)
name = TextField(null=False)
done = BooleanField(null=False, default=False)
task_list = ForeignKeyField(TaskList, backref="tasks", on_delete="CASCADE")

class Meta:
database = db
table_name = "tasks"

@cached_property
def pretty_name(self) -> str:
if self.emoji:
return f"{self.emoji} {self.name}"
else:
return str(self.name)


db.create_tables([TaskList, Task], safe=True)
73 changes: 73 additions & 0 deletions gemini-cli/todolist/src/todolist/emojis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import os
import typing
from collections.abc import Sequence
from functools import cache

if typing.TYPE_CHECKING:
from openai import OpenAI

MODEL = "gpt-4o-mini"
TEMPERATURE = 0.3
MAX_TOKENS = 10

SYSTEM_PROMPT = (
"You are an emoji expert. When given a phrase, you respond with only "
"a single emoji that best matches it. Never include explanations or "
"multiple emojis."
)

BATCH_USER_PROMPT = (
"Given the list of PHRASES below (each on a separate line), return ONLY "
"a list of emojis (one per line) that best represents each phrase. "
"Return ONLY the emoji characters themselves in the same order, with no "
"explanation or additional text. One emoji per line."
)


def has_emoji_support() -> bool:
if "NO_EMOJI" in os.environ:
return False
return os.environ.get("OPENAI_API_KEY") is not None


def find_matching_emojis(phrases: Sequence[str]) -> tuple[str | None, ...]:
if not phrases:
return tuple()

if client := _get_client():
phrases_text = "\n".join(
f"{i + 1}. {phrase}" for i, phrase in enumerate(phrases)
)
user_prompt = BATCH_USER_PROMPT + f"\n\nPHRASES:\n{phrases_text}"
try:
response = client.chat.completions.create(
model=MODEL,
max_tokens=MAX_TOKENS * len(phrases),
temperature=TEMPERATURE,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
emojis = response.choices[0].message.content.strip().split("\n")
result = []
for i in range(len(phrases)):
if i < len(emojis):
emoji = emojis[i].strip()
if emoji and emoji[0].isdigit():
emoji = emoji.split(".", 1)[-1].strip()
result.append(emoji[0] if emoji else None)
else:
result.append(None)
return tuple(result)
except Exception:
return (None,) * len(phrases)
else:
return (None,) * len(phrases)


@cache
def _get_client() -> OpenAI | None:
from openai import OpenAI

return OpenAI() if has_emoji_support() else None
Loading