diff --git a/skills/python-type-safety/SKILL.md b/skills/python-type-safety/SKILL.md new file mode 100644 index 0000000..cce2377 --- /dev/null +++ b/skills/python-type-safety/SKILL.md @@ -0,0 +1,200 @@ +--- +name: python-type-safety +description: Python type safety with type hints, generics, protocols, and strict type checking. Use when adding type annotations, implementing generic classes, defining structural interfaces, or configuring mypy/pyright. +--- + +# Python Type Safety + +Leverage Python's type system to catch errors at static analysis time. Type annotations serve as enforced documentation that tooling validates automatically. + +## When to Use This Skill + +- Adding type hints to existing code +- Creating generic, reusable classes +- Defining structural interfaces with protocols +- Configuring mypy or pyright for strict checking +- Understanding type narrowing and guards +- Building type-safe APIs and libraries + +## Core Concepts + +### 1. Type Annotations + +Declare expected types for function parameters, return values, and variables. + +### 2. Generics + +Write reusable code that preserves type information across different types. + +### 3. Protocols + +Define structural interfaces without inheritance (duck typing with type safety). + +### 4. Type Narrowing + +Use guards and conditionals to narrow types within code blocks. + +## Quick Start + +```python +def get_user(user_id: str) -> User | None: + """Return type makes 'might not exist' explicit.""" + ... + +# Type checker enforces handling None case +user = get_user("123") +if user is None: + raise UserNotFoundError("123") +print(user.name) # Type checker knows user is User here +``` + +## Fundamental Patterns + +### Pattern 1: Annotate All Public Signatures + +Every public function, method, and class should have type annotations. + +```python +def get_user(user_id: str) -> User: + """Retrieve user by ID.""" + ... + +def process_batch( + items: list[Item], + max_workers: int = 4, +) -> BatchResult[ProcessedItem]: + """Process items concurrently.""" + ... + +class UserRepository: + def __init__(self, db: Database) -> None: + self._db = db + + async def find_by_id(self, user_id: str) -> User | None: + """Return User if found, None otherwise.""" + ... + + async def find_by_email(self, email: str) -> User | None: + ... + + async def save(self, user: User) -> User: + """Save and return user with generated ID.""" + ... +``` + +Use `mypy --strict` or `pyright` in CI to catch type errors early. For existing projects, enable strict mode incrementally using per-module overrides. + +### Pattern 2: Use Modern Union Syntax + +Python 3.10+ provides cleaner union syntax. + +```python +# Preferred (3.10+) +def find_user(user_id: str) -> User | None: + ... + +def parse_value(v: str) -> int | float | str: + ... + +# Older style (still valid, needed for 3.9) +from typing import Optional, Union + +def find_user(user_id: str) -> Optional[User]: + ... +``` + +### Pattern 3: Type Narrowing with Guards + +Use conditionals to narrow types for the type checker. + +```python +def process_user(user_id: str) -> UserData: + user = find_user(user_id) + + if user is None: + raise UserNotFoundError(f"User {user_id} not found") + + # Type checker knows user is User here, not User | None + return UserData( + name=user.name, + email=user.email, + ) + +def process_items(items: list[Item | None]) -> list[ProcessedItem]: + # Filter and narrow types + valid_items = [item for item in items if item is not None] + # valid_items is now list[Item] + return [process(item) for item in valid_items] +``` + +### Pattern 4: Generic Classes + +Create type-safe reusable containers. + +```python +from typing import TypeVar, Generic + +T = TypeVar("T") +E = TypeVar("E", bound=Exception) + +class Result(Generic[T, E]): + """Represents either a success value or an error.""" + + def __init__( + self, + value: T | None = None, + error: E | None = None, + ) -> None: + if (value is None) == (error is None): + raise ValueError("Exactly one of value or error must be set") + self._value = value + self._error = error + + @property + def is_success(self) -> bool: + return self._error is None + + @property + def is_failure(self) -> bool: + return self._error is not None + + def unwrap(self) -> T: + """Get value or raise the error.""" + if self._error is not None: + raise self._error + return self._value # type: ignore[return-value] + + def unwrap_or(self, default: T) -> T: + """Get value or return default.""" + if self._error is not None: + return default + return self._value # type: ignore[return-value] + +# Usage preserves types +def parse_config(path: str) -> Result[Config, ConfigError]: + try: + return Result(value=Config.from_file(path)) + except ConfigError as e: + return Result(error=e) + +result = parse_config("config.yaml") +if result.is_success: + config = result.unwrap() # Type: Config +``` + +## Detailed worked examples and patterns + +Detailed sections (starting with `## Advanced Patterns`) live in `references/details.md`. Read that file when the navigation summary above is insufficient. + +## Best Practices Summary + +1. **Annotate all public APIs** - Functions, methods, class attributes +2. **Use `T | None`** - Modern union syntax over `Optional[T]` +3. **Run strict type checking** - `mypy --strict` in CI +4. **Use generics** - Preserve type info in reusable code +5. **Define protocols** - Structural typing for interfaces +6. **Narrow types** - Use guards to help the type checker +7. **Bound type vars** - Restrict generics to meaningful types +8. **Create type aliases** - Meaningful names for complex types +9. **Minimize `Any`** - Use specific types or generics. `Any` is acceptable for truly dynamic data or when interfacing with untyped third-party code +10. **Document with types** - Types are enforceable documentation diff --git a/skills/python-type-safety/references/details.md b/skills/python-type-safety/references/details.md new file mode 100644 index 0000000..9bcaab6 --- /dev/null +++ b/skills/python-type-safety/references/details.md @@ -0,0 +1,237 @@ +# python-type-safety — detailed worked examples + +## Advanced Patterns + +### Pattern 5: Generic Repository + +Create type-safe data access patterns. + +```python +from typing import TypeVar, Generic +from abc import ABC, abstractmethod + +T = TypeVar("T") +ID = TypeVar("ID") + +class Repository(ABC, Generic[T, ID]): + """Generic repository interface.""" + + @abstractmethod + async def get(self, id: ID) -> T | None: + """Get entity by ID.""" + ... + + @abstractmethod + async def save(self, entity: T) -> T: + """Save and return entity.""" + ... + + @abstractmethod + async def delete(self, id: ID) -> bool: + """Delete entity, return True if existed.""" + ... + +class UserRepository(Repository[User, str]): + """Concrete repository for Users with string IDs.""" + + async def get(self, id: str) -> User | None: + row = await self._db.fetchrow( + "SELECT * FROM users WHERE id = $1", id + ) + return User(**row) if row else None + + async def save(self, entity: User) -> User: + ... + + async def delete(self, id: str) -> bool: + ... +``` + +### Pattern 6: TypeVar with Bounds + +Restrict generic parameters to specific types. + +```python +from typing import TypeVar +from pydantic import BaseModel + +ModelT = TypeVar("ModelT", bound=BaseModel) + +def validate_and_create(model_cls: type[ModelT], data: dict) -> ModelT: + """Create a validated Pydantic model from dict.""" + return model_cls.model_validate(data) + +# Works with any BaseModel subclass +class User(BaseModel): + name: str + email: str + +user = validate_and_create(User, {"name": "Alice", "email": "a@b.com"}) +# user is typed as User + +# Type error: str is not a BaseModel subclass +result = validate_and_create(str, {"name": "Alice"}) # Error! +``` + +### Pattern 7: Protocols for Structural Typing + +Define interfaces without requiring inheritance. + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class Serializable(Protocol): + """Any class that can be serialized to/from dict.""" + + def to_dict(self) -> dict: + ... + + @classmethod + def from_dict(cls, data: dict) -> "Serializable": + ... + +# User satisfies Serializable without inheriting from it +class User: + def __init__(self, id: str, name: str) -> None: + self.id = id + self.name = name + + def to_dict(self) -> dict: + return {"id": self.id, "name": self.name} + + @classmethod + def from_dict(cls, data: dict) -> "User": + return cls(id=data["id"], name=data["name"]) + +def serialize(obj: Serializable) -> str: + """Works with any Serializable object.""" + return json.dumps(obj.to_dict()) + +# Works - User matches the protocol +serialize(User("1", "Alice")) + +# Runtime checking with @runtime_checkable +isinstance(User("1", "Alice"), Serializable) # True +``` + +### Pattern 8: Common Protocol Patterns + +Define reusable structural interfaces. + +```python +from typing import Protocol + +class Closeable(Protocol): + """Resource that can be closed.""" + def close(self) -> None: ... + +class AsyncCloseable(Protocol): + """Async resource that can be closed.""" + async def close(self) -> None: ... + +class Readable(Protocol): + """Object that can be read from.""" + def read(self, n: int = -1) -> bytes: ... + +class HasId(Protocol): + """Object with an ID property.""" + @property + def id(self) -> str: ... + +class Comparable(Protocol): + """Object that supports comparison.""" + def __lt__(self, other: "Comparable") -> bool: ... + def __le__(self, other: "Comparable") -> bool: ... +``` + +### Pattern 9: Type Aliases + +Create meaningful type names. + +**Note:** The `type Alias = ...` statement syntax (PEP 695) was introduced in **Python 3.12**, not 3.10. For projects targeting earlier versions (including 3.10/3.11), use the `TypeAlias` annotation (PEP 613, available since Python 3.10). + +```python +# Python 3.12+ type statement (PEP 695) +type UserId = str +type UserDict = dict[str, Any] + +# Python 3.12+ type statement with generics (PEP 695) +type Handler[T] = Callable[[Request], T] +type AsyncHandler[T] = Callable[[Request], Awaitable[T]] +``` + +```python +# Python 3.10-3.11 style (needed for broader compatibility) +from typing import TypeAlias +from collections.abc import Callable, Awaitable + +UserId: TypeAlias = str +Handler: TypeAlias = Callable[[Request], Response] +``` + +```python +# Usage +def register_handler(path: str, handler: Handler[Response]) -> None: + ... +``` + +### Pattern 10: Callable Types + +Type function parameters and callbacks. + +```python +from collections.abc import Callable, Awaitable + +# Sync callback +ProgressCallback = Callable[[int, int], None] # (current, total) + +# Async callback +AsyncHandler = Callable[[Request], Awaitable[Response]] + +# With named parameters (using Protocol) +class OnProgress(Protocol): + def __call__( + self, + current: int, + total: int, + *, + message: str = "", + ) -> None: ... + +def process_items( + items: list[Item], + on_progress: ProgressCallback | None = None, +) -> list[Result]: + for i, item in enumerate(items): + if on_progress: + on_progress(i, len(items)) + ... +``` + +## Configuration + +### Strict Mode Checklist + +For `mypy --strict` compliance: + +```toml +# pyproject.toml +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +no_implicit_optional = true +``` + +Incremental adoption goals: +- All function parameters annotated +- All return types annotated +- Class attributes annotated +- Minimize `Any` usage (acceptable for truly dynamic data) +- Generic collections use type parameters (`list[str]` not `list`) + +For existing codebases, enable strict mode per-module using `# mypy: strict` or configure per-module overrides in `pyproject.toml`. diff --git a/tests/inventory.test.mjs b/tests/inventory.test.mjs index 38956b3..7377677 100644 --- a/tests/inventory.test.mjs +++ b/tests/inventory.test.mjs @@ -31,11 +31,12 @@ test('live skills are unique and include the three original packages plus BotKit assert.ok(names.includes('setup-bot')); assert.ok(names.includes('retro')); assert.ok(names.includes('simple-as-writing')); + assert.ok(names.includes('python-type-safety')); assert.ok(names.includes('tdd')); assert.ok(names.includes('matt-tdd')); assert.ok(names.includes('teach')); assert.ok(names.includes('matt-teach')); - assert.equal(names.length, 96); + assert.equal(names.length, 97); }); test('pinned original sources remain present', async () => {