Skip to content

sharik709/lucid-framework

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lucid

A Python framework that tells you what to do next.

Django gives you an ORM and leaves you to figure out the rest. Flask gives you a route decorator and wishes you luck. FastAPI gives you type hints and a prayer. Every Python project ends up as a custom framework anyway — you just spend the first two weeks building it instead of building your product.

Lucid is the missing opinion layer. One install, one boot sequence, and you get dependency injection, configuration, event-driven architecture, and clean pipelines — all wired together and ready to go. You always know the next step because there's a preferred way to do everything.

pip install lucid-framework

PyPI version Python 3.10+ License: MIT


30 Seconds to Running

from lucid import Application

app = Application()

# Load config from .env and defaults
app.configure({
    "app": {"name": "my-project", "debug": True},
    "cache": {"driver": "memory"},
})

# Register your services
app.container.singleton(UserRepository, PostgresUserRepository)
app.container.singleton(PaymentGateway, StripeGateway)

# Register event listeners
app.events.listen(OrderCompleted, SendConfirmationEmail)
app.events.listen(OrderCompleted, UpdateInventory)

# Boot — everything wires itself
app.boot()

# Build any service — the entire dependency tree resolves automatically
service = app.make(OrderService)
service.process(order)

No setup scripts. No configuration classes that inherit from other configuration classes. No settings.py with 200 lines of os.environ.get(). You describe what you want, Lucid builds it.


What's Inside

One install gives you the full core:

lucid-framework
├── lucid-container    →  Dependency injection with autowiring
├── lucid-config       →  Cascading config with .env support and type casting
├── lucid-events       →  Event dispatcher with typed events and prioritized listeners
└── lucid-pipeline     →  Multi-step data processing chains

Each package works standalone. The framework ties them into a single coherent application lifecycle.


The Application

The Application class is the entry point. It creates the container, loads config, registers the event dispatcher, and boots your service providers — in the right order, every time.

Creating an application

from lucid import Application

app = Application()

This sets up:

  • A Container instance with the application itself bound as "app"
  • A Config instance bound as ConfigContract and aliased to "config"
  • A Dispatcher instance bound as DispatcherContract and aliased to "events"

app.configure(defaults, env_path=".env")

Loads configuration from defaults, .env files, and environment variables — in the right priority order.

app.configure(
    defaults={
        "app": {
            "name": "my-project",
            "debug": False,
            "env": "production",
            "secret_key": None,
        },
        "database": {
            "host": "localhost",
            "port": 5432,
            "name": "mydb",
        },
        "cache": {"driver": "memory"},
        "mail": {"driver": "log"},
    },
    env_path=".env",
)

This does, in order:

  1. Loads defaults as the base config.
  2. Loads .env if it exists.
  3. Loads .env.local if it exists (personal overrides, gitignored).
  4. Loads real environment variables (highest file-based priority).

After this, app.config is ready.

app.config

Shorthand for the config instance. Dot-notation access, type casting, the works.

app.config.get("app.name")             # "my-project"
app.config.boolean("app.debug")        # False
app.config.integer("database.port")    # 5432
app.config.get("app.env")              # "production" (or APP_ENV from environment)

app.container

Direct access to the container for binding services.

app.container.singleton(CacheContract, RedisCache)
app.container.bind(ReportGenerator, PDFReportGenerator)
app.container.instance("stripe_key", "sk_live_...")

app.events

Direct access to the event dispatcher.

app.events.listen(UserRegistered, SendWelcomeEmail)
app.events.listen(UserRegistered, CreateDefaultSettings, priority=10)

@app.events.listen(OrderCompleted)
def log_order(event):
    print(f"Order {event.order_id} completed")

app.make(abstract)

Shorthand for app.container.make(). Resolves any class or binding from the container.

service = app.make(OrderService)
config = app.make("config")
events = app.make("events")

app.boot()

Finalizes the application. Calls boot() on all registered service providers, freezes config (optional), and dispatches AppBooted event.

app.boot()

After boot:

  • All service providers have registered and booted.
  • The container is fully wired.
  • AppBooted event has fired.
  • The application is ready to handle requests/tasks.

app.is_booted

Property that returns True after boot() has been called.


Service Providers

Service providers are how you organize your application's bindings and boot logic. Each provider is responsible for one subsystem.

Writing a provider

from lucid import ServiceProvider

