python-api-ergonomics-patterns
Use this for the Python-specific realization of the api-ergonomics-audit checklist — `async with Client(...)` quickstart, named-only constructor args, exception class hierarchy with discoverable subcl
SKILL.mdskills/python-api-ergonomics-patterns/SKILL.md
GitHubpython-api-ergonomics-patterns (v1.0.0)
Scope
Python realization of the 8-point checklist in shared-core api-ergonomics-audit. The audit defines what to check; this skill defines what "good" looks like in Python.
5-line quickstart
async with motadatapysdk.Cache(addr="localhost:6379") as cache:
await cache.set("k", "v")
v = await cache.get("k")
print(v)
async with is the canonical surface for resource-owning clients — entry constructs, exit cleans up. If the quickstart exceeds 5 lines, the Config has too many required fields or the constructor isn't keyword-only.
Doctest-runnable Examples block
PEP 257 docstring with an Examples: section that is also a runnable doctest:
async def get(self, key: str) -> str | None:
"""Fetch a value by key.
Returns None if the key is absent.
Examples:
>>> async with Cache(addr="localhost:6379") as cache:
... await cache.set("greeting", "hello")
... value = await cache.get("greeting")
... print(value)
hello
>>> async with Cache(addr="localhost:6379") as cache:
... missing = await cache.get("absent")
... print(missing)
None
"""
Wire pytest --doctest-modules so doctests run with the suite. For I/O-bound calls, mark with # doctest: +SKIP and document why; prefer fakes over skip when the example is core to the API understanding.
Exception class hierarchy
Every failure mode is a class extending the SDK's base exception:
class MotadataError(Exception):
"""Base for all SDK exceptions."""
class NetworkError(MotadataError):
"""Transient network failure — caller may retry."""
class TimeoutError(NetworkError):
"""Operation deadline exceeded."""
class AuthError(MotadataError):
"""Authentication or authorization failure — caller must re-auth."""
class KeyNotFoundError(MotadataError):
"""Sentinel for missing key. Mirrors Go SDK's ErrNil semantics."""
Caller pattern:
try:
v = await cache.get("k")
except KeyNotFoundError:
return cache_miss # discriminated by type
except NetworkError:
return retry_with_backoff(...)
The exception class set is semver-public — adding is minor, removing/renaming is major. Document the hierarchy in the package's __init__.py docstring.
Constructor — keyword-only, frozen Config
@dataclass(frozen=True, slots=True, kw_only=True)
class CacheConfig:
addr: str
pool_size: int = 10
timeout: float = 30.0
class Cache:
def __init__(self, *, config: CacheConfig | None = None, **overrides: Any) -> None:
self._config = config or CacheConfig(**overrides)
# ...
* after the first arg forces keyword-only — no Cache("localhost:6379", 10, 30) ambiguity. frozen=True prevents post-construction mutation. slots=True slashes memory.
Async context manager protocol
class Cache:
async def __aenter__(self) -> Self:
await self._connect()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.aclose()
async def aclose(self) -> None:
"""Idempotent shutdown — safe to call multiple times."""
if self._closed:
return
self._closed = True
# ordered teardown: tasks → drain → sessions
...
aclose() (not close()) is the modern convention — it signals the async semantics. Idempotency via _closed flag is mandatory; async-leak-detection harnesses (the asyncio-task-tracker fixture, tracemalloc snapshot diffs, threading.enumerate gates) will flag tasks left running after teardown.
Type-hinted Protocol seam
Public ports use Protocol (PEP 544), not ABC:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Cache(Protocol):
async def get(self, key: str) -> str | None: ...
async def set(self, key: str, value: str, ttl: float = 0) -> None: ...
async def aclose(self) -> None: ...
Consumers can inject any object that satisfies the Protocol — no inheritance required. mypy --strict checks signature parity at type-check time.
Anti-patterns
1. String-matching error discrimination. if "not found" in str(exc) — fragile to message rewording. Fix: catch the specific exception class.
2. Bare __init__ with positional args + **kwargs. Client(host, port, **kw) — type-checker can't help; arg order ambiguous. Fix: keyword-only with * separator, plus a typed Config dataclass.
3. Missing py.typed marker. SDK ships without py.typed; consumers don't get type-check coverage. Add py.typed (PEP 561) to the package root.
4. Non-idempotent aclose(). Second call raises RuntimeError("already closed"). Test suites and __aexit__ chains both call it; idempotency is mandatory.
5. print for logs. Use stdlib logging with structured fields. Consumers configure handlers; SDK does not own the destination.
Severity ladder
- BLOCKER — sync
__init__doing I/O, missingaclose(), exception class with no base, panic-equivalent on documented input - HIGH — missing doctest Examples, missing exception subclass for documented failure, quickstart >5 lines, missing
py.typed - MEDIUM — sibling inconsistency (
close()vsaclose(),seconds: intvstimeout: float) - LOW — docstring phrasing, parameter naming, module-doc thinness
Cross-references
- shared-core
api-ergonomics-audit— the 8-point checklist this realizes python-sdk-config-pattern— frozen dataclass Config + keyword-only constructorpython-doctest-patterns— doctest format,+SKIPdiscipline,--doctest-moduleswiringpython-exception-patterns— exception hierarchy +raise fromchainingpython-mypy-strict-typing— strict typing posture, PEP 561 markerpython-client-shutdown-lifecycle—aclose()ordering and idempotency rulessdk-semver-governance— ergonomics-driven API rewrite triggers semver bumps