From python-package
This skill should be used when the user is designing a library's public API surface, defining __all__, organizing imports, creating an exception hierarchy, implementing async/sync dual APIs, adding plugin architecture (pluggy, entry points, protocols), applying progressive disclosure, choosing return types, naming methods, or reviewing backward compatibility. Covers __all__, underscore-prefixed modules, exception trees, httpx _BaseClient pattern, pluggy, entry points, Protocols, dependency injection, configuration patterns.
npx claudepluginhub oborchers/fractional-cto --plugin python-packageThis skill uses the workspace's default tool permissions.
The public API is the contract between your library and every downstream user. Once a symbol is public, removing or changing it is a breaking change. Getting the boundary right -- what is exported, what is private, how errors are communicated, how complexity is layered -- is the most consequential design decision in a Python library. Get it wrong and you end up like Pydantic v1: an unintentiona...
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
The public API is the contract between your library and every downstream user. Once a symbol is public, removing or changing it is a breaking change. Getting the boundary right -- what is exported, what is private, how errors are communicated, how complexity is layered -- is the most consequential design decision in a Python library. Get it wrong and you end up like Pydantic v1: an unintentionally wide API surface that required a full rewrite to fix.
httpx is the gold standard. Every public symbol is re-exported from __init__.py. Every implementation module is underscore-prefixed (_client.py, _config.py). The exception hierarchy is a carefully designed tree. Sync and async clients share all non-I/O logic in a _BaseClient. Study httpx before designing your own API.
__all__ and Underscore ModulesEvery module that exports symbols must define __all__. This documents intent, controls from module import *, and tells tools (mypy, pyright, mkdocstrings) what is public.
| Rule | Rationale |
|---|---|
Define __all__ in every public module | Documents the contract; tools rely on it |
Re-export everything from __init__.py | Users write from mylib import Client, never from mylib._client import Client |
Prefix all implementation modules with _ | _client.py, _config.py, _exceptions.py make the private boundary unambiguous |
Sort __all__ alphabetically | Easy to review in diffs, easy to check for completeness |
Include type aliases and protocols in __all__ | Users need them for type-checking; easy to forget |
| Bad | Good |
|---|---|
from mylib.client import Client | from mylib import Client |
No __all__, everything implicitly public | Explicit __all__ listing every public name |
utils.py with mixed public/private helpers | _utils.py for internal, re-export public helpers from __init__.py |
Expose less than you think you need. You can always make something public later; you cannot easily make it private. If it is in __all__, it is a contract. If it starts with an underscore, you can change it freely.
Design APIs that serve beginners and experts simultaneously. Simple things should be simple; complex things should be possible.
# Layer 1: Module-level convenience (simplest)
response = httpx.get("https://api.example.com/users")
# Layer 2: Configured client (intermediate)
client = httpx.Client(base_url="https://api.example.com", timeout=30.0)
response = client.get("/users")
# Layer 3: Full customization (advanced)
transport = httpx.HTTPTransport(retries=3)
client = httpx.Client(transport=transport, timeout=httpx.Timeout(5.0, connect=10.0))
Rules for progressive disclosure:
* in the signature) to prevent positional footguns as signatures grow.httpx.Timeout(5.0, connect=10.0) is more discoverable than timeout=5.0, connect_timeout=10.0.A well-designed exception hierarchy lets users catch errors at exactly the right granularity. It is part of __all__ and must be as carefully designed as your classes.
class MyLibError(Exception):
"""Base exception. `except MyLibError` catches everything."""
class ConfigurationError(MyLibError):
"""Raised when configuration is invalid."""
class ConnectionError(MyLibError):
"""Raised when a connection fails."""
class TimeoutError(ConnectionError):
"""Subclasses ConnectionError -- timeout is a type of connection failure."""
class AuthenticationError(MyLibError):
"""Raised when authentication fails."""
class ValidationError(MyLibError):
"""Carries structured error data, not just a string."""
def __init__(self, errors: list[dict[str, Any]]) -> None:
self.errors = errors
super().__init__(f"{len(errors)} validation error(s)")
| Rule | Example |
|---|---|
| Always provide a base exception class | except MyLibError as catch-all |
| Carry structured data, not just strings | httpx's HTTPStatusError.response, Pydantic's .errors() |
| Use inheritance to group related errors | except TransportError catches all network issues |
Never raise bare Exception or ValueError | Users cannot distinguish your errors from others |
| Name exceptions as nouns | TimeoutError, not TimedOut |
| Document which methods raise which exceptions | Part of the API contract |
Follow httpx's _BaseClient pattern: share all non-I/O logic in a base class, implement genuinely separate sync and async I/O paths.
class _BaseClient:
"""Shared logic: URL merging, headers, cookies, auth -- no I/O."""
def _build_request(self, method: str, url: str, **kwargs) -> Request:
...
class Client(_BaseClient):
"""Synchronous client with blocking transport."""
def send(self, request: Request) -> Response:
return self._transport.handle_request(request)
class AsyncClient(_BaseClient):
"""Asynchronous client with async transport."""
async def send(self, request: Request) -> Response:
return await self._transport.handle_async_request(request)
| Bad | Good |
|---|---|
Wrap async with asyncio.run() in sync methods | Separate sync/async transport implementations |
| Duplicate all non-I/O logic in both clients | Share logic in _BaseClient |
| Only provide async API | Always provide sync; add async if doing I/O |
Never use asyncio.run() as a sync wrapper. It fails if an event loop is already running (Jupyter, async frameworks) and prevents connection pooling.
Choose the right extensibility pattern based on your scale.
| Pattern | Complexity | When to Use | Exemplar |
|---|---|---|---|
| pluggy | High | Full plugin ecosystem with hooks | pytest, tox |
| Entry points | Medium | Installed packages register themselves | pytest plugin discovery |
| Protocols | Low | Third-party opt-in without importing your lib | Rich (__rich_repr__) |
| Decorator registry | Low | Internal extensibility within your package | Click, Flask |
For protocols, use dunder names (__mylib_serialize__), make them @runtime_checkable, keep them to one method, and always provide a fallback for objects that do not implement the protocol. The key insight from Rich: objects do not need to import or subclass anything from your library to participate.
For entry points, define them in pyproject.toml:
[project.entry-points."mylib.plugins"]
my_plugin = "my_plugin_package:MyPlugin"
Discover them at runtime with importlib.metadata.entry_points(group="mylib.plugins").
Use consistent verb-noun naming across the entire API. Pydantic v2 learned from v1's inconsistency by adopting a uniform model_ prefix.
| Verb | Meaning | Example |
|---|---|---|
get | Retrieve (may raise if missing) | client.get() |
create | Make a new resource | Session.create() |
build | Construct from parts | Request.build() |
validate | Check and convert | model_validate() |
dump | Serialize to format | model_dump(), model_dump_json() |
load | Deserialize from format | json.load() |
Force keyword-only arguments after the first positional with * in the signature. Use sensible, secure defaults (verify=True, follow_redirects=False). Return rich objects for complex operations (httpx's Response carries status, headers, content, and raise_for_status()), primitives for simple queries, and self for builder/configuration methods.
Use frozen dataclasses for configuration objects. Validate in __post_init__. Provide a from_env() classmethod for environment variable integration without requiring it.
@dataclass(frozen=True)
class ClientConfig:
base_url: str
timeout: float = 30.0
max_retries: int = 3
def __post_init__(self) -> None:
if self.timeout <= 0:
raise ConfigurationError("timeout must be positive")
@classmethod
def from_env(cls, prefix: str = "MYLIB_") -> "ClientConfig":
return cls(
base_url=os.environ.get(f"{prefix}BASE_URL", ""),
timeout=float(os.environ.get(f"{prefix}TIMEOUT", cls.timeout)),
)
When reviewing code for API design:
__all__ is defined in every public module, sorted alphabetically, and includes all public symbols (classes, functions, exceptions, type aliases)_client.py, _config.py) and users never import from them directly__init__.py* separator)__all__Exception, ValueError, or TypeError is raised from library codeasyncio.run() wrappers are used to bridge async to syncNone sentinel + factory)