class DatabaseServiceProvider(ServiceProvider):

    def register(self):
        """Bind things into the container. No resolving here."""
        self.app.container.singleton(DatabaseContract, lambda c: PostgresDatabase(
            host=c.make("config").get("database.host"),
            port=c.make("config").integer("database.port"),
            name=c.make("config").get("database.name"),
        ))

    def boot(self):
        """All providers have registered. Safe to resolve and interact."""
        db = self.app.make(DatabaseContract)
        db.connect()

Registering providers

app = Application()
app.configure(defaults)

# Register providers — register() called immediately on each
app.register(DatabaseServiceProvider)
app.register(CacheServiceProvider)
app.register(MailServiceProvider)

# Boot — boot() called on all providers in order
app.boot()

Provider lifecycle

app.register(P)  →  P(app) instantiated  →  P.register() called
app.register(Q)  →  Q(app) instantiated  →  Q.register() called
app.register(R)  →  R(app) instantiated  →  R.register() called
app.boot()        →  P.boot() → Q.boot() → R.boot() → AppBooted dispatched

register() runs immediately when you call app.register(). All bindings are available by the time boot() runs. This means providers can depend on each other's bindings during boot().


Application Events

The framework fires lifecycle events you can hook into.

Event When Payload
AppBooting Just before boot() runs providers app
AppBooted After all providers have booted app
from lucid.events import AppBooted

@app.events.listen(AppBooted)
def on_ready(event):
    print(f"{event.app.config.get('app.name')} is ready!")

Using Pipelines

Pipelines are available standalone — no special integration needed. But they shine when combined with the container.

from lucid import Pipeline, Pipe

class ValidateRequest(Pipe):
    def __init__(self, config: ConfigContract):
        self.config = config

    def handle(self, data, next_pipe):
        if not data.get("api_key"):
            return {"error": "Missing API key"}
        if data["api_key"] != self.config.get("app.api_key"):
            return {"error": "Invalid API key"}
        return next_pipe(data)

class NormalizeData(Pipe):
    def handle(self, data, next_pipe):
        data["email"] = data.get("email", "").lower().strip()
        return next_pipe(data)

# Resolve pipe instances through the container — dependencies autowired
result = (
    Pipeline(request_data)
    .through([
        app.make(ValidateRequest),
        NormalizeData(),
        lambda data: {**data, "processed": True},
    ])
    .then(save_to_database)
)

Real-World Examples

API service

from lucid import Application, ServiceProvider

# ── Config ──
defaults = {
    "app": {"name": "order-api", "debug": False, "secret_key": None},
    "database": {"host": "localhost", "port": 5432, "name": "orders"},
    "cache": {"driver": "memory"},
    "mail": {"driver": "log"},
}

# ── Providers ──
class RepositoryProvider(ServiceProvider):
    def register(self):
        self.app.container.singleton(UserRepository, PostgresUserRepository)
        self.app.container.singleton(OrderRepository, PostgresOrderRepository)

class PaymentProvider(ServiceProvider):
    def register(self):
        self.app.container.singleton(PaymentGateway, lambda c: StripeGateway(
            api_key=c.make("config").get("stripe.secret_key"),
        ))

class EventListenerProvider(ServiceProvider):
    def boot(self):
        events = self.app.events
        events.listen(OrderCompleted, SendConfirmationEmail)
        events.listen(OrderCompleted, UpdateInventory, priority=20)
        events.listen(PaymentFailed, NotifySupport)
        events.listen(UserRegistered, SendWelcomeEmail)
        events.listen(UserRegistered, CreateDefaultSettings, priority=10)

# ── Bootstrap ──
app = Application()
app.configure(defaults)

app.register(RepositoryProvider)
app.register(PaymentProvider)
app.register(EventListenerProvider)

app.boot()

# ── Use ──
order_service = app.make(OrderService)
order_service.process(incoming_order)

CLI tool

from lucid import Application

app = Application()
app.configure({
    "app": {"name": "data-migrator"},
    "source_db": {"host": "old-db.internal", "port": 5432},
    "target_db": {"host": "new-db.internal", "port": 5432},
})
app.boot()

migrator = app.make(DataMigrator)
migrator.run()

Testing

def create_test_app(**config_overrides):
    """Create a fresh application with test doubles."""
    app = Application()

    defaults = {
        "app": {"name": "test", "debug": True, "secret_key": "test-secret"},
        "database": {"host": "localhost", "name": "test_db"},
        "mail": {"driver": "log"},
    }
    defaults.update(config_overrides)
    app.configure(defaults)

    # Swap real implementations for test doubles
    app.container.instance(MailerContract, FakeMailer())
    app.container.instance(PaymentGateway, FakePaymentGateway(always_succeeds=True))
    app.container.instance(CacheContract, InMemoryCache())

    app.boot()
    return app


