
Dignified Python
- 84 installs
- Updated January 1, 1970
- dagster-io/erk
This is a copy of dignified-python by dagster-io - installs and ranking accrue to the original listing.
Guides a solo builder to write clean, idiomatic, well-typed Python following opinionated best-practice conventions.
About
dignified-python is a skill from Dagster's erk toolkit that enforces clean, idiomatic, well-typed Python conventions. A solo builder reaches for it when writing or refactoring Python and wants opinionated best-practice guidance for readable, maintainable code.
- Opinionated Python conventions
- Clean, typed idiomatic code
- Best-practice guidance
Dignified Python by the numbers
- 84 all-time installs (skills.sh)
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dagster-io/erk --skill dignified-pythonAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 84 |
|---|---|
| Last updated | January 1, 1970 |
| Repository | dagster-io/erk ↗ |
What it does
Guides a solo builder to write clean, idiomatic, well-typed Python following opinionated best-practice conventions.
Who is it for?
Python codebases
Skip if: Non-Python stacks
Files
Dignified Python Coding Standards Skill
Production-quality Python coding standards for writing clean, maintainable, modern Python code (versions 3.10-3.13).
When to Use This Skill
Auto-invoke when users ask about:
- "make this pythonic" / "is this good python"
- "type hints" / "type annotations" / "typing"
- "LBYL vs EAFP" / "exception handling"
- "pathlib vs os.path" / "path operations"
- "CLI patterns" / "click usage"
- "code review" / "improve this code"
- Any Python code quality or standards question
Note: This skill is general Python standards, not Dagster-specific. Use /dagster-best-practices for Dagster patterns.
When to Use This Skill vs. Others
| User Need | Use This Skill | Alternative Skill |
|---|---|---|
| "make this pythonic" | ✅ Yes - Python standards | |
| "is this good python" | ✅ Yes - code quality | |
| "type hints" | ✅ Yes - typing guidance | |
| "LBYL vs EAFP" | ✅ Yes - exception patterns | |
| "pathlib vs os.path" | ✅ Yes - path handling | |
| "best practices for dagster" | ❌ No | /dagster-best-practices |
| "implement X pipeline" | ❌ No | /dg for implementation |
| "which integration to use" | ❌ No | /dagster-expert |
| "CLI argument parsing" | ✅ Yes - CLI patterns |
Core Knowledge (ALWAYS Loaded)
@dignified-python-core.md
Version Detection
Identify the project's minimum Python version by checking (in order):
1. pyproject.toml - Look for requires-python field (e.g., requires-python = ">=3.12") 2. setup.py or setup.cfg - Look for python_requires 3. .python-version file - Contains version like 3.12 or 3.12.0 4. Default to Python 3.12 if no version specifier found
Once identified, load the appropriate version-specific file:
- Python 3.10: Load
versions/python-3.10.md - Python 3.11: Load
versions/python-3.11.md - Python 3.12: Load
versions/python-3.12.md - Python 3.13: Load
versions/python-3.13.md
Conditional Loading (Load Based on Task Patterns)
Core files above cover 80%+ of Python code patterns. Only load these additional files when you detect specific patterns:
Pattern detection examples:
- If task mentions "click" or "CLI" -> Load
references/cli-patterns.md - If task mentions "subprocess" -> Load subprocess patterns from core-standards
Reference Documentation Structure
The references/ directory contains detailed guidance organized by topic:
Core References
- `core-standards.md` - Essential standards (always loaded)
- `cli-patterns.md` - Command-line interface patterns (click, argparse)
Version-Specific References (references/versions/)
- `python-3.10.md` - Features available in Python 3.10+
- `python-3.11.md` - Features available in Python 3.11+
- `python-3.12.md` - Features available in Python 3.12+
- `python-3.13.md` - Features available in Python 3.13+
Advanced Topics (references/advanced/)
- `exception-handling.md` - LBYL patterns, error boundaries
- `interfaces.md` - ABC and Protocol patterns
- `typing-advanced.md` - Advanced typing patterns
- `api-design.md` - API design principles
When to Read Each Reference Document
references/advanced/exception-handling.md
Read when:
- Writing try/except blocks
- Wrapping third-party APIs that may raise
- Seeing or writing
from eorfrom None - Unsure if LBYL alternative exists
references/advanced/interfaces.md
Read when:
- Creating ABC or Protocol classes
- Writing @abstractmethod decorators
- Designing gateway layer interfaces
- Choosing between ABC and Protocol
references/advanced/typing-advanced.md
Read when:
- Using typing.cast()
- Creating Literal type aliases
- Narrowing types in conditional blocks
references/module-design.md
Read when:
- Creating new Python modules
- Adding module-level code (beyond simple constants)
- Using @cache decorator at module level
- Seeing Path() or computation at module level
- Considering inline imports
references/api-design.md
Read when:
- Adding default parameter values to functions
- Defining functions with 5 or more parameters
- Using ThreadPoolExecutor.submit()
- Reviewing function signatures
references/checklists.md
Read when:
- Final review before committing Python code
- Unsure if you've followed all rules
- Need a quick lookup of requirements
How to Use This Skill
1. Core knowledge is loaded automatically (LBYL, pathlib, basic imports, anti-patterns) 2. Version detection happens once - identify the minimum Python version and load the appropriate version file 3. Reference documents are loaded on-demand based on the triggers above 4. Additional patterns may require extra loading (CLI patterns, subprocess) 5. Each file is self-contained with complete guidance for its domain
CLI Patterns - Click Best Practices
Core Rules
1. Use `click.echo()` for output, NEVER `print()` 2. Exit with `raise SystemExit(1)` for CLI errors 3. Error boundaries at command level 4. Use `err=True` for error output 5. Flush stderr before `click.confirm()` (prevents buffering hangs)
Basic Click Patterns
import click
from pathlib import Path
# ✅ CORRECT: Use click.echo for output
@click.command()
@click.argument("name")
def greet(name: str) -> None:
"""Greet the user."""
click.echo(f"Hello, {name}!")
# ❌ WRONG: Using print()
@click.command()
def greet(name: str) -> None:
print(f"Hello, {name}!") # NEVER use print in CLIError Handling in CLI
# ✅ CORRECT: CLI command error boundary
@click.command("create")
@click.argument("name")
def create(name: str) -> None:
"""Create a resource."""
try:
create_resource(name)
except subprocess.CalledProcessError as e:
click.echo(f"Error: Command failed: {e.stderr}", err=True)
raise SystemExit(1)
except ValueError as e:
click.echo(f"Error: {e}", err=True)
raise SystemExit(1)Output Patterns
# Regular output to stdout
click.echo("Processing complete")
# Error output to stderr
click.echo("Error: Operation failed", err=True)
# Colored output
click.echo(click.style("Success!", fg="green"))
click.echo(click.style("Warning!", fg="yellow", bold=True))
# Progress indication
with click.progressbar(items) as bar:
for item in bar:
process(item)Command Structure
@click.group()
@click.pass_context
def cli(ctx: click.Context) -> None:
"""Main CLI entry point."""
ctx.ensure_object(dict)
ctx.obj["config"] = load_config()
@cli.command()
@click.option("--dry-run", is_flag=True, help="Perform dry run")
@click.argument("path", type=click.Path(exists=True))
@click.pass_obj
def sync(obj: dict, path: str, dry_run: bool) -> None:
"""Sync the repository."""
config = obj["config"]
if dry_run:
click.echo("DRY RUN: Would sync...")
else:
perform_sync(Path(path), config)
click.echo("✓ Sync complete")User Interaction
import sys
# ✅ CORRECT: Flush stderr before confirmation prompts
# This prevents buffering hangs when mixing stderr output with stdin prompts
click.echo("Warning: This operation is destructive!", err=True)
sys.stderr.flush() # Flush before prompting
if click.confirm("Are you sure?"):
perform_dangerous_operation()
# ❌ WRONG: click.confirm() after stderr output without flush
# This can hang because stderr isn't flushed before the prompt
click.echo("Warning: This operation is destructive!", err=True)
if click.confirm("Are you sure?"): # BAD: potential buffering hang
perform_dangerous_operation()
# User input
name = click.prompt("Enter your name", default="User")
# Password input
password = click.prompt("Password", hide_input=True)
# Choice selection
choice = click.prompt(
"Select option",
type=click.Choice(["option1", "option2"]),
default="option1"
)Path Handling
@click.command()
@click.argument(
"input_file",
type=click.Path(exists=True, file_okay=True, dir_okay=False)
)
@click.argument(
"output_dir",
type=click.Path(exists=False, file_okay=False, dir_okay=True)
)
def process(input_file: str, output_dir: str) -> None:
"""Process input file to output directory."""
input_path = Path(input_file)
output_path = Path(output_dir)
if not output_path.exists():
output_path.mkdir(parents=True)
click.echo(f"Processing {input_path} → {output_path}")Key Takeaways
1. Always click.echo(): Never use print() in CLI code 2. Error to stderr: Use err=True for error messages 3. Exit cleanly: Use raise SystemExit(1) for errors 4. User-friendly: Provide clear messages and confirmations 5. Type paths: Use click.Path() for path arguments
Dignified Python - Core Standards
This document contains the core Python coding standards that apply to 80%+ of Python code. These principles are loaded with every skill invocation.
For conditional loading of specialized patterns:
- CLI development -> Load
cli-patterns.md - Subprocess operations -> Load
subprocess.md
For detailed reference material, see the "When to Read Each Reference" section in SKILL.md.
---
The Cornerstone: LBYL Over EAFP
Look Before You Leap: Check conditions proactively, NEVER use exceptions for control flow.
This is the single most important rule in dignified Python. Every pattern below flows from this principle.
# CORRECT: Check first
if key in mapping:
value = mapping[key]
process(value)
# WRONG: Exception as control flow
try:
value = mapping[key]
process(value)
except KeyError:
pass---
Exception Handling Basics
Core Principle
ALWAYS use LBYL, NEVER EAFP for control flow
LBYL means checking conditions before acting. EAFP (Easier to Ask for Forgiveness than Permission) means trying operations and catching exceptions. In dignified Python, we strongly prefer LBYL.
Dictionary Access Patterns
# CORRECT: Membership testing
if key in mapping:
value = mapping[key]
process(value)
else:
handle_missing()
# ALSO CORRECT: .get() with default
value = mapping.get(key, default_value)
process(value)
# CORRECT: Check before nested access
if "config" in data and "timeout" in data["config"]:
timeout = data["config"]["timeout"]
# WRONG: KeyError as control flow
try:
value = mapping[key]
except KeyError:
handle_missing()When Exceptions ARE Acceptable
Exceptions are ONLY acceptable at:
1. Error boundaries (CLI/API level) 2. Third-party API compatibility (when no alternative exists) 3. Adding context before re-raising
Default: Let exceptions bubble up
For detailed exception handling patterns including B904 chaining, third-party API examples, and anti-patterns, see references/exception-handling.md.
---
Path Operations
The Golden Rule
ALWAYS check `.exists()` BEFORE `.resolve()` or `.is_relative_to()`
Why This Matters
.resolve()raisesOSErrorfor non-existent paths.is_relative_to()raisesValueErrorfor invalid comparisons- Checking
.exists()first avoids exceptions entirely (LBYL!)
Correct Patterns
from pathlib import Path
# CORRECT: Check exists first
for wt_path in worktree_paths:
if wt_path.exists():
wt_path_resolved = wt_path.resolve()
if current_dir.is_relative_to(wt_path_resolved):
current_worktree = wt_path_resolved
break
# WRONG: Using exceptions for path validation
try:
wt_path_resolved = wt_path.resolve()
if current_dir.is_relative_to(wt_path_resolved):
current_worktree = wt_path_resolved
except (OSError, ValueError):
continuePathlib Best Practices
Always Use Pathlib (Never os.path)
# CORRECT: Use pathlib.Path
from pathlib import Path
config_file = Path.home() / ".config" / "app.yml"
if config_file.exists():
content = config_file.read_text(encoding="utf-8")
# WRONG: Use os.path
import os.path
config_file = os.path.join(os.path.expanduser("~"), ".config", "app.yml")Always Specify Encoding
# CORRECT: Always specify encoding
content = path.read_text(encoding="utf-8")
path.write_text(data, encoding="utf-8")
# WRONG: Default encoding
content = path.read_text() # Platform-dependent!---
Import Organization
Core Rules
1. Default: ALWAYS place imports at module level 2. Use absolute imports only (no relative imports) 3. Inline imports only for specific exceptions (circular deps, TYPE_CHECKING, conditional features)
# CORRECT: Module-level imports
import json
import click
from pathlib import Path
from erk.config import load_config
def my_function() -> None:
data = json.loads(content)
# CORRECT: Absolute import
from erk.config import load_config
# WRONG: Relative import
from .config import load_config
# WRONG: Inline imports without justification
def my_function() -> None:
import json # NEVER do thisFor detailed inline import patterns and when they're legitimate, see references/module-design.md.
---
Performance Guidelines
Properties Must Be O(1)
# WRONG: Property doing I/O
@property
def size(self) -> int:
return self._fetch_from_db()
# CORRECT: Explicit method name
def fetch_size_from_db(self) -> int:
return self._fetch_from_db()
# CORRECT: O(1) property
@property
def size(self) -> int:
return self._cached_sizeMagic Methods Must Be O(1)
# WRONG: __len__ doing iteration
def __len__(self) -> int:
return sum(1 for _ in self._items)
# CORRECT: O(1) __len__
def __len__(self) -> int:
return self._count---
Anti-Patterns
No Backwards Compatibility Preservation (Default)
# WRONG: Keeping old API unnecessarily
def process_data(data: dict, legacy_format: bool = False) -> Result:
if legacy_format:
return legacy_process(data)
return new_process(data)
# CORRECT: Break and migrate immediately
def process_data(data: dict) -> Result:
return new_process(data)No Re-Exports: One Canonical Import Path
Core Principle: Every symbol has exactly one import path. Never re-export.
# WRONG: __all__ exports create duplicate import paths
# myapp/__init__.py
from myapp.core import Process
__all__ = ["Process"]
# CORRECT: Empty __init__.py, import from canonical location
# from myapp.core import ProcessWhen re-exports ARE required (plugin entry points): Use explicit import X as X syntax:
# CORRECT: Explicit re-export syntax for required entry points
from myapp.core.feature import my_function as my_functionDeclare Variables Close to Use
# WRONG: Variable declared far from use
def process_data(ctx, items):
result_path = compute_result_path(ctx) # Declared here...
# 20+ lines of other logic...
save_to_path(transformed, result_path) # ...used here
# CORRECT: Inline at use site
def process_data(ctx, items):
validate_items(items)
transformed = transform_items(items)
save_to_path(transformed, compute_result_path(ctx))Keep Context Managers Inline in with Statements
# CORRECT: Context manager stays in with statement
with (cm_a if condition else nullcontext()):
do_work()
# CORRECT: Multiple conditional context managers
with (lock if thread_safe else nullcontext()):
process(data)
# WRONG: Extracting to intermediate variable obscures lifecycle
cm = cm_a if condition else nullcontext()
with cm:
do_work()Context managers belong in with statements where the __enter__/__exit__ lifecycle is explicit. Do not extract them to intermediate variables. If the inline expression is genuinely overwhelming, extract the logic into a helper function that returns the context manager.
Don't Destructure Objects Into Single-Use Locals
# WRONG: Unnecessary field extraction
result = fetch_user(user_id)
name = result.name # only used once below
email = result.email # only used once below
send_notification(name, email, role)
# CORRECT: Access fields directly
user = fetch_user(user_id)
send_notification(user.name, user.email, user.role)Indentation Depth Limit
Maximum indentation: 4 levels
# WRONG: Too deeply nested (5 levels)
def process_items(items):
for item in items:
if item.valid:
for child in item.children:
if child.enabled:
for grandchild in child.descendants:
pass # 5 levels deep!
# CORRECT: Extract helper functions
def process_items(items):
for item in items:
if item.valid:
process_children(item.children)
def process_children(children):
for child in children:
if child.enabled:
process_descendants(child.descendants)---
Backwards Compatibility Philosophy
Default stance: NO backwards compatibility preservation
Only preserve backwards compatibility when:
- Code is clearly part of public API
- User explicitly requests it
- Migration cost is prohibitively high (rare)
Benefits:
- Cleaner, maintainable codebase
- Faster iteration
- No legacy code accumulation
- Simpler mental models
---
See Also
For detailed guidance on specialized topics:
- Exception chaining (B904):
references/exception-handling.md - ABC vs Protocol:
references/interfaces.md - typing.cast() assertions:
references/typing-advanced.md - Import-time side effects, @cache:
references/module-design.md - Default parameters, keyword-only args:
references/api-design.md - All decision checklists:
references/checklists.md
API Design Reference
Read when: Adding default parameters, functions with 5+ params, using ThreadPoolExecutor
---
Default Parameter Values Are Dangerous
Scope: This rule applies to function definitions (def foo(bar: bool = False)), NOT tofunction calls where you pass an argument named default (e.g.,click.confirm(default=True)). Passingdefault=Trueto a function that accepts adefault
parameter is perfectly valid—you're not creating a default parameter value, you're explicitly
providing a value.
Avoid default parameter values unless absolutely necessary. They are a significant source of bugs.
Why defaults are dangerous:
1. Silent incorrect behavior - Callers forget to pass a parameter and get unexpected results 2. Hidden coupling - The default encodes an assumption that may not hold for all callers 3. Audit difficulty - Hard to verify all call sites are using the right value 4. Refactoring hazard - Adding a new parameter with a default doesn't trigger errors at existing call sites
# DANGEROUS: Default that might be wrong for some callers
def process_file(path: Path, encoding: str = "utf-8") -> str:
return path.read_text(encoding=encoding)
# Caller forgets encoding, silently gets wrong behavior for legacy file
content = process_file(legacy_latin1_file) # Bug: should be encoding="latin-1"
# SAFER: Require explicit choice
def process_file(path: Path, encoding: str) -> str:
return path.read_text(encoding=encoding)
# Caller must think about encoding
content = process_file(legacy_latin1_file, encoding="latin-1")When you discover a default is never overridden, eliminate it:
# If every call site uses the default...
activate_worktree(ctx, repo, path, script, "up", preserve_relative_path=True) # Always True
activate_worktree(ctx, repo, path, script, "down", preserve_relative_path=True) # Always True
# CORRECT: Remove the parameter entirely
def activate_worktree(ctx, repo, path, script, command_name) -> None:
# Always preserve relative path - it's just the behavior
...Acceptable uses of defaults:
1. Truly optional behavior - Where the default is correct for 95%+ of callers 2. Backwards compatibility - When adding a parameter to existing API (temporary) 3. Test helper functions - Functions in tests/test_utils/ that exist to reduce test boilerplate are explicitly exempt. These helpers often wrap complex constructors (like format_plan_header_body) with sensible defaults, and having many default parameters is their intended purpose—not a code smell
When reviewing code with defaults, ask:
- Do all call sites actually want this default?
- Would a caller forgetting this parameter cause a bug?
- Is there a safer design that makes the choice explicit?
---
Keyword-Only Arguments for Complex Functions
Functions with 5 or more parameters MUST use keyword-only arguments.
Use the * separator after the first positional parameter to enforce keyword-only at the language level. This improves call-site readability by forcing explicit parameter names.
# CORRECT: Keyword-only after first param
def fetch_data(
url,
*,
timeout: float,
retries: int,
headers: dict[str, str],
auth_token: str,
) -> Response:
...
# Call site is self-documenting
response = fetch_data(
api_url,
timeout=30.0,
retries=3,
headers={"Accept": "application/json"},
auth_token=token,
)
# WRONG: All positional parameters
def fetch_data(
url,
timeout: float,
retries: int,
headers: dict[str, str],
auth_token: str,
) -> Response:
...
# Call site is unreadable - what do these values mean?
response = fetch_data(api_url, 30.0, 3, {"Accept": "application/json"}, token)Exceptions:
1. `self` - Always positional (Python requirement) 2. `ctx` / context objects - Can remain positional as the first parameter (convention) 3. ABC/Protocol methods - Exempt to avoid forcing all implementations to change signatures 4. Click callbacks - Click injects parameters; follow Click conventions
# CORRECT: ctx stays positional, rest are keyword-only
def create_worktree(
ctx: ErkContext,
*,
branch_name: str,
base_branch: str,
path: Path,
checkout: bool,
) -> WorktreeInfo:
...---
ThreadPoolExecutor.submit() Pattern
ThreadPoolExecutor.submit() passes arguments positionally to the callable. For functions with keyword-only parameters, wrap the call in a lambda:
# WRONG: submit() passes args positionally - fails with keyword-only functions
future = executor.submit(fetch_data, url, timeout, retries, headers, token)
# CORRECT: Lambda enables keyword arguments
future = executor.submit(
lambda: fetch_data(
url,
timeout=timeout,
retries=retries,
headers=headers,
auth_token=token,
)
)---
Speculative Test Infrastructure
Don't add parameters to fakes "just in case" they might be useful for testing.
Fakes should mirror production interfaces. Adding test-only configuration knobs that never get used creates dead code and false complexity.
# WRONG: Test-only parameter that's never used in production
class FakeGitHub:
def __init__(
self,
prs: dict[str, PullRequestInfo] | None = None,
rate_limited: bool = False, # "Might test this later"
) -> None:
self._rate_limited = rate_limited # Never set to True anywhere
# CORRECT: Only add infrastructure when you need it
class FakeGitHub:
def __init__(
self,
prs: dict[str, PullRequestInfo] | None = None,
) -> None:
...The test for this: If grep shows a parameter is only ever passed in test files, and those tests are testing hypothetical scenarios rather than actual production behavior, delete both the parameter and the tests.
---
Speculative Tests
# FORBIDDEN: Tests for future features
# def test_feature_we_might_add():
# pass
# CORRECT: TDD for current implementation
def test_feature_being_built_now():
result = new_feature()
assert result == expected---
Decision Checklist
Before adding a default parameter value:
- [ ] Do 95%+ of callers actually want this default?
- [ ] Would forgetting to pass this parameter cause a subtle bug?
- [ ] Is there a safer design that makes the choice explicit?
- [ ] If the default is never overridden anywhere, should this parameter exist at all?
Default: Require explicit values; eliminate unused defaults
Before adding a function with 5+ parameters:
- [ ] Have I added
*after the first (or ctx) parameter? - [ ] Is only
self/ctxpositional? - [ ] Is this an ABC/Protocol method? (exempt from rule)
- [ ] If using ThreadPoolExecutor.submit(), am I using a lambda wrapper?
Default: All parameters after the first should be keyword-only
Exception Handling Reference
Read when: Writing try/except blocks, wrapping third-party APIs, seeing from e or from None
---
When Exceptions ARE Acceptable
Exceptions are ONLY acceptable at:
1. Error boundaries (CLI/API level) 2. Third-party API compatibility (when no alternative exists) 3. Adding context before re-raising
1. Error Boundaries
# ACCEPTABLE: CLI command error boundary
@click.command("create")
@click.pass_obj
def create(ctx: ErkContext, name: str) -> None:
"""Create a worktree."""
try:
create_worktree(ctx, name)
except subprocess.CalledProcessError as e:
click.echo(f"Error: Git command failed: {e.stderr}", err=True)
raise SystemExit(1)2. Third-Party API Compatibility
# ACCEPTABLE: Third-party API forces exception handling
def _get_bigquery_sample(sql_client, table_name):
"""
BigQuery's TABLESAMPLE doesn't work on views.
There's no reliable way to determine a priori whether
a table supports TABLESAMPLE.
"""
try:
return sql_client.run_query(f"SELECT * FROM {table_name} TABLESAMPLE...")
except Exception:
return sql_client.run_query(f"SELECT * FROM {table_name} ORDER BY RAND()...")The test for "no alternative exists": Can you validate or check the condition BEFORE calling
the API? If yes (even using a different function/method), use LBYL. The exception only applies
when the API provides NO way to determine success a priori—you literally must attempt the
operation to know if it will work.
What Does NOT Qualify as Third-Party API Compatibility
Standard library functions with known LBYL alternatives do NOT qualify:
# WRONG: int() has LBYL alternative (str.isdigit)
try:
port = int(user_input)
except ValueError:
port = 80
# CORRECT: Check before calling
if user_input.lstrip('-+').isdigit():
port = int(user_input)
else:
port = 80
# WRONG: datetime.fromisoformat() can be validated first
try:
dt = datetime.fromisoformat(timestamp_str)
except ValueError:
dt = None
# CORRECT: Validate format before parsing
def _is_iso_format(s: str) -> bool:
return len(s) >= 10 and s[4] == "-" and s[7] == "-"
if _is_iso_format(timestamp_str):
dt = datetime.fromisoformat(timestamp_str)
else:
dt = None3. Adding Context Before Re-raising
# ACCEPTABLE: Adding context before re-raising
try:
process_file(config_file)
except yaml.YAMLError as e:
raise ValueError(f"Failed to parse config file {config_file}: {e}") from e---
Exception Chaining (B904 Lint Compliance)
Ruff rule B904 requires explicit exception chaining when raising inside an except block. This prevents losing the original traceback.
# CORRECT: Chain to preserve context
try:
parse_config(path)
except ValueError as e:
click.echo(json.dumps({"success": False, "error": str(e)}))
raise SystemExit(1) from e # Preserves traceback
# CORRECT: Explicitly break chain when intentional
try:
fetch_from_cache(key)
except KeyError:
# Original exception is not relevant to caller
raise ValueError(f"Unknown key: {key}") from None
# WRONG: Missing exception chain (B904 violation)
try:
parse_config(path)
except ValueError:
raise SystemExit(1) # Lint error: missing 'from e' or 'from None'
# CORRECT: CLI error boundary with JSON output
try:
result = some_operation()
except RuntimeError as e:
click.echo(json.dumps({"success": False, "error": str(e)}))
raise SystemExit(0) from None # Exception is in JSON, traceback irrelevant to CLI userWhen to use each:
from e- Preserve original exception for debuggingfrom None- Intentionally suppress original (e.g., transforming exception type, CLI JSON output)
---
Exception Anti-Patterns
Never swallow exceptions silently
Even at error boundaries, you must at least log/warn so issues can be diagnosed:
# WRONG: Silent exception swallowing
try:
risky_operation()
except:
pass
# WRONG: Silent swallowing even at error boundary
try:
optional_feature()
except Exception:
pass # Silent - impossible to diagnose issues
# CORRECT: Let exceptions bubble up (default)
risky_operation()
# CORRECT: At error boundaries, log the exception
try:
optional_feature()
except Exception as e:
logging.warning("Optional feature failed: %s", e) # DiagnosableNever use silent fallback behavior
# WRONG: Silent fallback masks failure
def process_text(text: str) -> dict:
try:
return llm_client.process(text)
except Exception:
return regex_parse_fallback(text)
# CORRECT: Let error bubble to boundary
def process_text(text: str) -> dict:
return llm_client.process(text)Interface Design Reference
Read when: Creating ABC/Protocol classes, writing @abstractmethod, designing gateway interfaces
---
ABC vs Protocol: Choosing the Right Interface
ABCs (nominal typing) and Protocols (structural typing) serve different purposes. Choose based on ownership and coupling needs.
| Use Case | Recommended | Why |
|---|---|---|
| Internal interfaces you control | ABC | Explicit enforcement, runtime validation, code reuse |
| Third-party library boundaries | Protocol | No inheritance required, loose coupling |
| Plugin systems with isinstance checks | ABC | Reliable runtime type validation |
| Minimal interface contracts (1-2 methods) | Protocol | Less boilerplate, focused contracts |
Default for erk internal code: ABC. Default for external library facades: Protocol.
---
ABC Interface Pattern
# CORRECT: Use ABC for interfaces
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def save(self, entity: Entity) -> None:
"""Save entity to storage."""
...
@abstractmethod
def load(self, id: str) -> Entity:
"""Load entity by ID."""
...
class PostgresRepository(Repository):
def save(self, entity: Entity) -> None:
# Implementation
pass
def load(self, id: str) -> Entity:
# Implementation
pass---
Benefits of ABC (Internal Interfaces)
1. Explicit inheritance - Clear class hierarchy, explicit opt-in 2. Runtime validation - Errors at instantiation if abstract methods missing 3. Code reuse - Can include concrete methods and shared logic 4. Reliable isinstance() - Full signature checking at runtime
---
Benefits of Protocol (External Boundaries)
1. No inheritance required - Works with code you don't control 2. Loose coupling - Implementations don't know about the protocol 3. Minimal contracts - Define only the methods you need 4. Duck typing - Aligns with Python's philosophy
---
Complete DI Example
from abc import ABC, abstractmethod
from dataclasses import dataclass
# Define the interface
class DataStore(ABC):
@abstractmethod
def get(self, key: str) -> str | None:
"""Retrieve value by key."""
...
@abstractmethod
def set(self, key: str, value: str) -> None:
"""Store value with key."""
...
# Real implementation
class RedisStore(DataStore):
def get(self, key: str) -> str | None:
return self.client.get(key)
def set(self, key: str, value: str) -> None:
self.client.set(key, value)
# Fake for testing
class FakeStore(DataStore):
def __init__(self) -> None:
self._data: dict[str, str] = {}
def get(self, key: str) -> str | None:
if key not in self._data:
return None
return self._data[key]
def set(self, key: str, value: str) -> None:
self._data[key] = value
# Business logic accepts interface
@dataclass
class Service:
store: DataStore # Depends on abstraction
def process(self, item: str) -> None:
cached = self.store.get(item)
if cached is None:
result = expensive_computation(item)
self.store.set(item, result)
else:
result = cached
use_result(result)---
When to Use Protocol
Protocols excel at defining interfaces for code you don't control:
# CORRECT: Protocol for third-party library facade
from typing import Protocol
class HttpClient(Protocol):
"""Interface for HTTP operations - decouples from requests/httpx/aiohttp."""
def get(self, url: str) -> Response: ...
def post(self, url: str, data: dict) -> Response: ...
# Any HTTP library that has these methods works - no inheritance needed
def fetch_data(client: HttpClient, endpoint: str) -> dict:
response = client.get(endpoint)
return response.json()Protocols are also appropriate for minimal, focused interfaces:
# CORRECT: Protocol for structural typing with minimal interface
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
def cleanup_resources(resources: list[Closeable]) -> None:
for r in resources:
r.close()---
Protocol Limitations
1. No runtime validation - @runtime_checkable only checks method existence, not signatures 2. No code reuse - Protocols shouldn't have method implementations 3. Weaker isinstance() checks - ABCs provide more reliable runtime type checking
---
Decision Checklist
Before defining an interface (ABC or Protocol):
- [ ] Do I own all implementations? -> Prefer ABC
- [ ] Am I wrapping a third-party library? -> Prefer Protocol
- [ ] Do I need runtime isinstance() validation? -> Use ABC
- [ ] Is this a minimal interface (1-2 methods)? -> Protocol may be simpler
- [ ] Do I need shared method implementations? -> Use ABC
Default for erk internal code: ABC. Default for external library facades: Protocol.
Advanced Typing Reference
Read when: Using typing.cast(), creating Literal type aliases, narrowing types
---
Using typing.cast()
Core Rule
ALWAYS verify `cast()` with a runtime assertion, unless there's a documented reason not to.
typing.cast() is a compile-time only construct—it tells the type checker to trust you but performs no runtime verification. If your assumption is wrong, you'll get silent misbehavior instead of a clear error.
Required Pattern
from collections.abc import MutableMapping
from typing import Any, cast
# CORRECT: Runtime assertion before cast
assert isinstance(doc, MutableMapping), f"Expected MutableMapping, got {type(doc)}"
cast(dict[str, Any], doc)["key"] = value
# CORRECT: Alternative with hasattr for duck typing
assert hasattr(obj, '__setitem__'), f"Expected subscriptable, got {type(obj)}"
cast(dict[str, Any], obj)["key"] = valueAnti-Pattern
# WRONG: Cast without runtime verification
cast(dict[str, Any], doc)["key"] = value # If doc isn't a dict-like, silent failureWhen to Skip Runtime Verification
Default: Always add the assertion when cost is trivial (O(1) checks like `in`, `isinstance`).
Skip the assertion only in these narrow cases:
1. Immediately after a type guard: The check was just performed and would be redundant
if isinstance(value, str):
# No assertion needed - we just checked
result = cast(str, value).upper()2. Performance-critical hot path: Add a comment explaining the measured overhead
# Skip assertion: called 10M times/sec, isinstance adds 15% overhead
# Type invariant maintained by _validate_input() at entry point
cast(int, cached_value)What is NOT a valid reason to skip:
- "Click validates the choice set" - Add assertion anyway; cost is trivial
- "The library guarantees the type" - Add assertion anyway; defense in depth
- "It's obvious from context" - Add assertion anyway; future readers benefit
Why This Matters
- Silent bugs are worse than loud bugs: An assertion failure gives you a stack trace and clear
error message
- Documentation: The assertion documents your assumption for future readers
- Defense in depth: Third-party libraries can change behavior between versions
---
Programmatically Significant Strings
Use `Literal` types for strings that have programmatic meaning.
When strings represent a fixed set of valid values (error codes, status values, command types), model them in the type system using Literal.
Why This Matters
1. Type safety - Typos caught at type-check time, not runtime 2. IDE support - Autocomplete shows valid options 3. Documentation - Valid values are explicit in the code 4. Refactoring - Rename operations work correctly
Naming Convention
Use kebab-case for all internal Literal string values:
# CORRECT: kebab-case for internal values
IssueCode = Literal["orphan-state", "orphan-dir", "missing-branch"]
ErrorType = Literal["not-found", "invalid-format", "timeout-exceeded"]Exception: When modeling external systems, match the external API's convention:
# CORRECT: Match GitHub API's UPPER_CASE
PRState = Literal["OPEN", "MERGED", "CLOSED"]
# CORRECT: Match GitHub Actions API's lowercase
WorkflowStatus = Literal["completed", "in_progress", "queued"]The rule is: kebab-case by default, external convention when modeling external APIs.
Pattern
from dataclasses import dataclass
from typing import Literal
# CORRECT: Define a type alias for the valid values
IssueCode = Literal["orphan-state", "orphan-dir", "missing-branch"]
@dataclass(frozen=True)
class Issue:
code: IssueCode
message: str
def check_state() -> list[Issue]:
issues: list[Issue] = []
if problem_detected:
issues.append(Issue(code="orphan-state", message="description")) # Type-checked!
return issues
# WRONG: Bare strings without type constraint
def check_state() -> list[tuple[str, str]]:
issues: list[tuple[str, str]] = []
issues.append(("orphen-state", "desc")) # Typo goes unnoticed!
return issuesWhen to Use Literal
- Error/issue codes
- Status values (pending, complete, failed)
- Command types or action names
- Configuration keys with fixed valid values
- Any string that is compared programmatically
Decision Checklist
Before using a bare str type, ask:
- Is this string compared with
==orinanywhere? - Is there a fixed set of valid values?
- Would a typo in this string cause a bug?
If any answer is "yes", use Literal instead.
Decision Checklists Reference
Read when: Final review before committing Python code, need quick lookup of requirements
---
Before Writing try/except
- [ ] Is this at an error boundary? (CLI/API level)
- [ ] Can I check the condition proactively? (LBYL)
- [ ] Am I adding meaningful context, or just hiding?
- [ ] Is third-party API forcing me to use exceptions? (No LBYL check exists—not even format
validation)
- [ ] Have I encapsulated the violation?
- [ ] Am I catching specific exceptions, not broad?
- [ ] If catching at error boundary, am I logging/warning? (Never silently swallow)
Default: Let exceptions bubble up
---
Before Path Operations
- [ ] Did I check
.exists()before.resolve()? - [ ] Did I check
.exists()before.is_relative_to()? - [ ] Am I using
pathlib.Path, notos.path? - [ ] Did I specify
encoding="utf-8"?
---
Before Using typing.cast()
- [ ] Have I added a runtime assertion to verify the cast?
- [ ] Is the assertion cost trivial (O(1))? If yes, always add it.
- [ ] If skipping, is it because I just performed an isinstance check (redundant)?
- [ ] If skipping for performance, have I documented the measured overhead?
Default: Always add runtime assertion before cast when cost is trivial
---
Before Defining an Interface (ABC or Protocol)
- [ ] Do I own all implementations? -> Prefer ABC
- [ ] Am I wrapping a third-party library? -> Prefer Protocol
- [ ] Do I need runtime isinstance() validation? -> Use ABC
- [ ] Is this a minimal interface (1-2 methods)? -> Protocol may be simpler
- [ ] Do I need shared method implementations? -> Use ABC
Default for erk internal code: ABC. Default for external library facades: Protocol.
---
Before Preserving Backwards Compatibility
- [ ] Did the user explicitly request it?
- [ ] Is this a public API with external consumers?
- [ ] Have I documented why it's needed?
- [ ] Is migration cost prohibitively high?
Default: Break the API and migrate callsites immediately
---
Before Inline Imports
- [ ] Is this to break a circular dependency?
- [ ] Is this for TYPE_CHECKING?
- [ ] Is this for conditional features?
- [ ] If for startup time: Have I MEASURED the import cost?
- [ ] If for startup time: Is the cost significant (>100ms)?
- [ ] If for startup time: Have I documented the measured cost in a comment?
- [ ] Have I documented why the inline import is needed?
Default: Module-level imports
---
Before Importing/Re-Exporting Symbols
- [ ] Is there already a canonical location for this symbol?
- [ ] Am I creating a second import path for the same symbol?
- [ ] If this is a shim module, am I importing only what's needed for this module's purpose?
- [ ] Have I avoided
__all__exports?
Default: Import from canonical location, never re-export
---
Before Declaring a Local Variable
- [ ] Is this variable used more than once?
- [ ] Is this variable used close to where it's declared?
- [ ] Would inlining the computation hurt readability?
- [ ] Am I extracting object fields into locals that are only used once?
Default: Inline single-use computations at the call site; access object attributes directly
---
Before Adding a Default Parameter Value
- [ ] Do 95%+ of callers actually want this default?
- [ ] Would forgetting to pass this parameter cause a subtle bug?
- [ ] Is there a safer design that makes the choice explicit?
- [ ] If the default is never overridden anywhere, should this parameter exist at all?
Default: Require explicit values; eliminate unused defaults
---
Before Adding a Function with 5+ Parameters
- [ ] Have I added
*after the first (or ctx) parameter? - [ ] Is only
self/ctxpositional? - [ ] Is this an ABC/Protocol method? (exempt from rule)
- [ ] If using ThreadPoolExecutor.submit(), am I using a lambda wrapper?
Default: All parameters after the first should be keyword-only
---
Before Writing Module-Level Code
- [ ] Does this involve any computation (even
Path()construction)? - [ ] Does this involve I/O (file, network, environment)?
- [ ] Could this fail or raise exceptions?
- [ ] Would tests need to mock this value?
If any answer is "yes", wrap in a @cache-decorated function instead.
Module Design Reference
Read when: Creating new modules, adding module-level code, using @cache decorator
---
Import-Time Side Effects
Core Rule
Avoid computation and side effects at import time. Defer to function calls.
Module-level code runs when the module is imported. Side effects at import time cause:
1. Slower startup - Every import triggers computation 2. Test brittleness - Hard to mock/control behavior 3. Circular import issues - Dependencies evaluated too early 4. Unpredictable order - Import order affects behavior
---
Common Anti-Patterns
# WRONG: Path computed at import time
SESSION_ID_FILE = Path(".erk/scratch/current-session-id")
def get_session_id() -> str | None:
if SESSION_ID_FILE.exists():
return SESSION_ID_FILE.read_text(encoding="utf-8")
return None
# WRONG: Config loaded at import time
CONFIG = load_config() # I/O at import!
# WRONG: Connection established at import time
DB_CLIENT = DatabaseClient(os.environ["DB_URL"]) # Side effect at import!---
Correct Patterns
Use `@cache` for deferred computation:
from functools import cache
# CORRECT: Defer computation until first call
@cache
def _session_id_file_path() -> Path:
"""Return path to session ID file (cached after first call)."""
return Path(".erk/scratch/current-session-id")
def get_session_id() -> str | None:
session_file = _session_id_file_path()
if session_file.exists():
return session_file.read_text(encoding="utf-8")
return NoneUse functions for resources:
# CORRECT: Defer resource creation to function call
@cache
def get_config() -> Config:
"""Load config on first call, cache result."""
return load_config()
@cache
def get_db_client() -> DatabaseClient:
"""Create database client on first call."""
return DatabaseClient(os.environ["DB_URL"])---
When Module-Level Constants ARE Acceptable
Simple, static values that don't involve computation or I/O:
# ACCEPTABLE: Static constants
DEFAULT_TIMEOUT = 30
MAX_RETRIES = 3
SUPPORTED_FORMATS = frozenset({"json", "yaml", "toml"})---
Inline Import Patterns
Core Rules
1. Default: ALWAYS place imports at module level 2. Use absolute imports only (no relative imports) 3. Inline imports only for specific exceptions (see below)
Legitimate Inline Import Patterns
1. Circular Import Prevention
# commands/sync.py
def register_commands(cli_group):
"""Register commands with CLI group (avoids circular import)."""
from myapp.cli import sync_command # Breaks circular dependency
cli_group.add_command(sync_command)When to use:
- CLI command registration
- Plugin systems with bidirectional dependencies
- Lazy loading to break import cycles
2. Conditional Feature Imports
def process_data(data: dict, dry_run: bool = False) -> None:
if dry_run:
# Inline import: Only needed for dry-run mode
from myapp.dry_run import NoopProcessor
processor = NoopProcessor()
else:
processor = RealProcessor()
processor.execute(data)When to use:
- Debug/verbose mode utilities
- Dry-run mode wrappers
- Optional feature modules
- Platform-specific implementations
3. TYPE_CHECKING Imports
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from myapp.models import User # Only for type hints
def process_user(user: "User") -> None:
...When to use:
- Avoiding circular dependencies in type hints
- Forward declarations
4. Startup Time Optimization (Rare)
Some packages have genuinely heavy import costs (pyspark, jupyter ecosystem, large ML frameworks). Deferring these imports can improve CLI startup time.
However, apply "innocent until proven guilty":
- Default to module-level imports
- Only defer imports when you have MEASURED evidence of startup impact
- Document the measured cost in a comment
# ACCEPTABLE: Measured heavy import (adds 800ms to startup)
def run_spark_job(config: SparkConfig) -> None:
from pyspark.sql import SparkSession # Heavy: 800ms import time
session = SparkSession.builder.getOrCreate()
...
# WRONG: Speculative deferral without measurement
def check_staleness(project_dir: Path) -> None:
# Inline imports to avoid import-time side effects <- WRONG: no evidence
from myapp.staleness import get_version
...When NOT to defer:
- Standard library modules
- Lightweight internal modules
- Modules you haven't measured
- "Just in case" optimization
---
Decision Checklist
Before writing module-level code:
- [ ] Does this involve any computation (even
Path()construction)? - [ ] Does this involve I/O (file, network, environment)?
- [ ] Could this fail or raise exceptions?
- [ ] Would tests need to mock this value?
If any answer is "yes", wrap in a @cache-decorated function instead.
Before inline imports:
- [ ] Is this to break a circular dependency?
- [ ] Is this for TYPE_CHECKING?
- [ ] Is this for conditional features?
- [ ] If for startup time: Have I MEASURED the import cost?
- [ ] If for startup time: Is the cost significant (>100ms)?
- [ ] If for startup time: Have I documented the measured cost in a comment?
- [ ] Have I documented why the inline import is needed?
Default: Module-level imports
Dignified Python Reference Documentation
Production-quality Python coding standards for writing clean, maintainable, modern Python code.
Table of Contents
Core Standards
- [core-standards.md](./core-standards.md) - Essential Python standards (always apply)
- Modern type syntax (list[str], str | None)
- LBYL exception handling patterns
- Pathlib operations
- Absolute imports
- Error boundaries
- [cli-patterns.md](./cli-patterns.md) - Command-line interface patterns
- Click usage patterns
- Argparse best practices
- CLI error handling
- Configuration management
Version-Specific Features (versions/)
- [python-3.10.md](./versions/python-3.10.md) - Python 3.10+ features
- Structural pattern matching (match/case)
- Parenthesized context managers
- Better error messages
- Type union operator (X | Y)
- [python-3.11.md](./versions/python-3.11.md) - Python 3.11+ features
- Exception groups (ExceptionGroup)
- except\* syntax
- Self type
- Variadic generics
- [python-3.12.md](./versions/python-3.12.md) - Python 3.12+ features
- Type parameter syntax (Generic[T])
- Override decorator
- Per-interpreter GIL
- f-string improvements
- [python-3.13.md](./versions/python-3.13.md) - Python 3.13+ features
- Experimental free-threading
- JIT compilation
- Improved error messages
- Performance improvements
Advanced Topics (advanced/)
- [exception-handling.md](./advanced/exception-handling.md) - Exception patterns
- LBYL (Look Before You Leap) patterns
- Error boundaries
- Exception chaining
- Custom exceptions
- [interfaces.md](./advanced/interfaces.md) - Interface design
- ABC (Abstract Base Class) patterns
- Protocol types
- Gateway layer interfaces
- Type narrowing
- [typing-advanced.md](./advanced/typing-advanced.md) - Advanced typing
- Generic types
- Type narrowing
- Literal types
- TypedDict and dataclasses
- [api-design.md](./advanced/api-design.md) - API design principles
- Function signatures
- Parameter complexity
- Code organization
- Production patterns from Dagster Labs
Philosophy
Core Principles
Modern Type Syntax: Use Python 3.10+ type syntax everywhere
# Good (modern)
def process(items: list[str]) -> str | None:
pass
# Avoid (legacy)
from typing import List, Optional
def process(items: List[str]) -> Optional[str]:
passLBYL Over EAFP: Look Before You Leap, not Easier to Ask for Forgiveness than Permission
# Good (LBYL)
if path.exists():
content = path.read_text()
# Avoid (EAFP)
try:
content = path.read_text()
except FileNotFoundError:
passPathlib Over os.path: Use pathlib for all file operations
# Good
from pathlib import Path
config_path = Path("config.yaml")
if config_path.exists():
content = config_path.read_text()
# Avoid
import os
if os.path.exists("config.yaml"):
with open("config.yaml") as f:
content = f.read()Absolute Imports: Never use relative imports
# Good
from myproject.utils import helper
# Avoid
from .utils import helper
from ..shared import helperError Boundaries at CLI Level: Handle errors at the CLI entry point, not deep in the stack
Quick Reference
Type Annotations
# Basic types
def greet(name: str) -> str:
return f"Hello, {name}"
# Collections (modern syntax)
def process(items: list[str], mapping: dict[str, int]) -> tuple[str, int]:
pass
# Optional/Union (modern syntax)
def find(query: str) -> str | None:
pass
# Multiple types
def parse(value: str | int | float) -> float:
passLBYL Patterns
# File operations
if path.exists():
content = path.read_text()
# Dictionary access
if "key" in data:
value = data["key"]
# Attribute access
if hasattr(obj, "method"):
obj.method()
# Type checking
if isinstance(value, str):
result = value.upper()Pathlib Operations
from pathlib import Path
# Create path
config = Path("config.yaml")
data_dir = Path("/data")
# Check existence
if config.exists():
pass
# Read/write
content = config.read_text()
config.write_text("data")
# Directory operations
for file in data_dir.glob("*.txt"):
print(file.name)
# Path manipulation
full_path = data_dir / "subdir" / "file.txt"
parent = full_path.parent
name = full_path.nameCLI Patterns (Click)
import click
@click.command()
@click.option("--name", required=True, help="User name")
@click.option("--count", default=1, help="Number of times")
def greet(name: str, count: int) -> None:
"""Greet a user multiple times."""
for _ in range(count):
click.echo(f"Hello, {name}!")
if __name__ == "__main__":
greet()Version Detection
Automatic version detection determines which Python version features are available:
1. Check pyproject.toml for requires-python field 2. Check setup.py/setup.cfg for python_requires 3. Check .python-version file 4. Default to Python 3.12 if not specified
Based on detected version, appropriate version-specific features are recommended.
Navigation Tips
- Start with core-standards.md for essential patterns that apply to all code
- Check version-specific docs based on your project's Python version
- Reference advanced topics when dealing with specialized patterns
- Use cli-patterns.md when building command-line tools
When to Read Each Reference
| Situation | Reference |
|---|---|
| Writing any Python code | core-standards.md |
| Building a CLI tool | cli-patterns.md |
| Using Python 3.10 features | versions/python-3.10.md |
| Using Python 3.11 features | versions/python-3.11.md |
| Using Python 3.12 features | versions/python-3.12.md |
| Using Python 3.13 features | versions/python-3.13.md |
| Handling exceptions | advanced/exception-handling.md |
| Designing interfaces | advanced/interfaces.md |
| Complex type hints | advanced/typing-advanced.md |
| API design decisions | advanced/api-design.md |
Related Skills
- `/dagster-best-practices` - Dagster-specific patterns (not general Python)
- `/dg` - Dagster CLI operations
- `/dagster-expert` - Dagster expertise including integrations
Important: /dignified-python is for general Python standards, not Dagster-specific patterns. For Dagster patterns, use /dagster-best-practices.
Cross-Skill Usage
Users invoke /dignified-python when they need Python code quality guidance, regardless of whether it's for a Dagster project or any other Python project.
Workflow:
User: "Is this good Python code?"
→ /dignified-python (check core-standards.md)
→ Apply modern type syntax, LBYL, pathlib patterns
→ Check version-specific features based on project
User: "How should I structure my Dagster assets?"
→ /dagster-best-practices (NOT dignified-python)
→ Learn asset patterns, dependencies, partitionsProduction Patterns
These standards are based on production-tested patterns from Dagster Labs:
- ✅ Modern type syntax - Improves IDE support and type checking
- ✅ LBYL patterns - More explicit and easier to debug than EAFP
- ✅ Pathlib - More readable and cross-platform than os.path
- ✅ Absolute imports - Avoid import confusion and relative import issues
- ✅ Error boundaries at CLI - Clean error messages for end users
Documentation Structure
Each reference document follows a consistent structure:
1. Overview - High-level concepts 2. Patterns - Common code patterns with examples 3. Best Practices - Recommended approaches 4. Anti-Patterns - What to avoid 5. Real-World Examples - Production code samples 6. Related Topics - Cross-references
Self-Selecting Usage
Users only invoke /dignified-python when they want Python standards guidance. The skill description makes it clear it's for general Python quality, not Dagster-specific patterns, so users naturally select it when appropriate.
Users will invoke this when they want:
- Code review and quality improvements
- Modern Python patterns
- Type annotation guidance
- Exception handling best practices
- CLI implementation patterns
Users will NOT invoke this when they want:
- Dagster-specific patterns (they'll use
/dagster-best-practices) - Creating Dagster projects (they'll use
/dg) - Finding integrations (they'll use
/dagster-expert)
Subprocess Handling - Safe Execution
Core Rule
ALWAYS use `check=True` with `subprocess.run()`
Basic Subprocess Pattern
import subprocess
from pathlib import Path
# ✅ CORRECT: check=True to raise on error
result = subprocess.run(
["git", "status"],
check=True,
capture_output=True,
text=True
)
print(result.stdout)
# ❌ WRONG: No check - silently ignores errors
result = subprocess.run(["git", "status"])Complete Subprocess Example
def run_git_command(args: list[str], cwd: Path | None = None) -> str:
"""Run a git command and return output."""
try:
result = subprocess.run(
["git"] + args,
check=True, # Raise on non-zero exit
capture_output=True, # Capture stdout/stderr
text=True, # Return strings, not bytes
cwd=cwd # Working directory
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
# Error boundary - add context
raise RuntimeError(f"Git command failed: {e.stderr}") from eError Handling
try:
result = subprocess.run(
["make", "test"],
check=True,
capture_output=True,
text=True
)
except subprocess.CalledProcessError as e:
# Access error details
print(f"Command: {e.cmd}")
print(f"Exit code: {e.returncode}")
print(f"Stdout: {e.stdout}")
print(f"Stderr: {e.stderr}")
raiseCommon Patterns
# Silent execution (no output)
subprocess.run(["git", "fetch"], check=True, capture_output=True)
# Stream output in real-time
process = subprocess.Popen(
["pytest", "-v"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
for line in process.stdout:
print(line, end="")
process.wait()
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode, process.args)
# With timeout
try:
subprocess.run(["long-command"], check=True, timeout=30)
except subprocess.TimeoutExpired:
print("Command timed out")Key Takeaways
1. Always check=True: Ensure errors are not silently ignored 2. Capture output: Use capture_output=True for stdout/stderr 3. Text mode: Use text=True for string output 4. Error context: Wrap in try/except at boundaries 5. Timeout safety: Set timeout for long-running commands
Type Annotations - Python 3.10
This document provides complete, canonical type annotation guidance for Python 3.10. This is the baseline for modern Python type syntax.
Overview
Python 3.10 introduced major improvements to type annotation syntax through PEP 604 (union types via |) and PEP 585 (generic types in standard collections). These features eliminated the need for most typing module imports and made type annotations more concise and readable.
What's new in 3.10:
- Union types with
|operator (PEP 604) - Built-in generic types:
list[T],dict[K, V], etc. (PEP 585) - No more need for
List,Dict,Union,Optionalfrom typing
What you need from typing module:
TypeVarfor generic functions/classesProtocolfor structural typing (rare - prefer ABC)TYPE_CHECKINGfor conditional importsAny(use sparingly)
Complete Type Annotation Syntax for Python 3.10
Basic Collection Types
✅ PREFERRED - Use built-in generic types:
names: list[str] = []
mapping: dict[str, int] = {}
unique_ids: set[str] = set()
coordinates: tuple[int, int] = (0, 0)❌ WRONG - Don't use typing module equivalents:
from typing import List, Dict, Set, Tuple # Don't do this
names: List[str] = []
mapping: Dict[str, int] = {}Why: Built-in types are more concise, don't require imports, and are the modern Python standard.
Union Types
✅ PREFERRED - Use | operator:
def process(value: str | int) -> str:
return str(value)
def find_config(name: str) -> dict[str, str] | dict[str, int]:
...
# Multiple unions
def parse(input: str | int | float) -> str:
return str(input)❌ WRONG - Don't use typing.Union:
from typing import Union
def process(value: Union[str, int]) -> str: # Don't do this
...Optional Types
✅ PREFERRED - Use X | None:
def find_user(id: str) -> User | None:
"""Returns user or None if not found."""
if id in users:
return users[id]
return None
def get_config(key: str) -> str | None:
return config.get(key)❌ WRONG - Don't use typing.Optional:
from typing import Optional
def find_user(id: str) -> Optional[User]: # Don't do this
...Generic Functions with TypeVar
✅ PREFERRED - Use TypeVar for generic functions:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
"""Return first item or None if empty."""
if not items:
return None
return items[0]
def identity(value: T) -> T:
"""Return the value unchanged."""
return valueNote: This is the standard way in Python 3.10. Python 3.12 introduces better syntax (PEP 695).
Generic Classes
✅ PREFERRED - Use Generic with TypeVar:
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
"""A generic stack data structure."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
# Usage
int_stack = Stack[int]()
int_stack.push(42)Note: Python 3.12 introduces cleaner syntax for this pattern.
Constrained and Bounded TypeVars
✅ Use TypeVar constraints when needed:
from typing import TypeVar
# Constrained to specific types
Numeric = TypeVar("Numeric", int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
# Bounded to base class
T = TypeVar("T", bound=BaseClass)
def process(obj: T) -> T:
return objCallable Types
✅ PREFERRED - Use collections.abc.Callable:
from collections.abc import Callable
# Function that takes int, returns str
processor: Callable[[int], str] = str
# Function with no args, returns None
callback: Callable[[], None] = lambda: None
# Function with multiple args
validator: Callable[[str, int], bool] = lambda s, i: len(s) > iType Aliases
✅ Use simple assignment for type aliases:
# Simple alias
UserId = str
Config = dict[str, str | int | bool]
# Complex nested type
JsonValue = dict[str, "JsonValue"] | list["JsonValue"] | str | int | float | bool | None
def load_config() -> Config:
return {"host": "localhost", "port": 8080}Note: Python 3.12 introduces type statement for better alias support.
when from future import annotations is Needed
Use from __future__ import annotations when you encounter:
Forward references (class referencing itself):
from __future__ import annotations
class Node:
def __init__(self, value: int, parent: Node | None = None):
self.value = value
self.parent = parentCircular type imports:
# a.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from b import B
class A:
def method(self) -> B:
...Complex recursive types:
from __future__ import annotations
JsonValue = dict[str, JsonValue] | list[JsonValue] | str | int | float | bool | NoneInterfaces: ABC vs Protocol
✅ PREFERRED - Use ABC for interfaces:
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, id: str) -> User | None:
"""Get user by ID."""
@abstractmethod
def save(self, user: User) -> None:
"""Save user."""🟡 VALID - Use Protocol only for structural typing:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
# Any object with draw() method matches
def render(obj: Drawable) -> None:
obj.draw()Dignified Python prefers ABC because it makes inheritance and intent explicit.
Complete Examples
Repository Pattern
from abc import ABC, abstractmethod
class Repository(ABC):
"""Abstract base class for data repositories."""
@abstractmethod
def get(self, id: str) -> dict[str, str] | None:
"""Get entity by ID."""
@abstractmethod
def save(self, entity: dict[str, str]) -> None:
"""Save entity."""
@abstractmethod
def delete(self, id: str) -> bool:
"""Delete entity, return success."""
class UserRepository(Repository):
def __init__(self) -> None:
self._users: dict[str, dict[str, str]] = {}
def get(self, id: str) -> dict[str, str] | None:
return self._users.get(id)
def save(self, entity: dict[str, str]) -> None:
if "id" not in entity:
raise ValueError("Entity must have id")
self._users[entity["id"]] = entity
def delete(self, id: str) -> bool:
if id in self._users:
del self._users[id]
return True
return FalseGeneric Data Structures
from typing import Generic, TypeVar
T = TypeVar("T")
class Node(Generic[T]):
"""A node in a tree structure."""
def __init__(self, value: T, children: list[Node[T]] | None = None) -> None:
self.value = value
self.children = children or []
def add_child(self, child: Node[T]) -> None:
self.children.append(child)
def find(self, predicate: Callable[[T], bool]) -> Node[T] | None:
"""Find first node matching predicate."""
if predicate(self.value):
return self
for child in self.children:
result = child.find(predicate)
if result:
return result
return None
# Usage
from collections.abc import Callable
root = Node[int](1)
root.add_child(Node[int](2))
root.add_child(Node[int](3))Configuration Management
from dataclasses import dataclass
@dataclass(frozen=True)
class DatabaseConfig:
host: str
port: int
username: str
password: str | None = None
ssl_enabled: bool = False
@dataclass(frozen=True)
class AppConfig:
app_name: str
debug_mode: bool
database: DatabaseConfig
feature_flags: dict[str, bool]
def load_config(path: str) -> AppConfig:
"""Load application configuration from file."""
import json
from pathlib import Path
config_path = Path(path)
if not config_path.exists():
raise FileNotFoundError(f"Config not found: {path}")
data: dict[str, str | int | bool | dict[str, str | int | bool]] = json.loads(
config_path.read_text(encoding="utf-8")
)
# Parse and validate...
return AppConfig(...)API Client with Error Handling
from collections.abc import Callable
from typing import TypeVar
T = TypeVar("T")
class ApiResponse(Generic[T]):
"""Container for API response with data or error."""
def __init__(self, data: T | None = None, error: str | None = None) -> None:
self.data = data
self.error = error
def is_success(self) -> bool:
return self.error is None
def map(self, func: Callable[[T], U]) -> ApiResponse[U]:
"""Transform successful response data."""
if self.is_success() and self.data is not None:
return ApiResponse(data=func(self.data))
return ApiResponse(error=self.error)
U = TypeVar("U")
def fetch_user(id: str) -> ApiResponse[dict[str, str]]:
"""Fetch user from API."""
# Implementation...
return ApiResponse(data={"id": id, "name": "Alice"})Type Checking Rules
What to Type
✅ MUST type:
- All public function parameters (except
self,cls) - All public function return values
- All class attributes (public and private)
- Module-level constants
🟡 SHOULD type:
- Internal function signatures
- Complex local variables
🟢 MAY skip:
- Simple local variables where type is obvious (
count = 0) - Lambda parameters in short inline lambdas
- Loop variables in short comprehensions
Running Type Checker
uv run ty checkAll code should pass type checking without errors.
Type Checking Configuration
Configure ty in pyproject.toml:
[tool.ty.environment]
python-version = "3.10"Common Patterns
Checking for None
✅ CORRECT - Check before use:
def process_user(user: User | None) -> str:
if user is None:
return "No user"
return user.nameDict.get() with Type Safety
✅ CORRECT - Handle None case:
def get_port(config: dict[str, int]) -> int:
port = config.get("port")
if port is None:
return 8080
return portList Operations
✅ CORRECT - Check before accessing:
def first_or_default(items: list[str], default: str) -> str:
if not items:
return default
return items[0]Migration from Python 3.9
If upgrading from Python 3.9, apply these changes:
1. Replace typing module types:
List[X]→list[X]Dict[K, V]→dict[K, V]Set[X]→set[X]Tuple[X, Y]→tuple[X, Y]Union[X, Y]→X | YOptional[X]→X | None
2. Add future annotations if needed:
- Add
from __future__ import annotationsfor forward references - Add for circular imports with
TYPE_CHECKING
3. Remove unnecessary imports:
- Remove
from typing import List, Dict, Optional, Union - Keep only
TypeVar,Generic,Protocol,TYPE_CHECKING,Any
References
Type Annotations - Python 3.11
This document provides complete, canonical type annotation guidance for Python 3.11.
Overview
Python 3.11 builds on 3.10's type syntax with the addition of the Self type (PEP 673), making method chaining and builder patterns significantly cleaner. All modern syntax from 3.10 continues to work.
What's new in 3.11:
Selftype for self-returning methods (PEP 673)- Variadic generics with TypeVarTuple (PEP 646)
- Significantly improved error messages
Available from 3.10:
- Built-in generic types:
list[T],dict[K, V], etc. (PEP 585) - Union types with
|operator (PEP 604) - Optional with
X | None
What you need from typing module:
Selffor self-returning methods (NEW)TypeVarfor generic functions/classesGenericfor generic classesProtocolfor structural typing (rare - prefer ABC)TYPE_CHECKINGfor conditional importsAny(use sparingly)
Complete Type Annotation Syntax for Python 3.11
Basic Collection Types
✅ PREFERRED - Use built-in generic types:
names: list[str] = []
mapping: dict[str, int] = {}
unique_ids: set[str] = set()
coordinates: tuple[int, int] = (0, 0)❌ WRONG - Don't use typing module equivalents:
from typing import List, Dict, Set, Tuple # Don't do this
names: List[str] = []Union Types
✅ PREFERRED - Use | operator:
def process(value: str | int) -> str:
return str(value)
def find_config(name: str) -> dict[str, str] | dict[str, int]:
...
# Multiple unions
def parse(input: str | int | float) -> str:
return str(input)❌ WRONG - Don't use typing.Union:
from typing import Union
def process(value: Union[str, int]) -> str: # Don't do this
...Optional Types
✅ PREFERRED - Use X | None:
def find_user(id: str) -> User | None:
"""Returns user or None if not found."""
if id in users:
return users[id]
return None❌ WRONG - Don't use typing.Optional:
from typing import Optional
def find_user(id: str) -> Optional[User]: # Don't do this
...Self Type for Self-Returning Methods (NEW in 3.11)
✅ PREFERRED - Use Self for methods that return the instance:
from typing import Self
class Builder:
def set_name(self, name: str) -> Self:
self.name = name
return self
def set_value(self, value: int) -> Self:
self.value = value
return self
# Usage with type safety
builder = Builder().set_name("app").set_value(42)❌ WRONG - Don't use bound TypeVar anymore:
from typing import TypeVar
T = TypeVar("T", bound="Builder")
class Builder:
def set_name(self: T, name: str) -> T: # Don't do this
...When to use Self:
- Methods that return
self - Builder pattern methods
- Fluent interfaces with method chaining
- Factory classmethods
Self in classmethod:
from typing import Self
class Config:
def __init__(self, data: dict[str, str]) -> None:
self.data = data
@classmethod
def from_file(cls, path: str) -> Self:
"""Load config from file."""
import json
with open(path, encoding="utf-8") as f:
data = json.load(f)
return cls(data)Generic Functions with TypeVar
✅ PREFERRED - Use TypeVar for generic functions:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
"""Return first item or None if empty."""
if not items:
return None
return items[0]
def identity(value: T) -> T:
return valueNote: Python 3.12 introduces better syntax (PEP 695) for this pattern.
Generic Classes
✅ PREFERRED - Use Generic with TypeVar:
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
"""A generic stack data structure."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> Self: # Can combine with Self!
self._items.append(item)
return self
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
# Usage
int_stack = Stack[int]()
int_stack.push(42).push(43) # Method chaining works!Note: Python 3.12 introduces cleaner syntax for generic classes.
Constrained and Bounded TypeVars
✅ Use TypeVar constraints when needed:
from typing import TypeVar
# Constrained to specific types
Numeric = TypeVar("Numeric", int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
# Bounded to base class
T = TypeVar("T", bound=BaseClass)
def process(obj: T) -> T:
return objCallable Types
✅ PREFERRED - Use collections.abc.Callable:
from collections.abc import Callable
# Function that takes int, returns str
processor: Callable[[int], str] = str
# Function with no args, returns None
callback: Callable[[], None] = lambda: None
# Function with multiple args
validator: Callable[[str, int], bool] = lambda s, i: len(s) > iType Aliases
✅ Use simple assignment for type aliases:
# Simple alias
UserId = str
Config = dict[str, str | int | bool]
# Complex nested type
JsonValue = dict[str, "JsonValue"] | list["JsonValue"] | str | int | float | bool | None
def load_config() -> Config:
return {"host": "localhost", "port": 8080}Note: Python 3.12 introduces type statement for better alias support.
When from future import annotations is Needed
Use from __future__ import annotations when you encounter:
Forward references (class referencing itself):
from __future__ import annotations
class Node:
def __init__(self, value: int, parent: Node | None = None):
self.value = value
self.parent = parentCircular type imports:
# a.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from b import B
class A:
def method(self) -> B:
...Complex recursive types:
from __future__ import annotations
JsonValue = dict[str, JsonValue] | list[JsonValue] | str | int | float | bool | NoneInterfaces: ABC vs Protocol
✅ PREFERRED - Use ABC for interfaces:
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, id: str) -> User | None:
"""Get user by ID."""
@abstractmethod
def save(self, user: User) -> None:
"""Save user."""🟡 VALID - Use Protocol only for structural typing:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
# Any object with draw() method matches
def render(obj: Drawable) -> None:
obj.draw()Dignified Python prefers ABC because it makes inheritance and intent explicit.
Complete Examples
Builder Pattern with Self
from typing import Self
class QueryBuilder:
"""SQL query builder with fluent interface."""
def __init__(self) -> None:
self._select: list[str] = ["*"]
self._from: str | None = None
self._where: list[str] = []
self._limit: int | None = None
def select(self, *columns: str) -> Self:
"""Specify columns to select."""
self._select = list(columns)
return self
def from_table(self, table: str) -> Self:
"""Specify table to query."""
self._from = table
return self
def where(self, condition: str) -> Self:
"""Add WHERE condition."""
self._where.append(condition)
return self
def limit(self, n: int) -> Self:
"""Set LIMIT."""
self._limit = n
return self
def build(self) -> str:
"""Build final SQL query."""
if not self._from:
raise ValueError("FROM table not specified")
parts = [f"SELECT {', '.join(self._select)}"]
parts.append(f"FROM {self._from}")
if self._where:
parts.append(f"WHERE {' AND '.join(self._where)}")
if self._limit:
parts.append(f"LIMIT {self._limit}")
return " ".join(parts)
# Usage with type-safe method chaining
query = (
QueryBuilder()
.select("id", "name", "email")
.from_table("users")
.where("active = true")
.where("age > 18")
.limit(10)
.build()
)Factory Methods with Self
from typing import Self
from pathlib import Path
import json
class Config:
"""Application configuration with multiple factory methods."""
def __init__(self, data: dict[str, str | int]) -> None:
self.data = data
@classmethod
def from_json(cls, path: Path) -> Self:
"""Load configuration from JSON file."""
if not path.exists():
raise FileNotFoundError(f"Config not found: {path}")
with path.open(encoding="utf-8") as f:
data = json.load(f)
return cls(data)
@classmethod
def from_env(cls) -> Self:
"""Load configuration from environment variables."""
import os
data = {
k.lower(): v
for k, v in os.environ.items()
if k.startswith("APP_")
}
return cls(data)
@classmethod
def default(cls) -> Self:
"""Create default configuration."""
return cls({"host": "localhost", "port": 8080})
def with_override(self, key: str, value: str | int) -> Self:
"""Return new config with overridden value."""
new_data = self.data.copy()
new_data[key] = value
return type(self)(new_data)
# All factory methods return correct type
config = Config.from_json(Path("config.json"))
dev_config = config.with_override("debug", True)Type Checking Rules
What to Type
✅ MUST type:
- All public function parameters (except
self,cls) - All public function return values
- All class attributes (public and private)
- Module-level constants
🟡 SHOULD type:
- Internal function signatures
- Complex local variables
🟢 MAY skip:
- Simple local variables where type is obvious (
count = 0) - Lambda parameters in short inline lambdas
- Loop variables in short comprehensions
Running Type Checker
uv run ty checkAll code should pass type checking without errors.
Type Checking Configuration
Configure ty in pyproject.toml:
[tool.ty.environment]
python-version = "3.11"Common Patterns
Checking for None
✅ CORRECT - Check before use:
def process_user(user: User | None) -> str:
if user is None:
return "No user"
return user.nameDict.get() with Type Safety
✅ CORRECT - Handle None case:
def get_port(config: dict[str, int]) -> int:
port = config.get("port")
if port is None:
return 8080
return portList Operations
✅ CORRECT - Check before accessing:
def first_or_default(items: list[str], default: str) -> str:
if not items:
return default
return items[0]Migration from Python 3.10
If upgrading from Python 3.10:
1. Replace bound TypeVar with Self for self-returning methods:
- Old:
T = TypeVar("T", bound="ClassName") - New:
from typing import Selfand use-> Self
2. Enjoy improved error messages (no code changes needed)
3. All existing 3.10 syntax continues to work
References
Type Annotations - Python 3.12
This document provides complete, canonical type annotation guidance for Python 3.12.
Overview
Python 3.12 introduces PEP 695, a major syntactic improvement for generic types. The new type parameter syntax makes generic functions and classes significantly more readable. All syntax from 3.10 and 3.11 continues to work.
What's new in 3.12:
- PEP 695 type parameter syntax:
def func[T](x: T) -> T typestatement for better type aliases- Cleaner generic class syntax
Available from 3.11:
Selftype for self-returning methods
Available from 3.10:
- Built-in generic types:
list[T],dict[K, V], etc. - Union types with
|operator - Optional with
X | None
What you need from typing module:
Selffor self-returning methodsTypeVaronly for constrained/bounded genericsProtocolfor structural typing (rare - prefer ABC)TYPE_CHECKINGfor conditional importsAny(use sparingly)
Complete Type Annotation Syntax for Python 3.12
Basic Collection Types
✅ PREFERRED - Use built-in generic types:
names: list[str] = []
mapping: dict[str, int] = {}
unique_ids: set[str] = set()
coordinates: tuple[int, int] = (0, 0)❌ WRONG - Don't use typing module equivalents:
from typing import List, Dict, Set, Tuple # Don't do this
names: List[str] = []Union Types
✅ PREFERRED - Use | operator:
def process(value: str | int) -> str:
return str(value)
def find_config(name: str) -> dict[str, str] | dict[str, int]:
...
# Multiple unions
def parse(input: str | int | float) -> str:
return str(input)❌ WRONG - Don't use typing.Union:
from typing import Union
def process(value: Union[str, int]) -> str: # Don't do this
...Optional Types
✅ PREFERRED - Use X | None:
def find_user(id: str) -> User | None:
"""Returns user or None if not found."""
if id in users:
return users[id]
return None❌ WRONG - Don't use typing.Optional:
from typing import Optional
def find_user(id: str) -> Optional[User]: # Don't do this
...Self Type for Self-Returning Methods
✅ PREFERRED - Use Self for methods that return the instance:
from typing import Self
class Builder:
def set_name(self, name: str) -> Self:
self.name = name
return self
def set_value(self, value: int) -> Self:
self.value = value
return selfGeneric Functions with PEP 695 (NEW in 3.12)
✅ PREFERRED - Use PEP 695 type parameter syntax:
def first[T](items: list[T]) -> T | None:
"""Return first item or None if empty."""
if not items:
return None
return items[0]
def identity[T](value: T) -> T:
"""Return value unchanged."""
return value
# Multiple type parameters
def zip_dicts[K, V](keys: list[K], values: list[V]) -> dict[K, V]:
"""Create dict from separate key and value lists."""
return dict(zip(keys, values))🟡 VALID - TypeVar still works:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
if not items:
return None
return items[0]Note: Prefer PEP 695 syntax for simple generics. TypeVar is still needed for constraints/bounds.
Generic Classes with PEP 695 (NEW in 3.12)
✅ PREFERRED - Use PEP 695 class syntax:
class Stack[T]:
"""A generic stack data structure."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> Self:
self._items.append(item)
return self
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
# Usage
int_stack = Stack[int]()
int_stack.push(42).push(43)🟡 VALID - Generic with TypeVar still works:
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
# ... rest of implementationNote: PEP 695 is cleaner - no imports needed, type parameter scope is local to class.
Type Parameter Bounds
✅ Use bounds with PEP 695:
class Comparable:
def compare(self, other: object) -> int:
...
def max_value[T: Comparable](items: list[T]) -> T:
"""Get maximum value from comparable items."""
return max(items, key=lambda x: x)Constrained TypeVars (Still Use TypeVar)
✅ Use TypeVar for specific type constraints:
from typing import TypeVar
# Constrained to specific types - must use TypeVar
Numeric = TypeVar("Numeric", int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b❌ WRONG - PEP 695 doesn't support constraints:
# This doesn't constrain to int|float
def add[Numeric](a: Numeric, b: Numeric) -> Numeric:
return a + bType Aliases with type Statement (NEW in 3.12)
✅ PREFERRED - Use type statement:
# Simple alias
type UserId = str
type Config = dict[str, str | int | bool]
# Generic type alias
type Result[T] = tuple[T, str | None]
def process(value: str) -> Result[int]:
try:
return (int(value), None)
except ValueError as e:
return (0, str(e))🟡 VALID - Simple assignment still works:
UserId = str # Still valid
Config = dict[str, str | int | bool] # Still validNote: type statement is more explicit and works better with generics.
Callable Types
✅ PREFERRED - Use collections.abc.Callable:
from collections.abc import Callable
# Function that takes int, returns str
processor: Callable[[int], str] = str
# Function with no args, returns None
callback: Callable[[], None] = lambda: None
# Function with multiple args
validator: Callable[[str, int], bool] = lambda s, i: len(s) > iWhen from future import annotations is Needed
Use from __future__ import annotations when you encounter:
Forward references (class referencing itself):
from __future__ import annotations
class Node:
def __init__(self, value: int, parent: Node | None = None):
self.value = value
self.parent = parentCircular type imports:
# a.py
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from b import B
class A:
def method(self) -> B:
...Complex recursive types:
from __future__ import annotations
type JsonValue = dict[str, JsonValue] | list[JsonValue] | str | int | float | bool | NoneInterfaces: ABC vs Protocol
✅ PREFERRED - Use ABC for interfaces:
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, id: str) -> User | None:
"""Get user by ID."""
@abstractmethod
def save(self, user: User) -> None:
"""Save user."""🟡 VALID - Use Protocol only for structural typing:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def render(obj: Drawable) -> None:
obj.draw()Dignified Python prefers ABC because it makes inheritance and intent explicit.
Complete Examples
Generic Stack with PEP 695
from typing import Self
class Stack[T]:
"""Type-safe stack with PEP 695 syntax."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> Self:
"""Push item and return self for chaining."""
self._items.append(item)
return self
def pop(self) -> T | None:
"""Pop item or return None if empty."""
if not self._items:
return None
return self._items.pop()
def peek(self) -> T | None:
"""Peek at top item without removing."""
if not self._items:
return None
return self._items[-1]
def is_empty(self) -> bool:
"""Check if stack is empty."""
return len(self._items) == 0
# Usage
numbers = Stack[int]()
numbers.push(1).push(2).push(3)
top = numbers.pop() # Type checker knows this is int | NoneGeneric Repository with PEP 695
from abc import ABC, abstractmethod
from typing import Self
class Repository[T]:
"""Abstract repository with generic type parameter."""
@abstractmethod
def get(self, id: str) -> T | None:
"""Get entity by ID."""
@abstractmethod
def save(self, entity: T) -> Self:
"""Save entity, return self for chaining."""
@abstractmethod
def delete(self, id: str) -> bool:
"""Delete entity, return success."""
def get_or_fail(self, id: str) -> T:
"""Get entity or raise error."""
entity = self.get(id)
if entity is None:
raise ValueError(f"Entity not found: {id}")
return entity
class InMemoryRepository[T](Repository[T]):
"""In-memory repository implementation."""
def __init__(self) -> None:
self._storage: dict[str, T] = {}
def get(self, id: str) -> T | None:
return self._storage.get(id)
def save(self, entity: T) -> Self:
# Assume entity has 'id' attribute
entity_id = str(getattr(entity, "id", id(entity)))
self._storage[entity_id] = entity
return self
def delete(self, id: str) -> bool:
if id in self._storage:
del self._storage[id]
return True
return False
# Usage
from dataclasses import dataclass
@dataclass
class User:
id: str
name: str
repo = InMemoryRepository[User]()
repo.save(User("1", "Alice")).save(User("2", "Bob"))
user = repo.get("1") # Type: User | NoneType Aliases with type Statement
# Simple aliases
type UserId = str
type ErrorMessage = str
# Complex nested types
type JsonValue = dict[str, JsonValue] | list[JsonValue] | str | int | float | bool | None
# Generic type aliases
type Result[T] = tuple[T, ErrorMessage | None]
type AsyncResult[T] = tuple[T | None, ErrorMessage | None]
def parse_int(value: str) -> Result[int]:
"""Parse string to int, return result with optional error."""
try:
return (int(value), None)
except ValueError as e:
return (0, str(e))
def fetch_user(id: UserId) -> AsyncResult[dict[str, str]]:
"""Fetch user data asynchronously."""
# Implementation...
return ({"id": id, "name": "Alice"}, None)Builder Pattern with Self and PEP 695
from typing import Self
class QueryBuilder[T]:
"""Generic query builder with fluent interface."""
def __init__(self, result_type: type[T]) -> None:
self._result_type = result_type
self._filters: list[str] = []
self._limit: int | None = None
def filter(self, condition: str) -> Self:
"""Add filter condition."""
self._filters.append(condition)
return self
def limit(self, n: int) -> Self:
"""Set result limit."""
self._limit = n
return self
def build(self) -> str:
"""Build query string."""
query = " AND ".join(self._filters)
if self._limit:
query += f" LIMIT {self._limit}"
return query
# Usage
@dataclass
class User:
name: str
age: int
builder = QueryBuilder[User](User)
query = (
builder
.filter("active = true")
.filter("age > 18")
.limit(10)
.build()
)Generic Function Utilities
def map_list[T, U](items: list[T], func: Callable[[T], U]) -> list[U]:
"""Map function over list items."""
from collections.abc import Callable
return [func(item) for item in items]
def filter_list[T](items: list[T], predicate: Callable[[T], bool]) -> list[T]:
"""Filter list by predicate."""
from collections.abc import Callable
return [item for item in items if predicate(item)]
def reduce_list[T, U](
items: list[T],
func: Callable[[U, T], U],
initial: U,
) -> U:
"""Reduce list to single value."""
from collections.abc import Callable
result = initial
for item in items:
result = func(result, item)
return result
# Usage
numbers = [1, 2, 3, 4, 5]
doubled = map_list(numbers, lambda x: x * 2) # list[int]
evens = filter_list(numbers, lambda x: x % 2 == 0) # list[int]
sum_val = reduce_list(numbers, lambda acc, x: acc + x, 0) # intType Checking Rules
What to Type
✅ MUST type:
- All public function parameters (except
self,cls) - All public function return values
- All class attributes (public and private)
- Module-level constants
🟡 SHOULD type:
- Internal function signatures
- Complex local variables
🟢 MAY skip:
- Simple local variables where type is obvious (
count = 0) - Lambda parameters in short inline lambdas
- Loop variables in short comprehensions
Running Type Checker
uv run ty checkAll code should pass type checking without errors.
Type Checking Configuration
Configure ty in pyproject.toml:
[tool.ty.environment]
python-version = "3.12"Common Patterns
Checking for None
✅ CORRECT - Check before use:
def process_user(user: User | None) -> str:
if user is None:
return "No user"
return user.nameDict.get() with Type Safety
✅ CORRECT - Handle None case:
def get_port(config: dict[str, int]) -> int:
port = config.get("port")
if port is None:
return 8080
return portList Operations
✅ CORRECT - Check before accessing:
def first_or_default[T](items: list[T], default: T) -> T:
if not items:
return default
return items[0]When to Use PEP 695 vs TypeVar
Use PEP 695 for:
- Simple generic functions (no constraints/bounds)
- Simple generic classes
- Most common generic use cases
- New code
Still use TypeVar for:
- Constrained type variables:
TypeVar("T", str, bytes) - Bound type variables with complex bounds
- Covariant/contravariant type variables
- Reusing same TypeVar across multiple functions
Migration from Python 3.11
If upgrading from Python 3.11:
1. Consider migrating to PEP 695 syntax:
TypeVar+def func(x: T) -> T→def func[T](x: T) -> TGeneric[T]+class C(Generic[T])→class C[T]
2. Consider using `type` statement for aliases:
Config = dict[str, str]→type Config = dict[str, str]
3. Keep TypeVar for constraints:
TypeVarwith constraints still needed
4. All existing 3.11 syntax continues to work:
Selftype still preferred- Union with
|still preferred
References
Type Annotations - Python 3.13
This document provides complete, canonical type annotation guidance for Python 3.13. Python 3.13 implements PEP 649 (Deferred Evaluation of Annotations), fundamentally changing how annotations are evaluated.
Overview
The key change: forward references and circular imports work naturally without `from __future__ import annotations`.
All type features from previous versions (3.10-3.12) continue to work.
What's new in 3.13:
- PEP 649 deferred annotation evaluation
- Forward references work naturally (no quotes, no
from __future__) - Circular imports no longer cause annotation errors
- DO NOT use `from __future__ import annotations`
Available from 3.12:
- PEP 695 type parameter syntax:
def func[T](x: T) -> T typestatement for better type aliases
Available from 3.11:
Selftype for self-returning methods
Universal Philosophy
Code Clarity:
- Types serve as inline documentation
- Make function contracts explicit
- Reduce cognitive load when reading code
- Help understand data flow without tracing through implementation
IDE Support:
- Enable autocomplete and intelligent suggestions
- Catch typos and attribute errors before runtime
- Support refactoring tools (rename, move, extract)
- Provide jump-to-definition for typed objects
Bug Prevention:
- Catch type mismatches during static analysis
- Prevent None-related errors with explicit optional types
- Document expected input/output without running code
- Enable early detection of API contract violations
Consistency Rules
All public APIs:
- 🔴 MUST: Type all function parameters (except
selfandcls) - 🔴 MUST: Type all function return values
- 🔴 MUST: Type all class attributes
- 🟡 SHOULD: Type module-level constants
Internal code:
- 🟡 SHOULD: Type function signatures where helpful for clarity
- 🟢 MAY: Type complex local variables where type isn't obvious
- 🟢 MAY: Omit types for obvious cases (e.g.,
count = 0)
Basic Collection Types
✅ PREFERRED - Use built-in generic types:
names: list[str] = []
mapping: dict[str, int] = {}
unique_ids: set[str] = set()
coordinates: tuple[int, int] = (0, 0)❌ WRONG - Don't use typing module equivalents:
from typing import List, Dict, Set, Tuple # Don't do this
names: List[str] = []Why: Built-in types are more concise, don't require imports, and are the modern Python standard (available since 3.10).
Union Types
✅ PREFERRED - Use | operator:
def process(value: str | int) -> str:
return str(value)
def find_config(name: str) -> dict[str, str] | dict[str, int]:
...
# Multiple unions
def parse(input: str | int | float) -> str:
return str(input)❌ WRONG - Don't use typing.Union:
from typing import Union
def process(value: Union[str, int]) -> str: # Don't do this
...Optional Types
✅ PREFERRED - Use X | None:
def find_user(id: str) -> User | None:
"""Returns user or None if not found."""
if id in users:
return users[id]
return None❌ WRONG - Don't use typing.Optional:
from typing import Optional
def find_user(id: str) -> Optional[User]: # Don't do this
...Callable Types
✅ PREFERRED - Use collections.abc.Callable:
from collections.abc import Callable
# Function that takes int, returns str
processor: Callable[[int], str] = str
# Function with no args, returns None
callback: Callable[[], None] = lambda: None
# Function with multiple args
validator: Callable[[str, int], bool] = lambda s, i: len(s) > iInterfaces: ABC vs Protocol
✅ PREFERRED - Use ABC for interfaces:
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, id: str) -> User | None:
"""Get user by ID."""
@abstractmethod
def save(self, user: User) -> None:
"""Save user."""🟡 VALID - Use Protocol only for structural typing:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
def render(obj: Drawable) -> None:
obj.draw()Dignified Python prefers ABC because it makes inheritance and intent explicit.
Self Type for Self-Returning Methods (3.11+)
✅ PREFERRED - Use Self for methods that return the instance:
from typing import Self
class Builder:
def set_name(self, name: str) -> Self:
self.name = name
return self
def set_value(self, value: int) -> Self:
self.value = value
return selfGeneric Functions with PEP 695 (3.12+)
✅ PREFERRED - Use PEP 695 type parameter syntax:
def first[T](items: list[T]) -> T | None:
"""Return first item or None if empty."""
if not items:
return None
return items[0]
def identity[T](value: T) -> T:
"""Return value unchanged."""
return value
# Multiple type parameters
def zip_dicts[K, V](keys: list[K], values: list[V]) -> dict[K, V]:
"""Create dict from separate key and value lists."""
return dict(zip(keys, values))🟡 VALID - TypeVar still works:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T | None:
if not items:
return None
return items[0]Note: Prefer PEP 695 syntax for simple generics. TypeVar is still needed for constraints/bounds.
Generic Classes with PEP 695 (3.12+)
✅ PREFERRED - Use PEP 695 class syntax:
class Stack[T]:
"""A generic stack data structure."""
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> Self:
self._items.append(item)
return self
def pop(self) -> T | None:
if not self._items:
return None
return self._items.pop()
# Usage
int_stack = Stack[int]()
int_stack.push(42).push(43)🟡 VALID - Generic with TypeVar still works:
from typing import Generic, TypeVar
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
# ... rest of implementationNote: PEP 695 is cleaner - no imports needed, type parameter scope is local to class.
Type Parameter Bounds (3.12+)
✅ Use bounds with PEP 695:
class Comparable:
def compare(self, other: object) -> int:
...
def max_value[T: Comparable](items: list[T]) -> T:
"""Get maximum value from comparable items."""
return max(items, key=lambda x: x)Constrained TypeVars (Still Use TypeVar)
✅ Use TypeVar for specific type constraints:
from typing import TypeVar
# Constrained to specific types - must use TypeVar
Numeric = TypeVar("Numeric", int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b❌ WRONG - PEP 695 doesn't support constraints:
# This doesn't constrain to int|float
def add[Numeric](a: Numeric, b: Numeric) -> Numeric:
return a + bType Aliases with type Statement (3.12+)
✅ PREFERRED - Use type statement:
# Simple alias
type UserId = str
type Config = dict[str, str | int | bool]
# Generic type alias
type Result[T] = tuple[T, str | None]
def process(value: str) -> Result[int]:
try:
return (int(value), None)
except ValueError as e:
return (0, str(e))🟡 VALID - Simple assignment still works:
UserId = str # Still valid
Config = dict[str, str | int | bool] # Still validNote: type statement is more explicit and works better with generics.
Forward References and Circular Imports (NEW in 3.13)
✅ CORRECT - Just works naturally with PEP 649:
# Forward reference - no quotes needed!
class Node:
def __init__(self, value: int, parent: Node | None = None):
self.value = value
self.parent = parent
# Circular imports - just works!
# a.py
from b import B
class A:
def method(self) -> B:
...
# b.py
from a import A
class B:
def method(self) -> A:
...
# Recursive types - no future needed!
type JsonValue = dict[str, JsonValue] | list[JsonValue] | str | int | float | bool | None❌ WRONG - Don't use from __future__ import annotations:
from __future__ import annotations # DON'T DO THIS in Python 3.13
class Node:
def __init__(self, value: int, parent: Node | None = None):
...Why avoid `from __future__ import annotations` in 3.13:
- Unnecessary - PEP 649 provides better default behavior
- Can cause confusion
- Masks the native 3.13 deferred evaluation
- Prevents you from leveraging improvements
Complete Examples
Tree Structure with Natural Forward References
from typing import Self
from collections.abc import Callable
class Node[T]:
"""Tree node - forward reference works naturally in 3.13!"""
def __init__(
self,
value: T,
parent: Node[T] | None = None, # Forward ref, no quotes!
children: list[Node[T]] | None = None, # Forward ref, no quotes!
) -> None:
self.value = value
self.parent = parent
self.children = children or []
def add_child(self, child: Node[T]) -> Self:
"""Add child and return self for chaining."""
self.children.append(child)
child.parent = self
return self
def find(self, predicate: Callable[[T], bool]) -> Node[T] | None:
"""Find first node matching predicate."""
if predicate(self.value):
return self
for child in self.children:
result = child.find(predicate)
if result:
return result
return None
# Usage - all type-safe with no __future__ import!
root = Node[int](1)
root.add_child(Node[int](2)).add_child(Node[int](3))Generic Repository with PEP 695
from abc import ABC, abstractmethod
from typing import Self
class Entity[T]:
"""Base class for entities."""
def __init__(self, id: T) -> None:
self.id = id
class Repository[T](ABC):
"""Generic repository interface."""
@abstractmethod
def get(self, id: str) -> T | None:
"""Get entity by ID."""
@abstractmethod
def save(self, entity: T) -> None:
"""Save entity."""
@abstractmethod
def delete(self, id: str) -> bool:
"""Delete entity, return True if deleted."""
class User(Entity[str]):
def __init__(self, id: str, name: str) -> None:
super().__init__(id)
self.name = name
class UserRepository(Repository[User]):
def __init__(self) -> None:
self._users: dict[str, User] = {}
def get(self, id: str) -> User | None:
if id not in self._users:
return None
return self._users[id]
def save(self, entity: User) -> None:
self._users[entity.id] = entity
def delete(self, id: str) -> bool:
if id not in self._users:
return False
del self._users[id]
return TrueGeneral Best Practices
Prefer specificity:
# ✅ GOOD - Specific
def get_config() -> dict[str, str | int]:
...
# ❌ WRONG - Too vague
def get_config() -> dict:
...Use Union sparingly:
# ✅ GOOD - Union only when necessary
def process(value: str | int) -> str:
...
# ❌ WRONG - Too permissive
def process(value: str | int | list | dict) -> str | None | list:
...Be explicit with None:
# ✅ GOOD - Explicit optional
def find_user(id: str) -> User | None:
...
# ❌ WRONG - Implicit None return
def find_user(id: str) -> User:
return None # Type checker error!Avoid Any when possible:
# ✅ GOOD - Specific type
def serialize(obj: User | Config) -> str:
...
# ❌ WRONG - Defeats purpose of types
from typing import Any
def serialize(obj: Any) -> str:
...When to Use Types
Always type:
- Public function signatures (parameters + return)
- Class attributes (including private ones)
- Function parameters that cross module boundaries
- Return values that aren't immediately obvious
Type when helpful:
- Complex local variables
- Closures and nested functions
- Lambda expressions used as callbacks
Can skip:
- Obvious cases:
count = 0,name = "example" - Trivial private helpers
- Test fixture setup code (if types add no clarity)
Type Checking with ty
Dignified Python uses ty for static type checking:
# Check all files
ty check
# Check specific file
ty check src/mymodule.py
# Check with specific Python version
ty check --python-version 3.13Configuration (in pyproject.toml):
[tool.ty.environment]
python-version = "3.13"Anti-Patterns
❌ Don't ignore type errors with `# type: ignore`
# ❌ WRONG - Hiding type error
result = unsafe_function() # type: ignore
# ✅ CORRECT - Fix the type error
result: Expected = cast(Expected, unsafe_function())❌ Don't use bare Exception in type hints
# ❌ WRONG - No value from typing exception
def risky() -> str | Exception:
...
# ✅ CORRECT - Let exceptions bubble
def risky() -> str:
... # Raises ValueError on error❌ Don't over-type simple cases
# ❌ WRONG - Obvious from context
def add_numbers(a: int, b: int) -> int:
result: int = a + b # Unnecessary type annotation
return result
# ✅ CORRECT - Type only signature
def add_numbers(a: int, b: int) -> int:
result = a + b # Type is obvious
return resultMigration from 3.10/3.11
If migrating from Python 3.10/3.11:
1. Remove `from __future__ import annotations` - No longer needed 2. Consider upgrading to PEP 695 syntax - Cleaner generics 3. Use `type` statement for aliases - More explicit than assignment 4. Remove quoted forward references - They work naturally now
# Python 3.10/3.11
from __future__ import annotations
from typing import TypeVar, Generic
T = TypeVar("T")
class Node(Generic[T]):
def __init__(self, value: T, parent: "Node[T] | None" = None):
...
# Python 3.13
from typing import Self
class Node[T]:
def __init__(self, value: T, parent: Node[T] | None = None):
...What typing imports are still needed?
Very rare:
TypeVar- Only for constrained/bounded type variablesAny- Use sparingly when type truly unknownProtocol- Structural typing (prefer ABC)TYPE_CHECKING- Conditional imports to avoid circular dependencies
Never needed:
List,Dict,Set,Tuple- Use built-in typesUnion- Use|operatorOptional- UseX | NoneGeneric- Use PEP 695 class syntax