Getting Started with Cullinan¶
This page provides a minimal quick-start to install and run a small Cullinan application. The key mental model is: write business methods and components with decorators first, then let the runtime assemble them. Cullinan is not centered on a manually wired app object.
Read next: Framework Semantics, Engineering Practices
Runnable repository guide:examples/minimal_app/is the maintained minimal example.
Advanced boundary: if you intentionally need explicitApplicationorchestration, continue in Internals & Extensions instead of treating that path as the default bootstrap.
Prerequisites¶
- Python 3.9+
- Git
Install¶
Before you start, make sure Python 3.9+ is installed and that python and pip are available on your PATH.
On most systems, you can upgrade pip and install Cullinan with the following commands (valid on Windows, Linux, and macOS):
python -m pip install -U pip
python -m pip install cullinan
Quick start¶
- Create a new project directory and change into it:
On all platforms:
mkdir my_cullinan_project
cd my_cullinan_project
- Ensure you have a Python environment (virtualenv, conda, system Python, etc.). Install the published package:
python -m pip install -U pip
python -m pip install cullinan
- Create a minimal application file
minimal_app.pyin your project with the following content:
# minimal_app.py
from cullinan import application, configure
from cullinan.core import service
from cullinan.web import controller, get_api
@service
class GreetingService:
def greet(self) -> str:
return "Hello from Cullinan!"
@controller(url="/hello")
class HelloController:
"""Simple HTTP controller."""
greeting_service: GreetingService # ← 构造注入 (constructor injection)
@get_api(url="")
def hello(self):
return {"message": self.greeting_service.greet()}
@configure(user_packages=["my_cullinan_project"])
@application
def main(): ...
if __name__ == '__main__':
main()
- Run your app:
On Windows (PowerShell):
python minimal_app.py
On Linux / macOS:
python minimal_app.py
Then open http://localhost:4080/hello in your browser to verify the server is running.
What this example demonstrates¶
- You mainly write business components with
@service,@controller, and handler decorators @applicationmarks the default entry method@configure(...)attaches startup settings to that method@moduleis optional and only needed when you want an explicit advanced runtime boundary- Bare type annotations enable constructor injection, resolving controller dependency from the active application context
- calling the entry method assembles and serves the application through the framework's default startup API
Minimal application example¶
Here's a minimal Cullinan application that demonstrates the core framework features:
# minimal_app.py
from cullinan import application, configure
from cullinan.core import service
from cullinan.web import controller, get_api
@service
class GreetingService:
def greet(self) -> str:
return "Hello from Cullinan!"
@controller(url="/hello")
class HelloController:
greeting_service: GreetingService # ← 构造注入 (constructor injection)
@get_api(url="")
def hello(self):
return {"message": self.greeting_service.greet()}
@configure(user_packages=["my_cullinan_project"])
@application
def main(): ...
if __name__ == "__main__":
main()
To run this example:
# Save the above code as minimal_app.py
python minimal_app.py
Then visit http://localhost:4080/hello in your browser.
Understanding the basics¶
Application lifecycle¶
- Entry declaration:
@applicationmarks the entry method, and@configure(...)attaches startup settings to it - Discovery and assembly: calling
main()imports owned packages, finalizes pending decorator registrations, and assembles business components under the framework-managed runtime boundary - Activation: the validated runtime becomes active and is served through the framework-selected backend path
- Reload / shutdown: old runtimes drain in-flight requests before closing
Dependency Injection¶
Cullinan provides built-in IoC/DI support through decorator-driven component discovery and runtime-managed wiring.
Recommended runtime model¶
- Use
@servicefor business services - Use
@controllerfor HTTP controllers - Use bare type annotations for constructor injection (recommended)
- Use
Inject()for explicit type-based injection when needed - Use
InjectByName()when name-based lookup is more convenient - Start from business decorators and an entry method first
- Add
@moduleonly when you need explicit runtime boundaries such as package ownership, hot-pluggable modules, or stricter reload/draining control - If you intentionally need low-level container or runtime internals, continue in Internals & Extensions instead of treating that path as the quick-start model
from cullinan.web import controller, get_api, Path
from cullinan.core import service
@service
class UserService:
def get_user(self, user_id):
return {'id': user_id, 'name': 'John'}
@controller(url='/api/users')
class UserController:
user_service: UserService # ← 构造注入 (constructor injection)
@get_api(url='/{user_id}')
async def get_user(self, user_id: int = Path()):
return self.user_service.get_user(user_id)
Compatibility note: injectable and inject_constructor still exist as compatibility shims, but new code should not use them as the primary pattern.
RESTful API decorators (quick overview)¶
Cullinan provides a set of REST-style decorators that bind controller methods to HTTP routes:
get_apipost_apipatch_apidelete_apiput_api
Key points:
- These decorators only accept keyword arguments (they are defined as
def get_api(**kwargs)etc.). @get_api('/user')is invalid and will raise aTypeErrorat import time.- Always use the keyword form:
@get_api(url='/user'). - The
urlargument uses a lightweight template syntax with{param}placeholders.
v0.90+ Recommended: Type-Safe Parameter System
from cullinan.web.params import Path, Query, Body, DynamicBody
@controller(url='/api/users')
class UserController:
# Type-safe path and query parameters (new unified syntax)
@get_api(url='/{id}')
async def get_user(self, id: int = Path(), include_posts: bool = Query(default=False)):
return {"id": id, "include_posts": include_posts}
# Pure type annotation as Query (v0.90a5+)
@get_api(url='/')
async def list_users(
self,
page: int = 1, # Same as Query(default=1)
size: int = 10, # Same as Query(default=10)
):
return {"page": page, "size": size}
# Type-safe body parameters with validation
@post_api(url='/')
async def create_user(
self,
name: str = Body(required=True),
age: int = Body(default=0, ge=0, le=150),
):
return {"name": name, "age": age}
# DynamicBody for flexible access
@post_api(url='/dynamic')
async def create_dynamic(self, body: DynamicBody):
return {"name": body.name, "age": body.get('age', 0)}
See Parameter System Guide for full details, including:
- File uploads with FileInfo/FileList
- Dataclass validation with @field_validator
- Pydantic integration (optional, install with pip install pydantic)
- Custom model handlers
Legacy Style (still supported)
The traditional parameter style is still supported for backward compatibility: Common options: - `url`: Route pattern (string). Supports `{param}` placeholders, e.g. `'/users/{user_id}'`. - `query_params`: Iterable of query parameter names, e.g. `('page', 'size')`. - `body_params` (POST/PATCH only): Iterable of body field names for JSON/form parsing. - `file_params`: Iterable of file field names for file uploads. - `headers`: Iterable of required HTTP header names. - `get_request_body` (POST/PATCH only): If `True`, passes the raw request body to your method.@controller(url='/api/users')
class UserController:
@get_api(url='/{user_id}')
def get_user(self, url_params):
user_id = url_params.get('user_id') if url_params else None
return {"id": user_id}
@get_api(url='/', query_params=('page', 'size'))
def list_users(self, query_params):
page = query_params.get('page') if query_params else None
size = query_params.get('size') if query_params else None
return {"page": page, "size": size}
@post_api(url='/', body_params=('name', 'email'))
def create_user(self, body_params):
name = body_params.get('name') if body_params else None
email = body_params.get('email') if body_params else None
return {"name": name, "email": email}
For a deeper dive into URL patterns and all decorator options, see wiki/restful_api.md.
Recommended Dependency Injection Approaches¶
Approach 1: Constructor Injection (Recommended)
Prefer bare type annotations for constructor injection — the simplest and most idiomatic pattern:
from cullinan.core import service
@service
class DatabaseService:
def query(self, sql):
return f"Results for: {sql}"
@service
class UserRepository:
db: DatabaseService # ← 构造注入 (constructor injection)
def get_users(self):
return self.db.query("SELECT * FROM users")
Approach 2: Inject
Use typed Inject() when you need explicit injection semantics or are working with legacy code:
from cullinan.core import Inject, service
@service
class DatabaseService:
def query(self, sql):
return f"Results for: {sql}"
@service
class UserRepository:
db: DatabaseService = Inject()
def get_users(self):
return self.db.query("SELECT * FROM users")
Approach 3: InjectByName
Inject by name without importing dependencies, avoiding circular import issues:
from cullinan.core import InjectByName, service
@service
class DatabaseService:
def query(self, sql):
return f"Results for: {sql}"
@service
class UserRepository:
db = InjectByName('DatabaseService')
def get_users(self):
return self.db.query("SELECT * FROM users")
Approach 4: Inject + TYPE_CHECKING (IDE autocomplete support)
If you need IDE autocomplete and type checking, use Inject with TYPE_CHECKING:
from typing import TYPE_CHECKING
from cullinan.core import Inject, service
# TYPE_CHECKING imports are not executed at runtime, avoiding circular imports
if TYPE_CHECKING:
from my_project.services import DatabaseService
@service
class DatabaseService:
def query(self, sql):
return f"Results for: {sql}"
@service
class UserRepository:
db: 'DatabaseService' = Inject()
def get_users(self):
return self.db.query("SELECT * FROM users")
Summary: - Constructor Injection (bare type annotation): Recommended default — simplest, cleanest, idiomatic - Inject: Explicit syntax for legacy compatibility or when explicit injection semantics are preferred - InjectByName: Useful for decoupling or avoiding circular imports - Inject + TYPE_CHECKING: Best for strong editor support without runtime import coupling
For detailed information on injection patterns, see wiki/injection.md.
Common patterns¶
Adding middleware¶
from cullinan.web.middleware import MiddlewareBase
class LoggingMiddleware(MiddlewareBase):
def process_request(self, request):
print(f"Request: {request.method} {request.path}")
# Register during app initialization
app.add_middleware(LoggingMiddleware())
Configuration¶
from cullinan.support.config import Config
config = Config()
config.set('database.url', 'postgresql://localhost/mydb')
config.set('server.port', 8080)
Troubleshooting¶
- If you encounter errors installing packages, ensure your Python and pip are up to date and that you have network access to PyPI.
Next steps¶
- Read
wiki/injection.mdfor IoC/DI details. - Explore
examples/for runnable samples.
Additional resources¶
- Architecture: See
architecture.mdfor system design overview - Application runtime: Read
wiki/application_runtime.mdfor module graph, ownership, and draining - Components: Read
wiki/components.mdfor component responsibilities - Lifecycle: Learn about application lifecycle in
wiki/lifecycle.md - Middleware: Understand middleware in
wiki/middleware.md - API Reference: Browse
api_reference.mdfor complete API documentation - Examples: Explore
examples/directory for more samples
Community and support¶
- Issues: Report bugs at GitHub Issues
- Contributing: See
contributing.mdfor contribution guidelines - Testing: Read
testing.mdfor testing best practices