Skip to content

Getting Started with Cullinan

This page provides a minimal quick-start to install and run a small Cullinan application.

Prerequisites

  • Python 3.8+
  • Git

Install

Before you start, make sure Python 3.8+ 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

  1. Create a new project directory and change into it:

On all platforms:

mkdir my_cullinan_project
cd my_cullinan_project
  1. 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
  1. Create a minimal application file minimal_app.py in your project with the following content:
# minimal_app.py
from cullinan.application import run
from cullinan.controller import controller, get_api

@controller(url='/hello')
class HelloController:
    """Simple HTTP controller."""

    @get_api(url='')
    def hello(self):
        return {'message': 'Hello from Cullinan!'}

if __name__ == '__main__':
    run()
  1. 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.

Sample run output

The following log output illustrates a successful run of the example in a local environment (Windows PowerShell session). Timestamps and durations will vary between environments:

|||||||||||||||||||||||||||||||||||||||||||||||||
|||                                           |||
|||    _____      _ _ _                       |||
|||   / ____|    | | (_)                      |||
|||  | |    _   _| | |_ _ __   __ _ _ __      |||
|||  | |   | | | | | | | '_ \ / _` | '_ \     |||
|||  | |___| |_| | | | | | | | (_| | | | |    |||
|||   \_____\__,_|_|_|_|_| |_|\__,_|_| |_|    |||
|||                                           |||
|||||||||||||||||||||||||||||||||||||||||||||||||
    |||

2025-11-19 04:18:50,209 INFO cullinan.application: loading env...
2025-11-19 04:18:50,210 INFO cullinan.application: └---configuring dependency injection...
2025-11-19 04:18:50,210 INFO cullinan.application: └---dependency injection configured
2025-11-19 04:18:50,210 INFO cullinan.application: └---scanning services...
2025-11-19 04:18:50,210 INFO cullinan.application: ...
2025-11-19 04:18:50,223 INFO cullinan.application: └---found 31 modules to scan
2025-11-19 04:18:50,228 INFO cullinan.application: └---scanning controllers...
2025-11-19 04:18:50,260 INFO cullinan.application: └---found 31 modules to scan
2025-11-19 04:18:50,261 INFO cullinan.application: └---initializing services...
2025-11-19 04:18:50,261 INFO cullinan.application: └---no services registered
2025-11-19 04:18:50,261 INFO cullinan.application: └---loading controller finish

2025-11-19 04:18:50,261 INFO cullinan.application: loading env finish

2025-11-19 04:18:50,262 INFO cullinan.application: server is starting
2025-11-19 04:18:50,262 INFO cullinan.application: port is 4080

At this point, the server is running and listening on http://localhost:4080. Use Ctrl+C to gracefully stop the server.

Minimal application example

Here's a minimal Cullinan application that demonstrates the core framework features:

# minimal_app.py
from cullinan.application import run
from cullinan.controller import controller, get_api

@controller(url='/hello')
class HelloController:
    """Simple HTTP controller."""

    @get_api(url='')
    def hello(self):
        return {'message': 'Hello from Cullinan!'}

if __name__ == '__main__':
    run()

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

  1. Creation: create_app() initializes the application with default settings
  2. Registration: Controllers and services are discovered via module scanning or explicit registration
  3. Startup: app.run() starts the Tornado IOLoop and begins accepting requests
  4. Shutdown: Graceful shutdown on SIGINT/SIGTERM

Dependency Injection

Cullinan provides built-in IoC/DI support.

Decorators Explained: @injectable vs @controller()

@injectable - General-purpose dependency injection decorator - Applicable to any class that needs dependency injection (Service, Repository, utility classes, etc.) - Must be manually applied, does not auto-register to any registry - Automatically injects marked dependencies after class instantiation - Use cases: Service layer, Repository layer, utility classes, etc.

@controller() - Controller-specific auto-registration decorator - Specifically for HTTP Controller classes - Automatically applies @injectable, no need to add manually - Auto-registers Controller and its routes to ControllerRegistry - Auto-scans methods decorated with @get_api, @post_api, etc. - Use cases: HTTP Controllers only

from cullinan.controller import controller, get_api
from cullinan.service import Service, service
from cullinan.core import injectable, InjectByName
from cullinan.params import Path

# Service uses @service (inherits from Service base class)
@service
class UserService(Service):
    def get_user(self, user_id):
        return {'id': user_id, 'name': 'John'}

# Repository uses @injectable
@injectable
class UserRepository:
    def find_by_id(self, user_id):
        return {'id': user_id}

# Controller uses @controller() - automatically includes @injectable
@controller(url='/api/users')
class UserController:
    # Dependency injection in Controller
    user_service = InjectByName('UserService')

    @get_api(url='/{user_id}')
    async def get_user(self, user_id: int = Path()):
        return self.user_service.get_user(user_id)

Important: Do not use @injectable on Controllers, as @controller() already includes it automatically.


RESTful API decorators (quick overview)

Cullinan provides a set of REST-style decorators that bind controller methods to HTTP routes:

  • get_api
  • post_api
  • patch_api
  • delete_api
  • put_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 a TypeError at import time.
  • Always use the keyword form: @get_api(url='/user').
  • The url argument uses a lightweight template syntax with {param} placeholders.

v0.90+ Recommended: Type-Safe Parameter System

from cullinan.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 docs/wiki/restful_api.md.


Approach 1: InjectByName (Recommended, Simplest)

Inject by name without importing dependencies, avoiding circular import issues:

from cullinan.service import Service, service
from cullinan.core import injectable, InjectByName

@service
class DatabaseService(Service):
    def query(self, sql):
        return f"Results for: {sql}"

@injectable
class UserRepository:
    # Recommended: InjectByName doesn't need type annotation, just use string name
    db = InjectByName('DatabaseService')

    def get_users(self):
        return self.db.query("SELECT * FROM users")

Approach 2: 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 injectable, Inject
from cullinan.service import Service, service

# TYPE_CHECKING imports are not executed at runtime, avoiding circular imports
if TYPE_CHECKING:
    from cullinan.service import DatabaseService

@service
class DatabaseService(Service):
    def query(self, sql):
        return f"Results for: {sql}"

@injectable
class UserRepository:
    # With TYPE_CHECKING import, you get IDE autocomplete support
    db: 'DatabaseService' = Inject()

    def get_users(self):
        # IDE can suggest db.query method
        return self.db.query("SELECT * FROM users")

Approach 3: Inject + Pure String Annotation (No IDE autocomplete)

If you don't need IDE autocomplete, use string annotations directly:

from cullinan.core import injectable, Inject

@injectable
class UserRepository:
    # Pure string annotation, no import needed, but no IDE autocomplete
    db: 'DatabaseService' = Inject()

    def get_users(self):
        return self.db.query("SELECT * FROM users")

Summary: - InjectByName: Recommended for most cases, simple and straightforward, no type annotation needed - Inject + TYPE_CHECKING: Best for development experience, provides IDE autocomplete - Inject + String annotation: Simplest but no IDE support

For detailed information on injection patterns, see docs/wiki/injection.md.

Common patterns

Adding middleware

from cullinan.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.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 docs/wiki/injection.md for IoC/DI details.
  • Explore examples/ for runnable samples.

Additional resources

  • Architecture: See docs/architecture.md for system design overview
  • Components: Read docs/wiki/components.md for component responsibilities
  • Lifecycle: Learn about application lifecycle in docs/wiki/lifecycle.md
  • Middleware: Understand middleware in docs/wiki/middleware.md
  • API Reference: Browse docs/api_reference.md for complete API documentation
  • Examples: Explore examples/ directory for more samples

Community and support

  • Issues: Report bugs at GitHub Issues
  • Contributing: See docs/contributing.md for contribution guidelines
  • Testing: Read docs/testing.md for testing best practices