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-frameworkfrom 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.
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 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.
from lucid import Application
app = Application()This sets up:
- A
Containerinstance with the application itself bound as"app" - A
Configinstance bound asConfigContractand aliased to"config" - A
Dispatcherinstance bound asDispatcherContractand aliased to"events"
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:
- Loads
defaultsas the base config. - Loads
.envif it exists. - Loads
.env.localif it exists (personal overrides, gitignored). - Loads real environment variables (highest file-based priority).
After this, app.config is ready.
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)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_...")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")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")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.
AppBootedevent has fired.- The application is ready to handle requests/tasks.
Property that returns True after boot() has been called.
Service providers are how you organize your application's bindings and boot logic. Each provider is responsible for one subsystem.
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()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()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().
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!")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)
)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)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()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.
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.
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 appmain.py:
from bootstrap import create_app
app = create_app()
# Your application logic starts hereThis 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.
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
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."""
passfrom 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[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"]Application()creates container, config, and dispatcherapp.containerreturns a Container instanceapp.configreturns a Config instanceapp.eventsreturns a Dispatcher instanceapp.is_bootedisFalsebefore boot,Trueafterapp.boot()called twice does nothing the second time
app.configure(defaults)loads defaults into config- Config values accessible via
app.config.get() - Environment variables override defaults
- Missing
.envfile doesn't raise - Missing
.env.localfile doesn't raise - Type casting works after configure (
boolean,integer, etc.)
app.make(SomeClass)resolves from containerapp.make("config")returns the config (alias works)app.make("events")returns the dispatcher (alias works)app.make("app")returns the application itselfapp.container.singleton()bindings work throughapp.make()app.container.bind()bindings work throughapp.make()
app.register(P)instantiates P with the appapp.register(P)callsP.register()immediately- Provider's
self.appis the application instance - Provider can bind into
self.app.containerduringregister() - Provider can read from
self.app.configduringregister() app.boot()callsboot()on all registered providersboot()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
AppBootingis dispatched at the start ofboot()AppBootedis dispatched after all providers have bootedAppBootingevent carries the app referenceAppBootedevent carries the app reference- Listeners registered before
boot()receive the events AppBootingfires before any providerboot()is calledAppBootedfires after all providerboot()calls complete
from lucid import Applicationworksfrom lucid import ServiceProviderworksfrom lucid import Containerworksfrom lucid import Config, ConfigContractworksfrom lucid import Event, Dispatcher, Listener, Subscriberworksfrom lucid import Pipeline, Pipeworksfrom lucid import AppBooting, AppBootedworks
- 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
- 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()beforeboot()works for bindings registered duringregister()- Provider
register()raising an exception surfaces clearly - Provider
boot()raising an exception surfaces clearly - Two providers binding the same abstract — last one wins
┌──────────────────────────────────────────────────────────┐
│ 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.
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.
MIT License. See LICENSE for details.