def test_order_completion():
    app = create_test_app()
    service = app.make(OrderService)

    result = service.process(test_order)

    assert result.status == "completed"
    assert app.make(MailerContract).last_sent.subject == "Order Confirmed"


def test_order_with_failed_payment():
    app = create_test_app()
    app.container.instance(PaymentGateway, FakePaymentGateway(always_fails=True))

    service = app.make(OrderService)
    result = service.process(test_order)

    assert result.status == "payment_failed"

Every test gets a clean application with isolated state. No global singletons, no monkeypatching, no test ordering issues.

Integration with web frameworks

Lucid doesn't replace your web framework — it runs alongside it.

With FastAPI:

from fastapi import FastAPI, Depends
from lucid import Application

# Bootstrap Lucid
lucid = Application()
lucid.configure(defaults)
lucid.register(DatabaseProvider)
lucid.register(CacheProvider)
lucid.boot()

# FastAPI app
api = FastAPI()

def get_lucid():
    return lucid

@api.post("/orders")
def create_order(data: OrderRequest, app: Application = Depends(get_lucid)):
    service = app.make(OrderService)
    return service.process(data)

With Flask:

from flask import Flask

flask_app = Flask(__name__)

lucid = Application()
lucid.configure(defaults)
lucid.boot()

@flask_app.route("/orders", methods=["POST"])
def create_order():
    service = lucid.make(OrderService)
    return service.process(request.json)

Lucid manages your services, config, and events. The web framework manages HTTP. They compose cleanly without either one taking over.


Directory Conventions

