Skip to content

Commit e6e4120

Browse files
authored
Merge branch 'realpython:master' into tdd-ai-agents
2 parents d805146 + 90d8392 commit e6e4120

55 files changed

Lines changed: 1932 additions & 122 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cursor-vs-copilot/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Cursor vs Copilot: Which AI Editor Is Better for Python?
2+
3+
This folder provides the prompts used in the Real Python tutorial [Cursor vs Copilot: Which AI Editor Is Better for Python?](https://realpython.com/cursor-vs-copilot/)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.venv/
2+
__pycache__/
3+
*.pyc
4+
*.egg-info/
5+
build/
6+
dist/
7+
.pytest_cache/
8+
9+
notes.db
10+
notes.db-journal
11+
notes.db-wal
12+
notes.db-shm
13+
/notes/
14+
15+
# Editor / OS
16+
.DS_Store
17+
*.swp

cursor-vs-copilot/cursor/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Notes Manager
2+
3+
A simple command-line Markdown note manager. Each note is stored twice:
4+
5+
- as a **Markdown file** (with YAML frontmatter) in a notes directory — the canonical, human-editable copy.
6+
- as a row in a **SQLite index** (`notes.db`) — used for fast search and listing.
7+
8+
## Install
9+
10+
```bash
11+
pip install -e .
12+
```
13+
14+
This installs the `notes` command (entry point defined in `pyproject.toml`).
15+
16+
## Usage
17+
18+
```bash
19+
notes [--notes-dir DIR] [--db PATH] <command> [args]
20+
```
21+
22+
`--notes-dir` (default `notes/`) and `--db` (default `notes.db`) let you point at a different notes store.
23+
24+
### Commands
25+
26+
| Command | Description |
27+
|---|---|
28+
| `notes add <title> [--tags a,b] [--body TEXT \| --body-file PATH]` | Create or update a note (body read from `--body`, `--body-file`, or stdin). |
29+
| `notes get <title>` | Print a note by its exact title. |
30+
| `notes list` | List all notes, most recently created first. |
31+
| `notes search <query>` | Search notes whose title or body contains `query` (case-insensitive). |
32+
| `notes list-tag <tag>` | List notes with a given tag (case-insensitive, exact tag match). |
33+
| `notes reindex` | Rebuild the SQLite index from the Markdown files on disk (fixes drift if the index and files get out of sync). |
34+
35+
### Examples
36+
37+
```bash
38+
notes add "Git Rebase vs Merge" --tags git,vcs --body "Rebase rewrites history; merge preserves it."
39+
notes search rebase
40+
notes list-tag git
41+
notes get "Git Rebase vs Merge"
42+
```
43+
44+
## Notes on storage
45+
46+
- Titles are unique; adding a note with an existing title updates (upserts) it.
47+
- `search` and `list-tag` query the SQLite index only. If a Markdown file is added/edited outside the CLI (or the index gets out of sync), run `notes reindex`.
48+
- The `notes/` directory and `notes.db` hold your actual note data and are gitignored — they aren't meant to be committed to source control.
49+
50+
## Development
51+
52+
```bash
53+
pip install -e .
54+
pytest
55+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[build-system]
2+
requires = ["setuptools>=68.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "notes-manager-cursor-test"
7+
version = "0.1.0"
8+
description = ""
9+
requires-python = ">=3.10"
10+
dependencies = [
11+
"pyyaml==6.0.2",
12+
"pytest==9.0.3",
13+
]
14+
15+
[project.scripts]
16+
notes = "notes_manager_cursor_test.cli:main"
17+
18+
[tool.setuptools.packages.find]
19+
where = ["src"]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""A command-line Markdown note manager."""
2+
3+
from .models import Note
4+
from .store import NoteStore
5+
6+
__all__ = ["Note", "NoteStore"]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .cli import main
2+
3+
if __name__ == "__main__":
4+
raise SystemExit(main())
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Command-line interface for the Markdown note manager."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import sys
7+
from collections.abc import Sequence
8+
from pathlib import Path
9+
10+
from .models import Note
11+
from .store import NoteStore
12+
13+
DEFAULT_NOTES_DIR = Path("notes")
14+
DEFAULT_DB_PATH = Path("notes.db")
15+
16+
17+
def _parse_tags(raw: str | None) -> list[str]:
18+
if not raw:
19+
return []
20+
return [tag.strip() for tag in raw.split(",") if tag.strip()]
21+
22+
23+
def _read_body(args: argparse.Namespace) -> str:
24+
if args.body_file:
25+
return Path(args.body_file).read_text(encoding="utf-8")
26+
if args.body is not None:
27+
return args.body
28+
return sys.stdin.read()
29+
30+
31+
def _print_note(note: Note) -> None:
32+
tags = ", ".join(note.tags) if note.tags else "-"
33+
print(f"# {note.title}")
34+
print(f"tags: {tags}")
35+
print(f"created_at: {note.created_at.isoformat()}")
36+
print()
37+
print(note.body)
38+
39+
40+
def _print_note_summary(note: Note) -> None:
41+
tags = ", ".join(note.tags) if note.tags else "-"
42+
print(f"{note.title}\t[{tags}]\t{note.created_at.isoformat()}")
43+
44+
45+
def build_parser() -> argparse.ArgumentParser:
46+
parser = argparse.ArgumentParser(
47+
prog="notes",
48+
description="A command-line Markdown note manager.",
49+
)
50+
parser.add_argument(
51+
"--notes-dir",
52+
default=DEFAULT_NOTES_DIR,
53+
type=Path,
54+
help=f"Directory to store Markdown note files in (default: {DEFAULT_NOTES_DIR})",
55+
)
56+
parser.add_argument(
57+
"--db",
58+
default=DEFAULT_DB_PATH,
59+
type=Path,
60+
help=f"Path to the SQLite index database (default: {DEFAULT_DB_PATH})",
61+
)
62+
63+
subparsers = parser.add_subparsers(dest="command", required=True)
64+
65+
add_parser = subparsers.add_parser("add", help="Add a new note")
66+
add_parser.add_argument("title", help="Title of the note")
67+
add_parser.add_argument(
68+
"--tags", default="", help="Comma-separated list of tags"
69+
)
70+
body_group = add_parser.add_mutually_exclusive_group()
71+
body_group.add_argument("--body", help="Body text of the note")
72+
body_group.add_argument(
73+
"--body-file", help="Path to a file containing the note body"
74+
)
75+
76+
search_parser = subparsers.add_parser(
77+
"search", help="Search notes by title or body content"
78+
)
79+
search_parser.add_argument("query", help="Text to search for")
80+
81+
list_tag_parser = subparsers.add_parser(
82+
"list-tag", help="List notes that have a given tag"
83+
)
84+
list_tag_parser.add_argument("tag", help="Tag to filter by")
85+
86+
get_parser = subparsers.add_parser(
87+
"get", help="Retrieve a note by its exact title"
88+
)
89+
get_parser.add_argument("title", help="Title of the note")
90+
91+
subparsers.add_parser("list", help="List all notes")
92+
93+
subparsers.add_parser(
94+
"reindex",
95+
help="Rebuild the SQLite search index from the Markdown files on disk",
96+
)
97+
98+
return parser
99+
100+
101+
def main(argv: Sequence[str] | None = None) -> int:
102+
parser = build_parser()
103+
args = parser.parse_args(argv)
104+
105+
with NoteStore(args.notes_dir, args.db) as store:
106+
if args.command == "add":
107+
note = Note(
108+
title=args.title,
109+
body=_read_body(args),
110+
tags=_parse_tags(args.tags),
111+
)
112+
store.add_note(note)
113+
print(f"Added note '{note.title}'")
114+
return 0
115+
116+
if args.command == "search":
117+
results = store.search_notes(args.query)
118+
if not results:
119+
print("No notes found.")
120+
return 0
121+
for note in results:
122+
_print_note_summary(note)
123+
return 0
124+
125+
if args.command == "list-tag":
126+
results = store.find_notes_by_tag(args.tag)
127+
if not results:
128+
print(f"No notes found with tag '{args.tag}'.")
129+
return 0
130+
for note in results:
131+
_print_note_summary(note)
132+
return 0
133+
134+
if args.command == "get":
135+
note = store.find_note_by_title(args.title)
136+
if note is None:
137+
print(
138+
f"No note found with title '{args.title}'.",
139+
file=sys.stderr,
140+
)
141+
return 1
142+
_print_note(note)
143+
return 0
144+
145+
if args.command == "list":
146+
results = store.find_all()
147+
if not results:
148+
print("No notes found.")
149+
return 0
150+
for note in results:
151+
_print_note_summary(note)
152+
return 0
153+
154+
if args.command == "reindex":
155+
count = store.reindex()
156+
print(f"Reindexed {count} note(s) from '{args.notes_dir}'.")
157+
return 0
158+
159+
parser.error(f"Unknown command: {args.command}")
160+
return 2
161+
162+
163+
if __name__ == "__main__":
164+
raise SystemExit(main())
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Read and write notes as Markdown files with YAML frontmatter.
2+
3+
The on-disk format looks like::
4+
5+
---
6+
title: My Note
7+
tags:
8+
- foo
9+
- bar
10+
---
11+
The body of the note goes here.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import re
17+
from pathlib import Path
18+
19+
import yaml
20+
21+
from .models import Note
22+
23+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?\n)---\s*\n?(.*)\Z", re.DOTALL)
24+
25+
26+
def slugify(title: str) -> str:
27+
"""Turn a note title into a filesystem-friendly slug."""
28+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", title.strip().lower()).strip("-")
29+
return slug or "note"
30+
31+
32+
def serialize_note(note: Note) -> str:
33+
"""Render a Note as Markdown text with YAML frontmatter."""
34+
frontmatter = yaml.safe_dump(
35+
{"title": note.title, "tags": list(note.tags)},
36+
sort_keys=False,
37+
)
38+
return f"---\n{frontmatter}---\n{note.body}"
39+
40+
41+
def deserialize_note(text: str, *, created_at=None) -> Note:
42+
"""Parse Markdown text with YAML frontmatter into a Note.
43+
44+
``created_at`` is not stored in the frontmatter (only title and tags
45+
are), so it must be supplied by the caller (e.g. from a database
46+
record or the file's modification time). If omitted, the Note's
47+
default (the current time) is used.
48+
"""
49+
match = _FRONTMATTER_RE.match(text)
50+
if not match:
51+
raise ValueError("Note text is missing YAML frontmatter")
52+
53+
raw_frontmatter, body = match.groups()
54+
metadata = yaml.safe_load(raw_frontmatter) or {}
55+
56+
title = metadata.get("title", "")
57+
tags = list(metadata.get("tags") or [])
58+
body = body.lstrip("\n")
59+
60+
kwargs = {"title": title, "body": body, "tags": tags}
61+
if created_at is not None:
62+
kwargs["created_at"] = created_at
63+
return Note(**kwargs)
64+
65+
66+
def write_note_file(note: Note, directory: Path) -> Path:
67+
"""Write ``note`` to a Markdown file inside ``directory`` and return its path."""
68+
directory.mkdir(parents=True, exist_ok=True)
69+
path = directory / f"{slugify(note.title)}.md"
70+
path.write_text(serialize_note(note), encoding="utf-8")
71+
return path
72+
73+
74+
def read_note_file(path: Path, *, created_at=None) -> Note:
75+
"""Read a Note from a Markdown file on disk."""
76+
return deserialize_note(
77+
path.read_text(encoding="utf-8"), created_at=created_at
78+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Data model for notes."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from datetime import datetime, timezone
7+
8+
9+
@dataclass
10+
class Note:
11+
"""A single Markdown note."""
12+
13+
title: str
14+
body: str
15+
tags: list[str] = field(default_factory=list)
16+
created_at: datetime = field(
17+
default_factory=lambda: datetime.now(timezone.utc)
18+
)
19+
updated_at: datetime = field(
20+
default_factory=lambda: datetime.now(timezone.utc)
21+
)
22+
is_archived: bool = False

0 commit comments

Comments
 (0)