Code Conventions¶
This page documents the coding standards and patterns used throughout CianaParrot. Following these conventions keeps the codebase consistent and makes contributions easier to review.
Type Hints¶
Type hints are used throughout the codebase. All function signatures, return types, and class attributes should be annotated.
# Good
async def send(self, chat_id: str, text: str, *, reply_to_message_id: Optional[str] = None) -> Optional[SendResult]:
...
# Bad -- missing type hints
async def send(self, chat_id, text, reply_to_message_id=None):
...
Type hint style
- Use
Optional[str]for values that may beNone - Use lowercase generics for built-in types:
list[str],dict[str, int],tuple[int, ...] - Import from
typingwhen needed:Optional,Any,Protocol
Pydantic v2 for Configuration¶
All configuration validation uses Pydantic v2 models. Config sections are defined as BaseModel subclasses with typed fields and defaults.
from pydantic import BaseModel, Field
class WebConfig(BaseModel):
"""Web search and fetch configuration."""
search_provider: str = "brave"
search_api_key: str = ""
fetch_timeout: int = 30
max_results: int = 5
Never use dict access for config
Always use attribute access (config.web.search_provider), never dict-style access (config["web"]["search_provider"]). Pydantic models provide type safety and IDE autocompletion.
Async-First¶
All I/O operations use async def. CianaParrot runs on a single asyncio event loop shared by all channels, the scheduler, and the agent.
# Good -- async I/O
async def fetch_url(url: str) -> str:
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.text
# Bad -- blocking I/O on the event loop
def fetch_url(url: str) -> str:
response = requests.get(url) # blocks the entire bot
return response.text
If you must call blocking code
Use asyncio.to_thread() to run blocking functions in a thread pool:
Imports¶
Use relative imports within the src/ package:
# Good -- relative imports within src/
from .config import AppConfig
from .channels.base import AbstractChannel, IncomingMessage
from ..tools.web import init_web_tools
# Bad -- absolute imports for internal modules
from src.config import AppConfig
Exception
Test files use absolute imports (from src.config import AppConfig) since they live outside the src/ package.
Tool Pattern¶
Agent-callable tools use the @tool decorator from langchain_core.tools. Tools follow the module-level config pattern: an init_*() function sets module-level globals at startup, and @tool-decorated functions read those globals at call time.
"""Example tool module."""
import logging
from langchain_core.tools import tool
logger = logging.getLogger(__name__)
# Module-level state, set by init_example_tools()
_api_key: str = ""
_timeout: int = 30
def init_example_tools(api_key: str, timeout: int = 30) -> None:
"""Initialize module-level config. Called once at startup."""
global _api_key, _timeout
_api_key = api_key
_timeout = timeout
@tool
def example_search(query: str) -> str:
"""Search for something using the example API.
Args:
query: The search query string.
Returns:
Search results as a formatted string.
"""
if not _api_key:
return "Error: example tool not configured"
# ... implementation using _api_key and _timeout
Tool docstrings matter
The docstring of a @tool function is what the LLM sees when deciding whether to call the tool. Write clear, descriptive docstrings that explain what the tool does, what arguments it expects, and what it returns.
Logging¶
Use logging.getLogger(__name__) for all logging. Never use print().
import logging
logger = logging.getLogger(__name__)
# Good
logger.info("Channel started: %s", self.name)
logger.warning("Message too long, chunking at %d chars", max_len)
logger.error("Failed to send message: %s", exc)
# Bad
print("Channel started")
print(f"Error: {exc}")
| Level | Use For |
|---|---|
debug |
Detailed diagnostic information (message payloads, internal state) |
info |
Normal operations (startup, shutdown, task execution) |
warning |
Recoverable problems (missing optional config, retry attempts) |
error |
Failures that affect functionality (API errors, send failures) |
Data Containers¶
Use dataclasses for simple data containers that hold structured data without validation logic:
from dataclasses import dataclass
from typing import Optional
@dataclass
class IncomingMessage:
"""Normalized incoming message from any channel."""
channel: str
chat_id: str
user_id: str
user_name: str
text: str
is_private: bool = False
reply_to: Optional[str] = None
Key dataclasses in the codebase: IncomingMessage, SendResult, GatewayResult, CCResponse.
Interfaces¶
Use Protocol classes for interfaces where multiple implementations are expected:
from typing import Protocol
class ModeHandler(Protocol):
"""Protocol for channel mode handlers."""
async def handle_command(self, chat_id: str, args: str) -> None: ...
async def handle_message(self, chat_id: str, text: str) -> Optional[str]: ...
def is_active(self, chat_id: str) -> bool: ...
This enables duck typing -- any class that implements the required methods satisfies the protocol without explicit inheritance.
Error Handling¶
- Raise specific exceptions for errors that callers should handle.
- Log warnings for recoverable problems.
- Never silently swallow exceptions -- at minimum, log them.
# Good -- specific exception with context
if not config.channels.telegram.token:
raise ValueError("Telegram bot token is required but not configured")
# Good -- log and continue for recoverable errors
try:
await self.send(chat_id, response_text)
except TelegramError as exc:
logger.warning("Failed to send message to %s: %s", chat_id, exc)
# Bad -- bare except that hides problems
try:
result = await dangerous_operation()
except:
pass
File Structure¶
One module per concern. Keep files focused on a single responsibility.
| Principle | Example |
|---|---|
| One channel per package | src/channels/telegram/, src/channels/discord/ |
| One bridge per package | src/gateway/bridges/claude_code/, src/gateway/bridges/spotify/ |
| One tool module per domain | src/tools/web.py, src/tools/cron.py, src/tools/host.py |
| Config models together | All Pydantic models in src/config.py |
Naming Conventions¶
| Element | Convention | Example |
|---|---|---|
| Functions and variables | snake_case |
handle_message, chat_id |
| Classes | PascalCase |
TelegramChannel, AppConfig |
| Constants | UPPER_CASE |
MAX_MESSAGE_LENGTH, DEFAULT_TIMEOUT |
| Private attributes | Leading underscore | _callback, _client |
| Module-level config globals | Leading underscore | _api_key, _gateway_client |
| Test functions | test_ prefix with descriptive name |
test_group_message_without_trigger |
Summary¶
| Convention | Rule |
|---|---|
| Type hints | Always annotate function signatures and return types |
| Config | Pydantic v2 models, attribute access only |
| I/O | async def for everything; asyncio.to_thread() for blocking calls |
| Imports | Relative within src/, absolute in tests |
| Tools | @tool decorator + module-level config pattern |
| Logging | logging.getLogger(__name__), never print() |
| Data | Dataclasses for containers, Protocols for interfaces |
| Errors | Specific exceptions, log warnings for recoverable issues |
| Naming | snake_case functions, PascalCase classes, UPPER_CASE constants |