Lucid recommends (but doesn't enforce) this project structure:

my-project/
├── app/
│   ├── __init__.py
│   ├── services/              # Business logic
│   │   ├── order_service.py
│   │   ├── user_service.py
│   │   └── payment_service.py
│   ├── repositories/          # Data access
│   │   ├── user_repository.py
│   │   └── order_repository.py
│   ├── events/                # Event classes
│   │   ├── order_events.py
│   │   └── user_events.py
│   ├── listeners/             # Event listeners
│   │   ├── send_welcome_email.py
│   │   └── update_inventory.py
│   ├── contracts/             # ABCs / interfaces
│   │   ├── cache_contract.py
│   │   ├── mailer_contract.py
│   │   └── payment_contract.py
│   └── providers/             # Service providers
│       ├── database_provider.py
│       ├── cache_provider.py
│       └── mail_provider.py
├── config/
│   └── defaults.py            # Default configuration dict
├── .env                       # Environment defaults
├── .env.local                 # Personal overrides (gitignored)
├── bootstrap.py               # Application setup
├── main.py                    # Entry point
└── tests/
    ├── conftest.py            # Test app factory
    └── ...

bootstrap.py:

from lucid import Application
from config.defaults import defaults
from app.providers.database_provider import DatabaseProvider
from app.providers.cache_provider import CacheProvider
from app.providers.mail_provider import MailProvider

def create_app() -> Application:
    app = Application()
    app.configure(defaults)

    app.register(DatabaseProvider)
    app.register(CacheProvider)
    app.register(MailProvider)

    app.boot()
    return app

main.py:

from bootstrap import create_app

app = create_app()

# Your application logic starts here

This is a convention, not a requirement. Lucid works with any project structure — it's your container, your config, your events. Organize them however you want.


Architecture

Project Structure

lucid-framework/
├── src/
│   └── lucid/
│       ├── __init__.py            # Public API — re-exports everything
│       ├── application.py         # Application class
│       ├── events/
│       │   ├── __init__.py
│       │   ├── app_booting.py     # AppBooting event
│       │   └── app_booted.py      # AppBooted event
│       └── service_provider.py    # ServiceProvider base (re-exported or extended)
├── tests/
│   ├── __init__.py
│   ├── test_application.py        # Application lifecycle
│   ├── test_configure.py          # Config loading through Application
│   ├── test_providers.py          # Provider registration and boot
│   ├── test_events.py             # Lifecycle events
│   ├── test_make.py               # Container resolution through app
│   └── test_integration.py        # Full stack integration tests
├── pyproject.toml
├── README.md
├── LICENSE
└── CHANGELOG.md

Implementation Notes

The Application class internals:

from lucid_container import Container
from lucid_config import Config, ConfigContract
from lucid_events import Dispatcher, DispatcherContract

class Application:
    def __init__(self):
        self._container = Container()
        self._config = Config()
        self._dispatcher = Dispatcher(container=self._container)
        self._providers: list[ServiceProvider] = []
        self._booted = False

        # Bind core services
        self._container.instance("app", self)
        self._container.instance(ConfigContract, self._config)
        self._container.alias("config", ConfigContract)
        self._container.instance(DispatcherContract, self._dispatcher)
        self._container.alias("events", DispatcherContract)

    @property
    def container(self) -> Container:
        return self._container

    @property
    def config(self) -> Config:
        return self._config

    @property
    def events(self) -> Dispatcher:
        return self._dispatcher

    @property
    def is_booted(self) -> bool:
        return self._booted

    def configure(self, defaults: dict, env_path: str = ".env"):
        self._config.load_dict(defaults)
        self._config.load_env(env_path)
        self._config.load_env(f"{env_path}.local")
        self._config.load_env_vars()

    def register(self, provider_class: type):
        provider = provider_class(self)
        self._providers.append(provider)
        provider.register()

    def make(self, abstract):
        return self._container.make(abstract)

    def boot(self):
        if self._booted:
            return

        self._dispatcher.dispatch(AppBooting(self))

        for provider in self._providers:
            provider.boot()

        self._booted = True
        self._dispatcher.dispatch(AppBooted(self))

What configure() does with missing files:

If .env or .env.local doesn't exist, load_env() silently skips it (no FileNotFoundError). This means configure() always works — in development with a .env file and in production with only real environment variables.

Re-exports in __init__.py:

The framework re-exports the most common classes so users only need one import:

# lucid/__init__.py
from lucid.application import Application
from lucid.service_provider import ServiceProvider
from lucid.events.app_booting import AppBooting
from lucid.events.app_booted import AppBooted

# Re-export from sub-packages for convenience
from lucid_container import Container
from lucid_config import Config, ConfigContract
from lucid_events import Event, Dispatcher, DispatcherContract, Listener, AsyncListener, Subscriber
from lucid_pipeline import Pipeline, AsyncPipeline, Pipe, AsyncPipe

__all__ = [
    # Framework
    "Application",
    "ServiceProvider",
    "AppBooting",
    "AppBooted",
    # Container
    "Container",
    # Config
    "Config",
    "ConfigContract",
    # Events
    "Event",
    "Dispatcher",
    "DispatcherContract",
    "Listener",
    "AsyncListener",
    "Subscriber",
    # Pipeline
    "Pipeline",
    "AsyncPipeline",
    "Pipe",
    "AsyncPipe",
]

This means users can write from lucid import Application, Event, Listener, Pipeline — one import line for everything.

ServiceProvider base class:

The framework either re-exports ServiceProvider from lucid_container or provides its own thin wrapper that adds the self.app property:

class ServiceProvider:
    def __init__(self, app: "Application"):
        self.app = app

    def register(self) -> None:
        """Bind things into the container."""
        pass

    def boot(self) -> None:
        """Called after all providers have registered."""
        pass

Public API

from lucid import Application           # The app
from lucid import ServiceProvider        # Base for providers
from lucid import AppBooting, AppBooted  # Lifecycle events

# Everything from sub-packages, re-exported:
from lucid import Container              # DI container
from lucid import Config, ConfigContract  # Configuration
from lucid import Event, Dispatcher, DispatcherContract, Listener, AsyncListener, Subscriber  # Events
from lucid import Pipeline, AsyncPipeline, Pipe, AsyncPipe  # Pipelines

pyproject.toml Specification

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "lucid-framework"
version = "0.1.0"
description = "A Python framework that tells you what to do next."
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
    { name = "Sharik Shaikh", email = "shaikhsharik709@gmail.com" },
]
keywords = [
    "framework", "dependency-injection", "config", "events",
    "pipeline", "service-provider", "ioc", "convention",
]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Topic :: Software Development :: Libraries :: Application Frameworks",
    "Topic :: Software Development :: Libraries :: Python Modules",
    "Typing :: Typed",
]
dependencies = [
    "lucid-pipeline>=0.1.0",
    "lucid-container>=0.1.0",
    "lucid-config>=0.1.0",
    "lucid-events>=0.1.0",
]

[project.urls]
Homepage = "https://github.com/sharik709/lucid-framework"
Documentation = "https://github.com/sharik709/lucid-framework#readme"
Repository = "https://github.com/sharik709/lucid-framework"
Issues = "https://github.com/sharik709/lucid-framework/issues"

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

