diff --git a/.copilot-track/crawl/README.md b/.copilot-track/crawl/README.md new file mode 100644 index 00000000..fc25c508 --- /dev/null +++ b/.copilot-track/crawl/README.md @@ -0,0 +1,73 @@ +# Crawl Track — GitHub Copilot AI Engineering + +This directory contains documentation and resources for the **Crawl** level of the AI Engineering track, where you use GitHub Copilot as an **assistant** for single-file or single-module exercises. + +## How This Track Works + +### Chain of PRs +Each exercise builds on the previous one. Exercises are numbered Ex 0–15: +- **Ex 0 (Bootstrap):** Set up scaffolding and documentation +- **Ex 1–15:** Progressive exercises that grow in scope and complexity + +Each exercise creates a new branch and PR. PRs are small, reversible, and focused on one goal. + +### Branch Naming +All branches follow this pattern: +``` +crawl//ex- +``` + +Example: `crawl/nowakfli/ex0-bootstrap` + +### Evidence in PRs +Every PR must include: +1. **What changed:** Clear summary of modifications +2. **Why:** Motivation and acceptance criteria met +3. **Evidence:** Test output, logs, or metrics proving the change works +4. **Rollback plan:** How to quickly undo if needed + +### Copilot Usage +In the Crawl track, Copilot helps with: +- **Inline suggestions:** Code completion while typing +- **/explain:** Understanding existing code +- **/tests:** Generating test cases +- **Chat prompts:** Planning changes before coding + +Copilot is your *assistant*—you drive the work, Copilot provides suggestions. + +## Repository Structure + +``` +. +├── ai-track-docs/ # Shared documentation across all exercises +│ ├── SYSTEM-OVERVIEW.md # Repo summary, entry points, test approach +│ ├── build-test.md # Exact build and test commands +│ ├── architecture.mmd # System diagram (Mermaid) +│ ├── dependencies.md # Dependency policy (added in Ex 7) +│ └── ... # Additional docs as exercises progress +├── .copilot-track/ +│ └── crawl/ # Crawl track resources +│ └── README.md # This file +``` + +## Exercise Pattern + +1. Create a branch: `git checkout -b crawl//ex-` +2. Send full prompt to Copilot Chat +3. Implement changes (Copilot assists) +4. Commit: `git add . && git commit -m "crawl: ex "` +5. Push: `git push -u origin crawl//ex-` +6. Open PR with evidence and rollback plan +7. Once approved, PR description becomes part of the track record + +## Guardrails + +- **Keep PRs small:** One goal per exercise +- **No secrets:** Don't commit API keys or credentials +- **Exclude vendor:** Don't modify `node_modules`, `vendor`, or submodules +- **Evidence required:** Every PR needs test output or metrics +- **Reversible:** Every change should be revertible in one commit + +## Next Steps + +Start with **Ex 1: Repo Orientation** to build a mental model of the codebase and pick a low-risk module to work with throughout the track. diff --git a/.gitignore b/.gitignore index d54b2511..ca1ddf7a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,15 @@ output.json .env.test.local .env.production.local +# Common secret and certificate files +*.pem +*.key +*.crt +*.cer +*.pfx +*.p12 +secrets.* + # IDE and Editor directories .vscode/* !.vscode/extensions.json diff --git a/ai-track-docs/SYSTEM-OVERVIEW.md b/ai-track-docs/SYSTEM-OVERVIEW.md new file mode 100644 index 00000000..3837c056 --- /dev/null +++ b/ai-track-docs/SYSTEM-OVERVIEW.md @@ -0,0 +1,77 @@ +# System Overview + +## Repository Purpose + +This is the **GitHub Copilot CLI for Beginners** — an educational course teaching AI-assisted development workflows. The repo contains: +- Chapters 00–07 (Markdown lessons with hands-on exercises) +- Sample applications: Python (primary), C#, and JavaScript versions of a book collection CLI +- Supporting assets: images, skills, agent templates, MCP configs + +**Primary Languages:** Python (exercise focus), C#, JavaScript, Markdown + +## Entry Points + +| Language | Entry Point | Purpose | +|----------|------------|---------| +| **Python** | `samples/book-app-project/book_app.py` | CLI: add/list/mark-as-read books | +| **C#** | `samples/book-app-project-cs/Program.cs` | CLI equivalent in C# | +| **JavaScript** | `samples/book-app-project-js/book_app.js` | CLI equivalent in JavaScript | + +All three versions manage a book collection stored in `data.json`. + +## Architecture + +**Python Book App Structure:** +``` +samples/book-app-project/ +├── book_app.py # Main CLI handler (menu, user I/O) +├── books.py # BookCollection class + persistence (data.json) +├── utils.py # UI helpers (print_menu, get_user_choice, format output) +├── data.json # Persistent storage (book list) +└── tests/ + └── test_books.py # Unit tests for BookCollection +``` + +**Key Components:** +- `BookCollection` — in-memory list with JSON persistence +- `Book` — dataclass representing a single book +- CLI handlers — user input/output management + +## Test Approach + +**Framework:** pytest (Python 3.10+) + +**Test Coverage:** +- `samples/book-app-project/tests/test_books.py` — unit tests for `BookCollection` +- Uses monkeypatch fixture to isolate tests with temp data files +- Covers: add, list, find, mark-as-read, invalid operations + +**Command to run tests:** +```bash +cd samples/book-app-project +pytest tests/ -v +``` + +## Dependencies + +**Python (see `pyproject.toml`):** +- `Python >= 3.10` +- `pytest` (dev/test) + +**C# & JavaScript:** Refer to respective package managers and project files. + +## Low-Risk Module Selection (Exercise 1) + +**Recommended: `samples/book-app-project/utils.py`** + +| Module | Risk | Reasoning | +|--------|------|-----------| +| **utils.py** | **Low** | Isolated UI helpers; no state; easy to test; changes don't cascade | +| books.py | Medium | Core data model; tested but has broader impact | +| appendices/additional-context.md | Low | Documentation only; safe but not code-oriented | + +**Why utils.py:** Functions like `print_menu()`, `get_user_choice()`, `get_book_details()`, and `print_books()` are perfect for practicing small, safe improvements (validation, error handling, formatting) without risking the core app logic. + +## References +- See `build-test.md` for exact build and test commands +- See `architecture.mmd` for system diagram diff --git a/ai-track-docs/architecture.mmd b/ai-track-docs/architecture.mmd new file mode 100644 index 00000000..a3e2ae3d --- /dev/null +++ b/ai-track-docs/architecture.mmd @@ -0,0 +1,14 @@ +graph TD + A["Placeholder: System Component A"] --> B["Placeholder: Component B"] + B --> C["Placeholder: Component C"] + C --> D["Database"] + A --> E["External Service"] + + style A fill:#e1f5ff + style B fill:#e1f5ff + style C fill:#e1f5ff + style D fill:#fff3e0 + style E fill:#f3e5f5 + + classDef placeholder fill:#e0e0e0,color:#424242 + class A,B,C,D,E placeholder diff --git a/ai-track-docs/build-test.md b/ai-track-docs/build-test.md new file mode 100644 index 00000000..80aaf7e3 --- /dev/null +++ b/ai-track-docs/build-test.md @@ -0,0 +1,92 @@ +# Build and Test + +## Prerequisites + +| Tool | Requirement | Status | +|------|-------------|--------| +| **Python** | 3.10 or higher | Required | +| **pip** | Package manager | Included with Python 3.10+ | +| **pytest** | Test framework | Listed in `pyproject.toml` | + +Verify: `python --version` should show `Python 3.10.x` or higher. + +## Build Commands + +### Python (Primary Sample) + +Navigate to the project: +```bash +cd samples/book-app-project +``` + +Install the project with dev dependencies: +```bash +pip install -e . +``` + +This installs the `book-app` package in editable mode and includes `pytest`. + +## Test Commands + +### Run All Tests +```bash +cd samples/book-app-project +pytest tests/ -v +``` + +### Run Tests with Coverage Report +```bash +cd samples/book-app-project +pytest tests/ --cov=. --cov-report=term-missing +``` + +### Run a Specific Test File +```bash +cd samples/book-app-project +pytest tests/test_utils.py -v +``` + +### Run a Single Test +```bash +cd samples/book-app-project +pytest tests/test_utils.py::test_get_book_details_valid_year -v +``` + +## Local Development + +1. **Clone and navigate:** + ```bash + git clone https://github.com//copilot-cli-for-beginners-mnf.git + cd copilot-cli-for-beginners-mnf/samples/book-app-project + ``` + +2. **Install in editable mode:** + ```bash + pip install -e . + ``` + +3. **Run the app:** + ```bash + python book_app.py + ``` + +4. **Run tests after changes:** + ```bash + pytest tests/ -v + ``` + +## Verification (Exercise 2 Baseline) + +After making changes, verify locally: +```bash +cd samples/book-app-project +pytest tests/ -v +``` + +Expected output: All tests pass (green checkmarks). + +## Baseline Tests Added + +- `test_utils.py` — Unit tests for utility functions + - `test_get_book_details_valid_year()` — Verifies year parsing works correctly + - `test_get_book_details_invalid_year()` — Verifies invalid years default to 0 diff --git a/ai-track-docs/dependencies.md b/ai-track-docs/dependencies.md new file mode 100644 index 00000000..4aa0bf08 --- /dev/null +++ b/ai-track-docs/dependencies.md @@ -0,0 +1,31 @@ +# Dependency Notes + +## Scope + +These notes cover the primary sample in `samples/book-app-project/`. + +## Critical Dependencies + +- Python: `>=3.10` + - Reason: the sample course content assumes a modern Python version and the project metadata already requires 3.10 or newer. +- pytest: `>=8,<9` + - Reason: pytest is the project's test dependency, and constraining it to the current major version reduces surprise breakage from future major releases. + +## Current Policy + +- Prefer bounded version ranges for tool dependencies used in course exercises. +- Keep changes small: allow patch and minor updates within a known-good major version before considering an upgrade. +- Re-run the sample test suite after any dependency change. + +## Current Gaps + +- `pytest` is listed in core dependencies even though it is only needed for validation workflows. +- There is no lock file for the Python sample, so installs can still vary slightly across environments within the allowed version range. + +## Verification + +Run from the repo root: + +```bash +python -m pytest samples/book-app-project/tests -q +``` \ No newline at end of file diff --git a/ai-track-docs/extending-utils.md b/ai-track-docs/extending-utils.md new file mode 100644 index 00000000..1ef0fb3a --- /dev/null +++ b/ai-track-docs/extending-utils.md @@ -0,0 +1,117 @@ +# Extending the Utils Module + +## Overview + +The `utils.py` module contains user interface helpers for the book collection app. It handles: +- Menu display and user input collection +- Input validation and parsing +- Formatted output display + +## Adding New Input Functions + +### Pattern: Input Collection + Validation + +```python +def get_new_field(): + """Collect and validate a new field from user input.""" + while True: # Loop until valid input + value = input("Enter field: ").strip() + if validate_field(value): # Add your validation logic + return value + print("Invalid input. Please try again.") +``` + +### Example: Adding Genre Input + +```python +def get_book_genre(): + """Collect book genre from user input.""" + genres = ["Fiction", "Non-Fiction", "Biography", "Science Fiction"] + while True: + print("Available genres:") + for i, genre in enumerate(genres, 1): + print(f"{i}. {genre}") + + choice = input("Choose genre (1-4): ").strip() + try: + index = int(choice) - 1 + if 0 <= index < len(genres): + return genres[index] + except ValueError: + pass + print("Invalid choice. Please select 1-4.") +``` + +## Adding New Display Functions + +### Pattern: Conditional Formatting + +```python +def print_custom_list(items, formatter_func): + """Display items using a custom formatter function.""" + if not items: + print("No items to display.") + return + + print("\nItems:") + for index, item in enumerate(items, start=1): + formatted = formatter_func(item) + print(f"{index}. {formatted}") +``` + +### Example: Adding Book Summary Display + +```python +def print_book_summaries(books): + """Display books with brief summaries.""" + if not books: + print("No books in your collection.") + return + + print("\nBook Summaries:") + for index, book in enumerate(books, start=1): + status = "✅ Read" if book.read else "📖 Unread" + summary = f"{book.title[:30]}..." if len(book.title) > 30 else book.title + print(f"{index}. {summary} by {book.author} - {status}") +``` + +## Testing New Functions + +### Pattern: Mock User Input + +```python +def test_new_function(monkeypatch): + """Test new input function with mocked user input.""" + inputs = iter(["test input", "another value"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = your_new_function() + assert result == expected_value +``` + +### Example: Testing Genre Input + +```python +def test_get_book_genre(monkeypatch): + """Test get_book_genre with mocked input.""" + inputs = iter(["1"]) # User selects first genre + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + result = utils.get_book_genre() + assert result == "Fiction" +``` + +## Best Practices + +1. **Input Validation**: Always validate user input and provide clear error messages +2. **Consistent Formatting**: Use consistent emojis and formatting in display functions +3. **Modular Functions**: Keep functions focused on single responsibilities +4. **Error Handling**: Use try/except blocks for parsing operations +5. **Testing**: Mock `input()` calls in tests to avoid interactive prompts + +## Integration Points + +- Functions in `utils.py` are called by `book_app.py` for user interaction +- Display functions receive Book objects from the `books.py` module +- Input functions return validated data to the main application logic +c:\crawl-walk-run\copilot-cli-for-beginners-mnf\ai-track-docs\extending-utils.md \ No newline at end of file diff --git a/ai-track-docs/perf-baseline.md b/ai-track-docs/perf-baseline.md new file mode 100644 index 00000000..aaaf594b --- /dev/null +++ b/ai-track-docs/perf-baseline.md @@ -0,0 +1,40 @@ +# Performance Baseline + +## Function + +- Module: `samples/book-app-project/books.py` +- Target: `BookCollection.list_books()` +- Measurement approach: standalone micro-benchmark in `samples/book-app-project/benchmark_list_books.py` + +## Command + +```bash +cd samples/book-app-project +python benchmark_list_books.py +``` + +## Baseline Results + +Run 1 + +- Iterations: 10000 +- Book count: 1000 +- Mean: 0.122 us +- Median: 0.100 us +- Min: 0.000 us +- Max: 2.100 us + +Run 2 + +- Iterations: 10000 +- Book count: 1000 +- Mean: 0.098 us +- Median: 0.100 us +- Min: 0.000 us +- Max: 3.700 us + +## Variance Notes + +- The median stayed at 0.100 us across both runs, which matches the simple in-memory return path. +- The mean moved from 0.122 us to 0.098 us and the max moved from 2.100 us to 3.700 us, which is consistent with small scheduler or interpreter noise. +- Re-run the benchmark from a quiet shell if you want to compare future changes against a cleaner baseline. \ No newline at end of file diff --git a/ai-track-docs/security.md b/ai-track-docs/security.md new file mode 100644 index 00000000..753c674b --- /dev/null +++ b/ai-track-docs/security.md @@ -0,0 +1,34 @@ +# Security Notes + +## Scope + +This note covers secret hygiene for the course repository, with emphasis on preventing accidental commits of local credentials and certificate material. + +## What Was Checked + +- Root `.gitignore` coverage for common environment files +- Root `.gitignore` coverage for private keys and certificate bundles +- Repository search for obvious secret-like strings and hard-coded credentials + +## Hygiene Improvements Made + +- Added ignore rules for common secret and certificate artifacts: + - `*.pem` + - `*.key` + - `*.crt` + - `*.cer` + - `*.pfx` + - `*.p12` + - `secrets.*` + +## Findings + +- `.env` and `.env.*.local` patterns were already ignored. +- The repository contains intentionally insecure examples under `samples/buggy-code/` for teaching purposes. Those files were not changed because the course instructions explicitly keep those bugs in place. +- The broader repository should avoid committing local key, certificate, or bundled secret files, which is why the ignore rules were expanded. + +## Recommended Practice + +- Keep real credentials out of the repo and load them from local environment configuration. +- Treat generated certificates, private keys, and exported credential bundles as untracked local artifacts. +- Review staged files before each commit with `git diff --staged`. \ No newline at end of file diff --git a/samples/book-app-project/benchmark_list_books.py b/samples/book-app-project/benchmark_list_books.py new file mode 100644 index 00000000..0e3cb8a3 --- /dev/null +++ b/samples/book-app-project/benchmark_list_books.py @@ -0,0 +1,38 @@ +import statistics +import time + +from books import Book, BookCollection + + +def run_benchmark(iterations: int = 10000, book_count: int = 1000) -> dict[str, float]: + collection = BookCollection() + collection.books = [ + Book(title=f"Book {index}", author="Benchmark Author", year=2000 + (index % 20)) + for index in range(book_count) + ] + + samples = [] + for _ in range(iterations): + start = time.perf_counter() + collection.list_books() + samples.append((time.perf_counter() - start) * 1_000_000) + + return { + "iterations": iterations, + "book_count": book_count, + "mean_us": statistics.mean(samples), + "median_us": statistics.median(samples), + "min_us": min(samples), + "max_us": max(samples), + } + + +if __name__ == "__main__": + results = run_benchmark() + print("Benchmark: list_books") + print(f"Iterations: {results['iterations']}") + print(f"Book count: {results['book_count']}") + print(f"Mean: {results['mean_us']:.3f} us") + print(f"Median: {results['median_us']:.3f} us") + print(f"Min: {results['min_us']:.3f} us") + print(f"Max: {results['max_us']:.3f} us") \ No newline at end of file diff --git a/samples/book-app-project/book_app.py b/samples/book-app-project/book_app.py index f0100c2d..30f8a38a 100644 --- a/samples/book-app-project/book_app.py +++ b/samples/book-app-project/book_app.py @@ -33,8 +33,17 @@ def handle_add(): author = input("Author: ").strip() year_str = input("Year: ").strip() + # Input validation + if not title: + print("\nError: Title cannot be empty.\n") + return + if not author: + print("\nError: Author cannot be empty.\n") + return try: year = int(year_str) if year_str else 0 + if year < 0 or year > 9999: + raise ValueError("Year must be between 0 and 9999.") collection.add_book(title, author, year) print("\nBook added successfully.\n") except ValueError as e: diff --git a/samples/book-app-project/books.py b/samples/book-app-project/books.py index 2110689f..2272cc3f 100644 --- a/samples/book-app-project/books.py +++ b/samples/book-app-project/books.py @@ -36,6 +36,14 @@ def save_books(self): json.dump([asdict(b) for b in self.books], f, indent=2) def add_book(self, title: str, author: str, year: int) -> Book: + # Validate inputs + if not title.strip(): + raise ValueError("Title cannot be empty.") + if not author.strip(): + raise ValueError("Author cannot be empty.") + if not (0 <= year <= 9999): + raise ValueError("Year must be between 0 and 9999.") + book = Book(title=title, author=author, year=year) self.books.append(book) self.save_books() diff --git a/samples/book-app-project/pyproject.toml b/samples/book-app-project/pyproject.toml index 6869c1c3..16a8a8c8 100644 --- a/samples/book-app-project/pyproject.toml +++ b/samples/book-app-project/pyproject.toml @@ -2,4 +2,4 @@ name = "book-app" version = "0.1.0" requires-python = ">=3.10" -dependencies = ["pytest"] +dependencies = ["pytest>=8,<9"] diff --git a/samples/book-app-project/tests/test_books.py b/samples/book-app-project/tests/test_books.py index 061149c5..79444feb 100644 --- a/samples/book-app-project/tests/test_books.py +++ b/samples/book-app-project/tests/test_books.py @@ -26,6 +26,23 @@ def test_add_book(): assert book.year == 1949 assert book.read is False +def test_add_book_invalid_title(): + collection = BookCollection() + with pytest.raises(ValueError, match="Title cannot be empty."): + collection.add_book("", "Author", 2023) + +def test_add_book_invalid_author(): + collection = BookCollection() + with pytest.raises(ValueError, match="Author cannot be empty."): + collection.add_book("Title", "", 2023) + +def test_add_book_invalid_year(): + collection = BookCollection() + with pytest.raises(ValueError, match="Year must be between 0 and 9999."): + collection.add_book("Title", "Author", -1) + with pytest.raises(ValueError, match="Year must be between 0 and 9999."): + collection.add_book("Title", "Author", 10000) + def test_mark_book_as_read(): collection = BookCollection() collection.add_book("Dune", "Frank Herbert", 1965) @@ -51,3 +68,31 @@ def test_remove_book_invalid(): collection = BookCollection() result = collection.remove_book("Nonexistent Book") assert result is False + +def test_handle_add_invalid_input(monkeypatch, capsys): + from book_app import handle_add + + # Test empty title + monkeypatch.setattr('builtins.input', lambda _: "") + handle_add() + captured = capsys.readouterr() + assert "Error: Title cannot be empty." in captured.out + + # Test empty author + monkeypatch.setattr('builtins.input', lambda prompt: "Title" if "Title" in prompt else "") + handle_add() + captured = capsys.readouterr() + assert "Error: Author cannot be empty." in captured.out + + # Test invalid year + monkeypatch.setattr('builtins.input', lambda prompt: "Title" if "Title" in prompt else ("Author" if "Author" in prompt else "-1")) + handle_add() + captured = capsys.readouterr() + assert "Error: Year must be between 0 and 9999." in captured.out + +def test_list_books(): + collection = BookCollection() + collection.add_book("Test Book", "Author", 2023) + books = collection.list_books() + assert len(books) == 1 + assert books[0].title == "Test Book" diff --git a/samples/book-app-project/tests/test_utils.py b/samples/book-app-project/tests/test_utils.py new file mode 100644 index 00000000..becdcee0 --- /dev/null +++ b/samples/book-app-project/tests/test_utils.py @@ -0,0 +1,73 @@ +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +import utils + + +def test_parse_year_valid(): + """Test parse_year with valid year.""" + result = utils.parse_year("1925") + assert result == 1925 + assert isinstance(result, int) + + +def test_parse_year_invalid(): + """Test parse_year with invalid year defaults to 0.""" + result = utils.parse_year("not_a_year") + assert result == 0 + assert isinstance(result, int) + + +def test_parse_year_empty(): + """Test parse_year with empty string defaults to 0.""" + result = utils.parse_year("") + assert result == 0 + + +def test_parse_year_out_of_range(): + """Test parse_year with year out of range defaults to 0.""" + result = utils.parse_year("10000") + assert result == 0 + + result = utils.parse_year("-1") + assert result == 0 + + +def test_get_book_details_valid_year(monkeypatch): + """Test get_book_details with valid year input.""" + inputs = iter(["The Great Gatsby", "F. Scott Fitzgerald", "1925"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + title, author, year = utils.get_book_details() + + assert title == "The Great Gatsby" + assert author == "F. Scott Fitzgerald" + assert year == 1925 + assert isinstance(year, int) + + +def test_get_book_details_invalid_year(monkeypatch): + """Test get_book_details with invalid year input defaults to 0.""" + inputs = iter(["1984", "George Orwell", "not_a_year"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + title, author, year = utils.get_book_details() + + assert title == "1984" + assert author == "George Orwell" + assert year == 0 # Defaults to 0 for invalid input + assert isinstance(year, int) + + +def test_get_book_details_strips_whitespace(monkeypatch): + """Test get_book_details strips leading/trailing whitespace.""" + inputs = iter([" Dune ", " Frank Herbert ", " 1965 "]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + + title, author, year = utils.get_book_details() + + assert title == "Dune" + assert author == "Frank Herbert" + assert year == 1965 diff --git a/samples/book-app-project/utils.py b/samples/book-app-project/utils.py index 4151dcda..9a4d8593 100644 --- a/samples/book-app-project/utils.py +++ b/samples/book-app-project/utils.py @@ -1,4 +1,5 @@ def print_menu(): + """Display the main menu options for the book collection app.""" print("\n📚 Book Collection App") print("1. Add a book") print("2. List books") @@ -8,24 +9,61 @@ def print_menu(): def get_user_choice() -> str: + """Prompt user for menu choice and return stripped input.""" return input("Choose an option (1-5): ").strip() -def get_book_details(): - title = input("Enter book title: ").strip() - author = input("Enter author: ").strip() +def parse_year(year_input: str) -> int: + """Parse year input, defaulting to 0 if invalid or out of range. - year_input = input("Enter publication year: ").strip() + Args: + year_input: String representation of a year + + Returns: + Parsed year as integer, or 0 if parsing fails or out of range + + Note: + Prints error message to console when parsing fails or year is invalid + """ try: year = int(year_input) + if 0 <= year <= 9999: + return year + else: + print("Year out of range. Defaulting to 0.") + return 0 except ValueError: print("Invalid year. Defaulting to 0.") - year = 0 + return 0 + + +def get_book_details(): + """Collect book details from user input. + + Prompts user for title, author, and publication year. + Year input is parsed with error handling. + + Returns: + Tuple of (title, author, year) where year is int + """ + title = input("Enter book title: ").strip() + author = input("Enter author: ").strip() + + year_input = input("Enter publication year: ").strip() + year = parse_year(year_input) return title, author, year def print_books(books): + """Display a formatted list of books with read status. + + Args: + books: List of Book objects to display + + Note: + Shows "No books in your collection." if list is empty + """ if not books: print("No books in your collection.") return