diff --git a/cursor-vs-copilot-python/README.md b/cursor-vs-copilot-python/README.md new file mode 100644 index 0000000000..00ce5f49eb --- /dev/null +++ b/cursor-vs-copilot-python/README.md @@ -0,0 +1,3 @@ +# Cursor vs Copilot: Which AI Editor Is Better for Python? + +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/) diff --git a/cursor-vs-copilot-python/cursor/.gitignore b/cursor-vs-copilot-python/cursor/.gitignore new file mode 100644 index 0000000000..1ba289cf67 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/.gitignore @@ -0,0 +1,17 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ +.pytest_cache/ + +notes.db +notes.db-journal +notes.db-wal +notes.db-shm +/notes/ + +# Editor / OS +.DS_Store +*.swp diff --git a/cursor-vs-copilot-python/cursor/README.md b/cursor-vs-copilot-python/cursor/README.md new file mode 100644 index 0000000000..fbd2e991ac --- /dev/null +++ b/cursor-vs-copilot-python/cursor/README.md @@ -0,0 +1,55 @@ +# Notes Manager + +A simple command-line Markdown note manager. Each note is stored twice: + +- as a **Markdown file** (with YAML frontmatter) in a notes directory — the canonical, human-editable copy. +- as a row in a **SQLite index** (`notes.db`) — used for fast search and listing. + +## Install + +```bash +pip install -e . +``` + +This installs the `notes` command (entry point defined in `pyproject.toml`). + +## Usage + +```bash +notes [--notes-dir DIR] [--db PATH] [args] +``` + +`--notes-dir` (default `notes/`) and `--db` (default `notes.db`) let you point at a different notes store. + +### Commands + +| Command | Description | +|---|---| +| `notes add [--tags a,b] [--body TEXT \| --body-file PATH]` | Create or update a note (body read from `--body`, `--body-file`, or stdin). | +| `notes get <title>` | Print a note by its exact title. | +| `notes list` | List all notes, most recently created first. | +| `notes search <query>` | Search notes whose title or body contains `query` (case-insensitive). | +| `notes list-tag <tag>` | List notes with a given tag (case-insensitive, exact tag match). | +| `notes reindex` | Rebuild the SQLite index from the Markdown files on disk (fixes drift if the index and files get out of sync). | + +### Examples + +```bash +notes add "Git Rebase vs Merge" --tags git,vcs --body "Rebase rewrites history; merge preserves it." +notes search rebase +notes list-tag git +notes get "Git Rebase vs Merge" +``` + +## Notes on storage + +- Titles are unique; adding a note with an existing title updates (upserts) it. +- `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`. +- The `notes/` directory and `notes.db` hold your actual note data and are gitignored — they aren't meant to be committed to source control. + +## Development + +```bash +pip install -e . +pytest +``` diff --git a/cursor-vs-copilot-python/cursor/pyproject.toml b/cursor-vs-copilot-python/cursor/pyproject.toml new file mode 100644 index 0000000000..714d81dfa2 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "notes-manager-cursor-test" +version = "0.1.0" +description = "" +requires-python = ">=3.10" +dependencies = [ + "pyyaml==6.0.2", + "pytest==9.0.3", +] + +[project.scripts] +notes = "notes_manager_cursor_test.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/__init__.py b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/__init__.py new file mode 100644 index 0000000000..186214dee4 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/__init__.py @@ -0,0 +1,6 @@ +"""A command-line Markdown note manager.""" + +from .models import Note +from .store import NoteStore + +__all__ = ["Note", "NoteStore"] diff --git a/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/__main__.py b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/__main__.py new file mode 100644 index 0000000000..bfdcd0c115 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/cli.py b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/cli.py new file mode 100644 index 0000000000..53c0fe4b14 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/cli.py @@ -0,0 +1,164 @@ +"""Command-line interface for the Markdown note manager.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +from pathlib import Path + +from .models import Note +from .store import NoteStore + +DEFAULT_NOTES_DIR = Path("notes") +DEFAULT_DB_PATH = Path("notes.db") + + +def _parse_tags(raw: str | None) -> list[str]: + if not raw: + return [] + return [tag.strip() for tag in raw.split(",") if tag.strip()] + + +def _read_body(args: argparse.Namespace) -> str: + if args.body_file: + return Path(args.body_file).read_text(encoding="utf-8") + if args.body is not None: + return args.body + return sys.stdin.read() + + +def _print_note(note: Note) -> None: + tags = ", ".join(note.tags) if note.tags else "-" + print(f"# {note.title}") + print(f"tags: {tags}") + print(f"created_at: {note.created_at.isoformat()}") + print() + print(note.body) + + +def _print_note_summary(note: Note) -> None: + tags = ", ".join(note.tags) if note.tags else "-" + print(f"{note.title}\t[{tags}]\t{note.created_at.isoformat()}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="notes", + description="A command-line Markdown note manager.", + ) + parser.add_argument( + "--notes-dir", + default=DEFAULT_NOTES_DIR, + type=Path, + help=f"Directory to store Markdown note files in (default: {DEFAULT_NOTES_DIR})", + ) + parser.add_argument( + "--db", + default=DEFAULT_DB_PATH, + type=Path, + help=f"Path to the SQLite index database (default: {DEFAULT_DB_PATH})", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + add_parser = subparsers.add_parser("add", help="Add a new note") + add_parser.add_argument("title", help="Title of the note") + add_parser.add_argument( + "--tags", default="", help="Comma-separated list of tags" + ) + body_group = add_parser.add_mutually_exclusive_group() + body_group.add_argument("--body", help="Body text of the note") + body_group.add_argument( + "--body-file", help="Path to a file containing the note body" + ) + + search_parser = subparsers.add_parser( + "search", help="Search notes by title or body content" + ) + search_parser.add_argument("query", help="Text to search for") + + list_tag_parser = subparsers.add_parser( + "list-tag", help="List notes that have a given tag" + ) + list_tag_parser.add_argument("tag", help="Tag to filter by") + + get_parser = subparsers.add_parser( + "get", help="Retrieve a note by its exact title" + ) + get_parser.add_argument("title", help="Title of the note") + + subparsers.add_parser("list", help="List all notes") + + subparsers.add_parser( + "reindex", + help="Rebuild the SQLite search index from the Markdown files on disk", + ) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + with NoteStore(args.notes_dir, args.db) as store: + if args.command == "add": + note = Note( + title=args.title, + body=_read_body(args), + tags=_parse_tags(args.tags), + ) + store.add_note(note) + print(f"Added note '{note.title}'") + return 0 + + if args.command == "search": + results = store.find_notes_by_title_and_body(args.query) + if not results: + print("No notes found.") + return 0 + for note in results: + _print_note_summary(note) + return 0 + + if args.command == "list-tag": + results = store.find_notes_by_tag(args.tag) + if not results: + print(f"No notes found with tag '{args.tag}'.") + return 0 + for note in results: + _print_note_summary(note) + return 0 + + if args.command == "get": + note = store.find_note_by_title(args.title) + if note is None: + print( + f"No note found with title '{args.title}'.", + file=sys.stderr, + ) + return 1 + _print_note(note) + return 0 + + if args.command == "list": + results = store.find_all() + if not results: + print("No notes found.") + return 0 + for note in results: + _print_note_summary(note) + return 0 + + if args.command == "reindex": + count = store.reindex() + print(f"Reindexed {count} note(s) from '{args.notes_dir}'.") + return 0 + + parser.error(f"Unknown command: {args.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/markdown_io.py b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/markdown_io.py new file mode 100644 index 0000000000..32b36bf1c9 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/markdown_io.py @@ -0,0 +1,78 @@ +"""Read and write notes as Markdown files with YAML frontmatter. + +The on-disk format looks like:: + + --- + title: My Note + tags: + - foo + - bar + --- + The body of the note goes here. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +from .models import Note + +_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?\n)---\s*\n?(.*)\Z", re.DOTALL) + + +def slugify(title: str) -> str: + """Turn a note title into a filesystem-friendly slug.""" + slug = re.sub(r"[^a-zA-Z0-9]+", "-", title.strip().lower()).strip("-") + return slug or "note" + + +def serialize_note(note: Note) -> str: + """Render a Note as Markdown text with YAML frontmatter.""" + frontmatter = yaml.safe_dump( + {"title": note.title, "tags": list(note.tags)}, + sort_keys=False, + ) + return f"---\n{frontmatter}---\n{note.body}" + + +def deserialize_note(text: str, *, created_at=None) -> Note: + """Parse Markdown text with YAML frontmatter into a Note. + + ``created_at`` is not stored in the frontmatter (only title and tags + are), so it must be supplied by the caller (e.g. from a database + record or the file's modification time). If omitted, the Note's + default (the current time) is used. + """ + match = _FRONTMATTER_RE.match(text) + if not match: + raise ValueError("Note text is missing YAML frontmatter") + + raw_frontmatter, body = match.groups() + metadata = yaml.safe_load(raw_frontmatter) or {} + + title = metadata.get("title", "") + tags = list(metadata.get("tags") or []) + body = body.lstrip("\n") + + kwargs = {"title": title, "body": body, "tags": tags} + if created_at is not None: + kwargs["created_at"] = created_at + return Note(**kwargs) + + +def write_note_file(note: Note, directory: Path) -> Path: + """Write ``note`` to a Markdown file inside ``directory`` and return its path.""" + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{slugify(note.title)}.md" + path.write_text(serialize_note(note), encoding="utf-8") + return path + + +def read_note_file(path: Path, *, created_at=None) -> Note: + """Read a Note from a Markdown file on disk.""" + return deserialize_note( + path.read_text(encoding="utf-8"), created_at=created_at + ) diff --git a/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/models.py b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/models.py new file mode 100644 index 0000000000..14654f462d --- /dev/null +++ b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/models.py @@ -0,0 +1,22 @@ +"""Data model for notes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass +class Note: + """A single Markdown note.""" + + title: str + body: str + tags: list[str] = field(default_factory=list) + created_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc) + ) + updated_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc) + ) + is_archived: bool = False diff --git a/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/store.py b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/store.py new file mode 100644 index 0000000000..9ffe19790e --- /dev/null +++ b/cursor-vs-copilot-python/cursor/src/notes_manager_cursor_test/store.py @@ -0,0 +1,151 @@ +"""SQLite-backed storage for notes. + +Each note is persisted twice: + +* as a Markdown file (with YAML frontmatter) on disk, which is the + canonical, human-editable representation, and +* as a row in a SQLite database, which acts as a fast, queryable index + used for search/listing operations. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from pathlib import Path + +from . import markdown_io +from .models import Note + +_TAG_SEPARATOR = "," + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS notes ( + title TEXT PRIMARY KEY, + body TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + filepath TEXT NOT NULL +); +""" + + +class NoteStore: + """Add, search, and retrieve Markdown notes backed by SQLite.""" + + def __init__(self, notes_dir: Path | str, db_path: Path | str): + self.notes_dir = Path(notes_dir) + self.db_path = Path(db_path) + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + + self._conn = sqlite3.connect(self.db_path) + self._conn.row_factory = sqlite3.Row + self._conn.execute(_SCHEMA) + self._conn.commit() + + def close(self) -> None: + self._conn.close() + + def __enter__(self) -> "NoteStore": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + def add_note(self, note: Note) -> Note: + """Write ``note`` to disk and index it in the database. + + Returns the note as-is (useful when chaining). + """ + filepath = markdown_io.write_note_file(note, self.notes_dir) + self._conn.execute( + """ + INSERT INTO notes (title, body, tags, created_at, filepath) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(title) DO UPDATE SET + body = excluded.body, + tags = excluded.tags, + created_at = excluded.created_at, + filepath = excluded.filepath + """, + ( + note.title, + note.body, + _TAG_SEPARATOR.join(note.tags), + note.created_at.isoformat(), + str(filepath), + ), + ) + self._conn.commit() + return note + + def reindex(self) -> int: + """Rebuild the SQLite index from the Markdown files on disk. + + Every ``*.md`` file in ``self.notes_dir`` is read and upserted into + the database, so files that were added or edited outside of + :meth:`add_note` (or whose index row was lost) become searchable + again. Returns the number of notes indexed. + """ + count = 0 + for path in sorted(self.notes_dir.glob("*.md")): + created_at = datetime.fromtimestamp( + path.stat().st_mtime + ).astimezone() + note = markdown_io.read_note_file(path, created_at=created_at) + self.add_note(note) + count += 1 + return count + + def find_notes_by_title_and_body(self, query: str) -> list[Note]: + """Return notes whose title or body contains `query` (case-insensitive).""" + rows = self._conn.execute( + """ + SELECT * FROM notes + WHERE title LIKE ? COLLATE NOCASE + OR body LIKE ? COLLATE NOCASE + ORDER BY created_at DESC + """, + (f"%{query}%", f"%{query}%"), + ).fetchall() + return [self._row_to_note(row) for row in rows] + + def find_notes_by_tag(self, tag: str) -> list[Note]: + """Return all notes tagged with ``tag`` (case-insensitive, exact tag match).""" + rows = self._conn.execute( + "SELECT * FROM notes ORDER BY created_at DESC" + ).fetchall() + return [ + note + for row in rows + if tag.lower() in {t.lower() for t in _split_tags(row["tags"])} + for note in [self._row_to_note(row)] + ] + + def find_note_by_title(self, title: str) -> Note | None: + """Retrieve a single note by its exact title, or ``None`` if not found.""" + row = self._conn.execute( + "SELECT * FROM notes WHERE title = ?", (title,) + ).fetchone() + return self._row_to_note(row) if row else None + + def find_all(self) -> list[Note]: + """Return every note in the store, most recently created first.""" + rows = self._conn.execute( + "SELECT * FROM notes ORDER BY created_at DESC" + ).fetchall() + return [self._row_to_note(row) for row in rows] + + @staticmethod + def _row_to_note(row: sqlite3.Row) -> Note: + return Note( + title=row["title"], + body=row["body"], + tags=_split_tags(row["tags"]), + created_at=datetime.fromisoformat(row["created_at"]), + ) + + +def _split_tags(raw: str) -> list[str]: + return [tag for tag in raw.split(_TAG_SEPARATOR) if tag] diff --git a/cursor-vs-copilot-python/cursor/tests/__init__.py b/cursor-vs-copilot-python/cursor/tests/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/cursor-vs-copilot-python/cursor/tests/test_cli.py b/cursor-vs-copilot-python/cursor/tests/test_cli.py new file mode 100644 index 0000000000..2d335f5069 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/tests/test_cli.py @@ -0,0 +1,64 @@ +from notes_manager_cursor_test.cli import main + + +def run(tmp_path, *args): + notes_dir = tmp_path / "notes" + db_path = tmp_path / "notes.db" + return main(["--notes-dir", str(notes_dir), "--db", str(db_path), *args]) + + +def test_add_and_get(tmp_path, capsys): + exit_code = run( + tmp_path, + "add", + "My Note", + "--tags", + "foo,bar", + "--body", + "Hello world", + ) + assert exit_code == 0 + + exit_code = run(tmp_path, "get", "My Note") + assert exit_code == 0 + out = capsys.readouterr().out + assert "My Note" in out + assert "foo, bar" in out + assert "Hello world" in out + + +def test_get_missing_note_returns_error(tmp_path): + assert run(tmp_path, "get", "Nope") == 1 + + +def test_search(tmp_path, capsys): + run(tmp_path, "add", "Trip Plan", "--body", "Visit the mountains") + run(tmp_path, "add", "Unrelated", "--body", "Nothing relevant") + capsys.readouterr() + + run(tmp_path, "search", "mountains") + out = capsys.readouterr().out + assert "Trip Plan" in out + assert "Unrelated" not in out + + +def test_list_tag(tmp_path, capsys): + run(tmp_path, "add", "Note A", "--tags", "red", "--body", "a") + run(tmp_path, "add", "Note B", "--tags", "blue", "--body", "b") + capsys.readouterr() + + run(tmp_path, "list-tag", "red") + out = capsys.readouterr().out + assert "Note A" in out + assert "Note B" not in out + + +def test_list_all(tmp_path, capsys): + run(tmp_path, "add", "Note A", "--body", "a") + run(tmp_path, "add", "Note B", "--body", "b") + capsys.readouterr() + + run(tmp_path, "list") + out = capsys.readouterr().out + assert "Note A" in out + assert "Note B" in out diff --git a/cursor-vs-copilot-python/cursor/tests/test_markdown_io.py b/cursor-vs-copilot-python/cursor/tests/test_markdown_io.py new file mode 100644 index 0000000000..668cbedbb0 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/tests/test_markdown_io.py @@ -0,0 +1,58 @@ +from datetime import datetime, timezone + +from notes_manager_cursor_test.markdown_io import ( + deserialize_note, + read_note_file, + serialize_note, + slugify, + write_note_file, +) +from notes_manager_cursor_test.models import Note + + +def test_slugify(): + assert slugify("Hello, World!") == "hello-world" + assert slugify(" ") == "note" + + +def test_serialize_roundtrip(): + note = Note(title="My Note", body="Some body text.\n", tags=["foo", "bar"]) + text = serialize_note(note) + + assert text.startswith("---\n") + assert "title: My Note" in text + assert "Some body text." in text + + parsed = deserialize_note(text, created_at=note.created_at) + assert parsed.title == note.title + assert parsed.tags == note.tags + assert parsed.body.strip() == note.body.strip() + assert parsed.created_at == note.created_at + + +def test_deserialize_requires_frontmatter(): + try: + deserialize_note("no frontmatter here") + except ValueError: + pass + else: + raise AssertionError("Expected ValueError for missing frontmatter") + + +def test_write_and_read_note_file(tmp_path): + created_at = datetime(2024, 1, 1, tzinfo=timezone.utc) + note = Note( + title="Grocery List", + body="- milk\n- eggs\n", + tags=["home"], + created_at=created_at, + ) + + path = write_note_file(note, tmp_path) + assert path.exists() + assert path.name == "grocery-list.md" + + loaded = read_note_file(path, created_at=created_at) + assert loaded.title == note.title + assert loaded.tags == note.tags + assert loaded.body.strip() == note.body.strip() diff --git a/cursor-vs-copilot-python/cursor/tests/test_package.py b/cursor-vs-copilot-python/cursor/tests/test_package.py new file mode 100644 index 0000000000..dd86f63d15 --- /dev/null +++ b/cursor-vs-copilot-python/cursor/tests/test_package.py @@ -0,0 +1,5 @@ +import notes_manager_cursor_test + + +def test_package_importable(): + assert notes_manager_cursor_test is not None diff --git a/cursor-vs-copilot-python/cursor/tests/test_store.py b/cursor-vs-copilot-python/cursor/tests/test_store.py new file mode 100644 index 0000000000..39c06dee4e --- /dev/null +++ b/cursor-vs-copilot-python/cursor/tests/test_store.py @@ -0,0 +1,74 @@ +import pytest + +from notes_manager_cursor_test.models import Note +from notes_manager_cursor_test.store import NoteStore + + +@pytest.fixture +def store(tmp_path): + with NoteStore(tmp_path / "notes", tmp_path / "notes.db") as store: + yield store + + +def test_add_and_get_note(store): + note = Note( + title="Recipe", body="Mix flour and water.", tags=["cooking", "bread"] + ) + store.add_note(note) + + fetched = store.find_note_by_title("Recipe") + assert fetched is not None + assert fetched.title == "Recipe" + assert fetched.body == "Mix flour and water." + assert set(fetched.tags) == {"cooking", "bread"} + + +def test_get_missing_note_returns_none(store): + assert store.find_note_by_title("Nonexistent") is None + + +def test_add_note_writes_markdown_file(store): + note = Note(title="Shopping", body="- bread\n- butter", tags=["home"]) + store.add_note(note) + + files = list(store.notes_dir.glob("*.md")) + assert len(files) == 1 + assert "title: Shopping" in files[0].read_text() + + +def test_search_notes_matches_title_and_body(store): + store.add_note( + Note(title="Trip Plan", body="Visit the mountains", tags=["travel"]) + ) + store.add_note( + Note(title="Work Notes", body="Discuss trip budget", tags=["work"]) + ) + store.add_note( + Note(title="Unrelated", body="Nothing to see here", tags=[]) + ) + + results = store.find_notes_by_title_and_body("trip") + titles = {note.title for note in results} + assert titles == {"Trip Plan", "Work Notes"} + + +def test_list_notes_by_tag(store): + store.add_note(Note(title="Note A", body="a", tags=["red", "blue"])) + store.add_note(Note(title="Note B", body="b", tags=["blue"])) + store.add_note(Note(title="Note C", body="c", tags=["green"])) + + results = store.find_notes_by_tag("blue") + titles = {note.title for note in results} + assert titles == {"Note A", "Note B"} + + assert store.find_notes_by_tag("purple") == [] + + +def test_add_note_upserts_on_same_title(store): + store.add_note(Note(title="Duplicate", body="first version", tags=["v1"])) + store.add_note(Note(title="Duplicate", body="second version", tags=["v2"])) + + fetched = store.find_note_by_title("Duplicate") + assert fetched.body == "second version" + assert fetched.tags == ["v2"] + assert len(store.find_all()) == 1 diff --git a/cursor-vs-copilot-python/github-copilot/.gitignore b/cursor-vs-copilot-python/github-copilot/.gitignore new file mode 100644 index 0000000000..c393f8501d --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ +.DS_Store diff --git a/cursor-vs-copilot-python/github-copilot/README.md b/cursor-vs-copilot-python/github-copilot/README.md new file mode 100644 index 0000000000..ab701ef17b --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/README.md @@ -0,0 +1,36 @@ +# Notes Manager + +A CLI for managing Markdown notes, backed by a SQLite database. + +## Install + +```bash +pip install -e . +``` + +## Usage + +``` +notes [--db PATH] <command> [args] +``` + +`--db` sets the SQLite database path (default: `~/.notes-manager-copilot-test/notes.db`). + +| Command | Description | +|---|---| +| `notes add <title> <body> [--tags TAG ...]` | Create a note | +| `notes get <title>` | Fetch a note by exact title | +| `notes search <query>` | Find notes whose title or body contains `query` | +| `notes list-tag <tag>` | List notes with a given tag | + +## Storage + +- Notes are stored in a SQLite database at the `--db` path. +- Each note added via `add` is also written as a Markdown file (with YAML frontmatter for `title`, `tags`, `created_at`) to a `notes/` folder next to the database. + +## Development + +```bash +pip install -e ".[dev]" +pytest +``` diff --git a/cursor-vs-copilot-python/github-copilot/pyproject.toml b/cursor-vs-copilot-python/github-copilot/pyproject.toml new file mode 100644 index 0000000000..8f1bdfde1d --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "notes-manager-copilot-test" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "pyyaml==6.0.2", +] + +[project.optional-dependencies] +dev = [ + "pytest==9.0.3", +] + +[project.scripts] +notes = "notes_manager_copilot_test.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/__init__.py b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/cli.py b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/cli.py new file mode 100644 index 0000000000..b550c0f9b9 --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/cli.py @@ -0,0 +1,112 @@ +"""Command-line interface for the note manager.""" + +import argparse +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +from .markdown_io import note_to_markdown +from .models import Note +from .store import NoteStore + +DEFAULT_DB_PATH = Path.home() / ".notes-manager-copilot-test" / "notes.db" + + +def _notes_dir(db_path: Path) -> Path: + return db_path.parent / "notes" + + +def _slugify(title: str) -> str: + slug = re.sub(r"[^A-Za-z0-9._-]+", "_", title).strip("_") + return slug or "untitled" + + +def _write_note_file(notes_dir: Path, note: Note) -> None: + notes_dir.mkdir(parents=True, exist_ok=True) + (notes_dir / f"{_slugify(note.title)}.md").write_text( + note_to_markdown(note) + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="notes", description="Manage Markdown notes" + ) + parser.add_argument( + "--db", + type=Path, + default=DEFAULT_DB_PATH, + help="Path to the SQLite database file", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + add_parser = subparsers.add_parser("add", help="Add a note") + add_parser.add_argument("title") + add_parser.add_argument("body") + add_parser.add_argument("--tags", nargs="*", default=[]) + + search_parser = subparsers.add_parser( + "search", help="Search notes by title or body" + ) + search_parser.add_argument("query") + + list_tag_parser = subparsers.add_parser( + "list-tag", help="List notes with a given tag" + ) + list_tag_parser.add_argument("tag") + + get_parser = subparsers.add_parser("get", help="Get a note by title") + get_parser.add_argument("title") + + return parser + + +def _print_note(note: Note) -> None: + print(note_to_markdown(note)) + print() + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + args.db.parent.mkdir(parents=True, exist_ok=True) + store = NoteStore(args.db) + try: + if args.command == "add": + note = Note( + title=args.title, + body=args.body, + tags=list(args.tags), + created_at=datetime.now(timezone.utc), + ) + store.add_note(note) + _write_note_file(_notes_dir(args.db), note) + print(f"Added note {note.title!r}") + elif args.command == "search": + notes = store.search_notes(args.query) + if not notes: + print("No notes found") + for note in notes: + _print_note(note) + elif args.command == "list-tag": + notes = store.list_by_tag(args.tag) + if not notes: + print("No notes found") + for note in notes: + _print_note(note) + elif args.command == "get": + note = store.get_by_title(args.title) + if note is None: + print(f"No note titled {args.title!r}", file=sys.stderr) + return 1 + _print_note(note) + finally: + store.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/markdown_io.py b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/markdown_io.py new file mode 100644 index 0000000000..006c0dbbdb --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/markdown_io.py @@ -0,0 +1,42 @@ +"""Serialize and parse notes as Markdown files with YAML frontmatter.""" + +from datetime import datetime + +import yaml + +from .models import Note + +FRONTMATTER_DELIMITER = "---" + + +def note_to_markdown(note: Note) -> str: + """Render a note as Markdown text with a YAML frontmatter header.""" + frontmatter = { + "title": note.title, + "tags": note.tags, + "created_at": note.created_at.isoformat(), + } + frontmatter_text = yaml.safe_dump(frontmatter, sort_keys=False) + return f"{FRONTMATTER_DELIMITER}\n{frontmatter_text}{FRONTMATTER_DELIMITER}\n{note.body}" + + +def note_from_markdown(text: str) -> Note: + """Parse Markdown text with a YAML frontmatter header into a note.""" + if not text.startswith(f"{FRONTMATTER_DELIMITER}\n"): + raise ValueError("Markdown text is missing a YAML frontmatter header") + + _, frontmatter_text, body = text.split(FRONTMATTER_DELIMITER, 2) + frontmatter = yaml.safe_load(frontmatter_text) or {} + + title = frontmatter.get("title", "") + tags = list(frontmatter.get("tags") or []) + created_at_raw = frontmatter.get("created_at") + created_at = ( + datetime.fromisoformat(created_at_raw) + if created_at_raw + else datetime.utcnow() + ) + + return Note( + title=title, body=body.lstrip("\n"), tags=tags, created_at=created_at + ) diff --git a/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/models.py b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/models.py new file mode 100644 index 0000000000..198fba5c0a --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/models.py @@ -0,0 +1,20 @@ +"""Data model for notes.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass +class Note: + """A single note.""" + + title: str + body: str + tags: list[str] = field(default_factory=list) + created_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc) + ) + updated_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc) + ) + is_archived: bool = False diff --git a/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/store.py b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/store.py new file mode 100644 index 0000000000..87f322a28d --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/src/notes_manager_copilot_test/store.py @@ -0,0 +1,80 @@ +"""SQLite-backed storage for notes.""" + +import json +import sqlite3 +from datetime import datetime +from os import PathLike + +from .models import Note + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL UNIQUE, + body TEXT NOT NULL, + tags TEXT NOT NULL, + created_at TEXT NOT NULL +) +""" + + +class NoteStore: + """Stores and queries notes in a SQLite database.""" + + def __init__(self, db_path: str | PathLike[str]) -> None: + self._conn = sqlite3.connect(db_path) + self._conn.row_factory = sqlite3.Row + self._conn.execute(_SCHEMA) + self._conn.commit() + + def add_note(self, note: Note) -> None: + """Insert a new note into the store.""" + self._conn.execute( + "INSERT INTO notes (title, body, tags, created_at) VALUES (?, ?, ?, ?)", + ( + note.title, + note.body, + json.dumps(note.tags), + note.created_at.isoformat(), + ), + ) + self._conn.commit() + + def search_notes(self, query: str) -> list[Note]: + """Return notes whose title or body contains the given query text.""" + rows = self._conn.execute( + "SELECT * FROM notes WHERE title LIKE ? OR body LIKE ? ORDER BY created_at", + (f"%{query}%", f"%{query}%"), + ).fetchall() + return [self._row_to_note(row) for row in rows] + + def list_by_tag(self, tag: str) -> list[Note]: + """Return notes tagged with the given tag.""" + rows = self._conn.execute( + "SELECT * FROM notes ORDER BY created_at" + ).fetchall() + return [ + note + for row in rows + if tag in (note := self._row_to_note(row)).tags + ] + + def get_by_title(self, title: str) -> Note | None: + """Return the note with the given title, or None if it doesn't exist.""" + row = self._conn.execute( + "SELECT * FROM notes WHERE title = ?", (title,) + ).fetchone() + return self._row_to_note(row) if row is not None else None + + def close(self) -> None: + """Close the underlying database connection.""" + self._conn.close() + + @staticmethod + def _row_to_note(row: sqlite3.Row) -> Note: + return Note( + title=row["title"], + body=row["body"], + tags=json.loads(row["tags"]), + created_at=datetime.fromisoformat(row["created_at"]), + ) diff --git a/cursor-vs-copilot-python/github-copilot/tests/__init__.py b/cursor-vs-copilot-python/github-copilot/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cursor-vs-copilot-python/github-copilot/tests/test_cli.py b/cursor-vs-copilot-python/github-copilot/tests/test_cli.py new file mode 100644 index 0000000000..1035d0fc0e --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/tests/test_cli.py @@ -0,0 +1,54 @@ +import pytest + +from notes_manager_copilot_test.cli import main + + +@pytest.fixture +def db_path(tmp_path): + return tmp_path / "notes.db" + + +def test_add_and_get(db_path, capsys): + exit_code = main( + ["--db", str(db_path), "add", "Title", "Body text", "--tags", "a", "b"] + ) + assert exit_code == 0 + capsys.readouterr() + + exit_code = main(["--db", str(db_path), "get", "Title"]) + assert exit_code == 0 + output = capsys.readouterr().out + assert "title: Title" in output + assert "Body text" in output + + +def test_get_missing_returns_error(db_path, capsys): + exit_code = main(["--db", str(db_path), "get", "Nope"]) + assert exit_code == 1 + + +def test_search(db_path, capsys): + main( + ["--db", str(db_path), "add", "Shopping", "Buy milk", "--tags", "home"] + ) + capsys.readouterr() + + exit_code = main(["--db", str(db_path), "search", "milk"]) + assert exit_code == 0 + output = capsys.readouterr().out + assert "Shopping" in output + + +def test_list_tag(db_path, capsys): + main(["--db", str(db_path), "add", "Note1", "Body1", "--tags", "work"]) + capsys.readouterr() + + exit_code = main(["--db", str(db_path), "list-tag", "work"]) + assert exit_code == 0 + output = capsys.readouterr().out + assert "Note1" in output + + exit_code = main(["--db", str(db_path), "list-tag", "missing"]) + assert exit_code == 0 + output = capsys.readouterr().out + assert "No notes found" in output diff --git a/cursor-vs-copilot-python/github-copilot/tests/test_markdown_io.py b/cursor-vs-copilot-python/github-copilot/tests/test_markdown_io.py new file mode 100644 index 0000000000..f7bc7faa43 --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/tests/test_markdown_io.py @@ -0,0 +1,40 @@ +from datetime import datetime + +import pytest + +from notes_manager_copilot_test.markdown_io import ( + note_from_markdown, + note_to_markdown, +) +from notes_manager_copilot_test.models import Note + + +def test_round_trip(): + note = Note( + title="My Note", + body="Some body text.\n", + tags=["work", "ideas"], + created_at=datetime(2024, 1, 1, 12, 30), + ) + markdown = note_to_markdown(note) + parsed = note_from_markdown(markdown) + + assert parsed.title == note.title + assert parsed.body == note.body + assert parsed.tags == note.tags + assert parsed.created_at == note.created_at + + +def test_note_to_markdown_contains_frontmatter(): + note = Note( + title="T", body="B", tags=["x"], created_at=datetime(2024, 1, 1) + ) + markdown = note_to_markdown(note) + assert markdown.startswith("---\n") + assert "title: T" in markdown + assert "tags:" in markdown + + +def test_note_from_markdown_missing_frontmatter_raises(): + with pytest.raises(ValueError): + note_from_markdown("no frontmatter here") diff --git a/cursor-vs-copilot-python/github-copilot/tests/test_models.py b/cursor-vs-copilot-python/github-copilot/tests/test_models.py new file mode 100644 index 0000000000..cafcceb78e --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/tests/test_models.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from notes_manager_copilot_test.models import Note + + +def test_note_defaults(): + note = Note(title="Title", body="Body") + assert note.title == "Title" + assert note.body == "Body" + assert note.tags == [] + assert isinstance(note.created_at, datetime) + + +def test_note_explicit_fields(): + created_at = datetime(2024, 1, 1) + note = Note( + title="Title", body="Body", tags=["a", "b"], created_at=created_at + ) + assert note.tags == ["a", "b"] + assert note.created_at == created_at diff --git a/cursor-vs-copilot-python/github-copilot/tests/test_store.py b/cursor-vs-copilot-python/github-copilot/tests/test_store.py new file mode 100644 index 0000000000..038c8b4b7d --- /dev/null +++ b/cursor-vs-copilot-python/github-copilot/tests/test_store.py @@ -0,0 +1,93 @@ +from datetime import datetime + +import pytest + +from notes_manager_copilot_test.models import Note +from notes_manager_copilot_test.store import NoteStore + + +@pytest.fixture +def store(tmp_path): + db_path = tmp_path / "notes.db" + note_store = NoteStore(db_path) + yield note_store + note_store.close() + + +def test_add_and_get_by_title(store): + note = Note( + title="Groceries", + body="Milk, eggs", + tags=["home"], + created_at=datetime(2024, 1, 1), + ) + store.add_note(note) + + fetched = store.get_by_title("Groceries") + assert fetched is not None + assert fetched.title == "Groceries" + assert fetched.body == "Milk, eggs" + assert fetched.tags == ["home"] + assert fetched.created_at == datetime(2024, 1, 1) + + +def test_get_by_title_missing_returns_none(store): + assert store.get_by_title("Nope") is None + + +def test_search_notes(store): + store.add_note( + Note( + title="Trip plan", + body="Visit museum", + tags=["travel"], + created_at=datetime(2024, 1, 1), + ) + ) + store.add_note( + Note( + title="Recipe", + body="Bake bread", + tags=["food"], + created_at=datetime(2024, 1, 2), + ) + ) + + results = store.search_notes("museum") + assert len(results) == 1 + assert results[0].title == "Trip plan" + + title_results = store.search_notes("Recipe") + assert len(title_results) == 1 + assert title_results[0].title == "Recipe" + + assert store.search_notes("nonexistent") == [] + + +def test_list_by_tag(store): + store.add_note( + Note( + title="A", body="a", tags=["work"], created_at=datetime(2024, 1, 1) + ) + ) + store.add_note( + Note( + title="B", + body="b", + tags=["personal"], + created_at=datetime(2024, 1, 2), + ) + ) + store.add_note( + Note( + title="C", + body="c", + tags=["work", "urgent"], + created_at=datetime(2024, 1, 3), + ) + ) + + work_notes = store.list_by_tag("work") + assert {n.title for n in work_notes} == {"A", "C"} + + assert store.list_by_tag("missing") == [] diff --git a/cursor-vs-copilot-python/prompts.md b/cursor-vs-copilot-python/prompts.md new file mode 100644 index 0000000000..08accb8c27 --- /dev/null +++ b/cursor-vs-copilot-python/prompts.md @@ -0,0 +1,79 @@ +# Prompts Used in Cursor vs GitHub Copilot + +This file contains the prompts used in the **Cursor vs GitHub Copilot: Which AI Editor Is Better for Python?** comparison. The same prompts are used in both editors to compare how each editor handles the same development task. + +## Project Setup + +Use this prompt in **Agent** mode to set up the Markdown note manager project. It asks the editor to create the Python environment, install the required dependencies, add the test directory, and follow standard Python packaging conventions. + +```text +Set up a Python project in this directory, following standard Python +packaging conventions: +- Create a virtual environment +- Install pyyaml==6.0.2 and pytest==9.0.3 +- Add a tests/ directory +- Use the directory name as the package name +- Only include the dependencies listed above +``` + +## Implementing the Application + +Use this prompt in **Agent** mode after setting up the project. It defines the requirements for the command-line Markdown note manager, including Markdown storage, YAML frontmatter, the note data model, SQLite persistence, and the command-line interface. + +```text +Build a command-line Markdown note manager for this project. + +Requirements: + +- Store notes as Markdown files with YAML frontmatter containing a + title and tags. +- Represent each note as a dataclass with title, body, tags, and + created_at fields. +- Create a SQLite-backed NoteStore that can add notes, search notes, + list notes by tag, and retrieve notes by title. +- Build an argparse command-line interface that exposes those + operations. +``` + +## Testing and Debugging + +Use this prompt in **Agent** mode after deliberately removing the `self._conn.commit()` call from `NoteStore.add_note()`. It asks the editor to run the existing tests, investigate any failures, fix the underlying problem, and verify the fix by running the complete test suite again. + +```text +Run the existing pytest test suite. + +If any tests fail, investigate the root cause, fix the underlying issue, +and rerun the tests until the entire suite passes. +``` + +## Planning the Archiving Feature + +Use this prompt in **Plan** mode to compare how Cursor and GitHub Copilot plan a multi-file change before modifying the project. The feature adds support for archiving notes while keeping archived notes out of normal searches and listings unless explicitly requested. + +```text +Create a plan to add support for archiving notes. + +- Archived notes shouldn't appear in normal searches or listings. +- Add an `--include-archived` option to the search and list commands + so archived notes can be included when needed. +- Integrate the feature cleanly with the existing application without + introducing duplicate logic. +``` + +## Reviewing the Database Layer + +Use this prompt in **Ask** mode after deliberately replacing the parameterized search query with an interpolated SQL query. It asks the editor to inspect the database layer for correctness, SQL safety, and code quality without changing the implementation. + +```text +Review the database layer for correctness, SQL safety, +and general code quality. +Identify any issues and suggest improvements without modifying the code. +``` + +## Reviewing Pending Changes in Cursor + +Use the `/review` command in Cursor after introducing the SQL injection vulnerability. Unlike the broader review in Ask mode, `/review` focuses on the changes in the current diff and identifies issues introduced by those changes. + +```text +/review +``` diff --git a/cursor-vs-copilot-python/test-snippets.md b/cursor-vs-copilot-python/test-snippets.md new file mode 100644 index 0000000000..f684b46b53 --- /dev/null +++ b/cursor-vs-copilot-python/test-snippets.md @@ -0,0 +1,119 @@ +# Test Snippets + +Code changes used for the debugging, code completion, and code review tests in **Cursor vs GitHub Copilot: Which AI Editor Is Better for Python?** + +## Debugging + +Remove the `self._conn.commit()` call immediately after the SQLite insert operation in `NoteStore.add_note()`: + +```python +self._conn.commit() +``` + +## AI Code Completion + +### Add `updated_at` + +Add an `updated_at` field to the `Note` dataclass after `created_at`: + +```python +@dataclass +class Note: + title: str + body: str + tags: list[str] + created_at: datetime + updated_at +``` + +### Rename the Search Method + +Rename the `search_notes()` method: + +```python +def search_notes(self, query: str) -> list[Note]: +``` + +to: + +```python +def search(self, query: str) -> list[Note]: +``` + +## Code Review + +Replace the parameterized `search_notes()` implementation with the corresponding vulnerable version. + +### Cursor + +Original: + +```python +def search_notes(self, query: str) -> list[Note]: + """ + Return notes whose title or body contains `query` (case-insensitive). + """ + pattern = f"%{query}%" + rows = self._conn.execute( + """ + SELECT * FROM notes + WHERE title LIKE ? COLLATE NOCASE + OR body LIKE ? COLLATE NOCASE + ORDER BY created_at DESC + """, + (pattern, pattern), + ).fetchall() + return [self._row_to_note(row) for row in rows] +``` + +Replace with: + +```python +def search_notes(self, query: str) -> list[Note]: + """ + Return notes whose title or body contains `query` (case-insensitive). + """ + rows = self._conn.execute( + f""" + SELECT * FROM notes + WHERE title LIKE '%{query}%' COLLATE NOCASE + OR body LIKE '%{query}%' COLLATE NOCASE + ORDER BY created_at DESC + """ + ).fetchall() + return [self._row_to_note(row) for row in rows] +``` + +### GitHub Copilot + +Original: + +```python +def search_notes(self, query: str) -> list[Note]: + """Return notes whose title or body contains the given query text.""" + rows = self._conn.execute( + """ + SELECT * FROM notes + WHERE title LIKE ? OR body LIKE ? + ORDER BY created_at + """, + (f"%{query}%", f"%{query}%"), + ).fetchall() + return [self._row_to_note(row) for row in rows] +``` + +Replace with: + +```python +def search_notes(self, query: str) -> list[Note]: + """Return notes whose title or body contains the given query text.""" + rows = self._conn.execute( + f""" + SELECT * FROM notes + WHERE title LIKE '%{query}%' + OR body LIKE '%{query}%' + ORDER BY created_at + """ + ).fetchall() + return [self._row_to_note(row) for row in rows] +```