[tool.mypy]
strict = true

[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "mypy>=1.0", "ruff>=0.1"]

Test Cases to Implement

Application Lifecycle

  • Application() creates container, config, and dispatcher
  • app.container returns a Container instance
  • app.config returns a Config instance
  • app.events returns a Dispatcher instance
  • app.is_booted is False before boot, True after
  • app.boot() called twice does nothing the second time

Configure

  • app.configure(defaults) loads defaults into config
  • Config values accessible via app.config.get()
  • Environment variables override defaults
  • Missing .env file doesn't raise
  • Missing .env.local file doesn't raise
  • Type casting works after configure (boolean, integer, etc.)

Container Access

  • app.make(SomeClass) resolves from container
  • app.make("config") returns the config (alias works)
  • app.make("events") returns the dispatcher (alias works)
  • app.make("app") returns the application itself
  • app.container.singleton() bindings work through app.make()
  • app.container.bind() bindings work through app.make()

Service Providers

  • app.register(P) instantiates P with the app
  • app.register(P) calls P.register() immediately
  • Provider's self.app is the application instance
  • Provider can bind into self.app.container during register()
  • Provider can read from self.app.config during register()
  • app.boot() calls boot() on all registered providers
  • boot() is called in registration order
  • Provider boot can resolve bindings from other providers
  • Provider boot can access the event dispatcher
  • Multiple providers register without conflicts

Lifecycle Events

  • AppBooting is dispatched at the start of boot()
  • AppBooted is dispatched after all providers have booted
  • AppBooting event carries the app reference
  • AppBooted event carries the app reference
  • Listeners registered before boot() receive the events
  • AppBooting fires before any provider boot() is called
  • AppBooted fires after all provider boot() calls complete

Re-exports

  • from lucid import Application works
  • from lucid import ServiceProvider works
  • from lucid import Container works
  • from lucid import Config, ConfigContract works
  • from lucid import Event, Dispatcher, Listener, Subscriber works
  • from lucid import Pipeline, Pipe works
  • from lucid import AppBooting, AppBooted works

Integration Tests

  • Full stack: configure → register providers → boot → make service → use service
  • Provider registers binding, another provider resolves it during boot
  • Event listener registered in a provider fires when event dispatched
  • Config loaded in configure, read by provider during register
  • Container autowires a service whose dependencies were bound by providers
  • Pipeline used inside a service that was resolved from the container
  • Test double swap: instance() overrides a singleton for testing

Edge Cases

  • Application with no providers — boot succeeds
  • Application with no config — boot succeeds (empty config)
  • Registering a provider after boot raises error (or is silently ignored)
  • make() before boot() works for bindings registered during register()
  • Provider register() raising an exception surfaces clearly
  • Provider boot() raising an exception surfaces clearly
  • Two providers binding the same abstract — last one wins

The Lucid Ecosystem

┌──────────────────────────────────────────────────────────┐
│                    lucid-framework                        │
│                                                          │
│   Application  ·  ServiceProvider  ·  Lifecycle Events   │
│                                                          │
├──────────┬──────────┬──────────────┬─────────────────────┤
│ lucid-   │ lucid-   │ lucid-       │ lucid-              │
│ container│ config   │ events       │ pipeline            │
│          │          │              │                     │
│ DI +     │ .env +   │ Typed events │ Multi-step          │
│ autowire │ dot      │ + listeners  │ data chains         │
│          │ notation │ + subscribers│                     │
└──────────┴──────────┴──────────────┴─────────────────────┘
                         ▼
              Feature packages (coming soon)
         lucid-cache  ·  lucid-mail  ·  lucid-queue

Each box is an independent PyPI package. Install the framework to get everything, or install only what you need.


Coming Soon

  • lucid-cache — Multi-driver cache (memory, file, Redis) with a unified API.
  • lucid-mail — Multi-driver mail (SMTP, Mailgun, SES) with templates and queued sending.
  • lucid-queue — Background job processing with swappable backends.
  • lucid-cli — Artisan-style CLI for code generation, migrations, and task scheduling.

License

MIT License. See LICENSE for details.

About

A Python framework that tells you what to do next. Django gives you an ORM and leaves you to figure out the rest. Flask gives you a route decorator and wishes you luck. FastAPI gives you type hints and a prayer. Every Python project ends up as a custom framework anyway you just spend the first two weeks building it instead of building your product.

Resources

Stars

Watchers

Forks

Contributors

Languages