Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@

from __future__ import annotations

from background_tasks.celery_app import build_celery
from background_tasks.settings import BackgroundTasksSettings
from pathlib import Path

from simple_module_core.dotenv import load_dotenv_into_environ

# Match the precedence uvicorn gives the web process: load ``.env`` into the
# environment before importing settings, so the worker doesn't fall back to
# SQLite defaults when celery is launched outside the host's cwd.
load_dotenv_into_environ(Path(__file__).resolve().parent.parent / ".env")

from background_tasks.celery_app import build_celery # noqa: E402
from background_tasks.settings import BackgroundTasksSettings # noqa: E402

celery = build_celery(BackgroundTasksSettings())
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,21 @@ if (fs.existsSync(manifestPath)) {
export default defineConfig({
plugins: [react(), tailwindcss()],
root: __dirname,
// Pre-bundle the use-sync-external-store CJS shim so its named export
// resolves under ESM. recharts (and anything else pulling in react-redux)
// breaks without this.
// Force every importer to resolve to one React copy — without it,
// plugin-react's Fast Refresh preamble check fires in a realm where its
// global was never set ("can't detect preamble").
resolve: {
dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
},
// ``use-sync-external-store`` is the CJS shim recharts/react-redux pull
// in; pre-bundling resolves its named export under ESM.
optimizeDeps: {
include: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'react/jsx-dev-runtime',
'use-sync-external-store',
'use-sync-external-store/shim',
'use-sync-external-store/shim/with-selector',
Expand Down
Empty file.
6 changes: 5 additions & 1 deletion framework/cli/tests/test_scaffolding_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,17 @@ async def test_creates_expected_module_files(self, tmp_path):
"my_feature/module.py",
"my_feature/endpoints/__init__.py",
"my_feature/endpoints/api.py",
"tests/__init__.py",
"tests/test_module.py",
".gitignore",
"README.md",
]:
assert (dest / relpath).is_file(), f"missing: {relpath}"

# `tests/__init__.py` is intentionally NOT shipped: under
# `--import-mode=importlib`, having one in two modules makes
# pytest try to register `tests.conftest` as a plugin twice.
assert not (dest / "tests" / "__init__.py").exists()

async def test_pyproject_declares_entry_point_and_deps(self, tmp_path):
"""pyproject.toml sets the entry_point and pins the framework API range."""
from simple_module_cli.scaffolding import create_module
Expand Down
33 changes: 32 additions & 1 deletion framework/core/simple_module_core/dotenv.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Minimal ``.env`` parser — dependency-free.
"""Minimal ``.env`` parser + env-var helpers — dependency-free.

Used in places that can't or shouldn't pull in ``pydantic-settings`` (the
diagnostics CLI runs before the host package is imported; the users-module
Expand All @@ -11,6 +11,9 @@
import os
from pathlib import Path

BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})


def parse_dotenv(path: Path | None = None) -> dict[str, str]:
"""Parse a ``.env`` file into a dict. Empty dict if the file is missing.
Expand All @@ -36,3 +39,31 @@ def parse_dotenv(path: Path | None = None) -> dict[str, str]:
key, _, value = line.partition("=")
parsed[key.strip()] = value.strip().strip('"').strip("'")
return parsed


def load_dotenv_into_environ(path: Path | None = None) -> None:
"""Merge ``parse_dotenv(path)`` into ``os.environ`` via ``setdefault``.

Same precedence as the web process under uvicorn: real environment wins
over file values. Worker entrypoints call this before importing settings.
"""
for key, value in parse_dotenv(path).items():
os.environ.setdefault(key, value)


def env_str(name: str, default: str) -> str:
"""Return ``$name`` if set and non-empty, else ``default``."""
value = os.environ.get(name, "").strip()
return value or default


def env_bool(name: str, default: bool = False) -> bool:
"""Parse ``$name`` as a boolean, returning ``default`` when unset/blank."""
raw = os.environ.get(name, "").strip().lower()
if not raw:
return default
if raw in BOOL_LITERALS_TRUE:
return True
if raw in BOOL_LITERALS_FALSE:
return False
return default
9 changes: 7 additions & 2 deletions framework/core/simple_module_core/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,13 @@ def register_permissions(self, registry: PermissionRegistry) -> None:
def register_feature_flags(self, registry: FeatureFlagRegistry) -> None:
"""Declare feature flags this module exposes."""

def register_event_handlers(self, bus: EventBus) -> None:
"""Subscribe to events published by other modules."""
def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
"""Subscribe to events published by other modules.

``app`` is optional for back-compat; pass it through to handlers
that need ``app.state.sm.db.session_factory`` to persist on the
framework's engine instead of building their own.
"""

def register_health_checks(self, registry: HealthRegistry) -> None:
"""Contribute health checks for the ``/health/ready`` endpoint."""
Expand Down
26 changes: 21 additions & 5 deletions framework/db/simple_module_db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os

from simple_module_core.dotenv import env_bool
from sqlalchemy import MetaData
from sqlmodel import SQLModel

Expand Down Expand Up @@ -39,13 +40,28 @@ def _register_base(base: type[SQLModel]) -> None:


def _default_provider() -> DatabaseProvider:
"""Resolve the active provider from ``SM_DATABASE_URL`` at import time.

Falls back to SQLite when the variable is unset, keeping the common
dev-loop happy without requiring callers to plumb the provider through.
"""Resolve the schema layout to register module tables under.

Drives whether module tables get a dedicated Postgres schema
(``orders.<table>``) or share the public schema with prefixed names
(``orders_<table>``). ``SM_SCHEMA_PER_MODULE`` is authoritative when
set; the ``SM_DATABASE_URL`` fallback is kept for back-compat with
deployments that haven't migrated to the explicit knob — workers and
the web process can disagree on whether the URL is set, so prefer
the explicit form going forward.
"""
explicit = os.environ.get("SM_SCHEMA_PER_MODULE")
if explicit is not None:
return (
DatabaseProvider.POSTGRESQL
if env_bool("SM_SCHEMA_PER_MODULE")
else DatabaseProvider.SQLITE
)

url = os.environ.get("SM_DATABASE_URL", "")
return detect_provider(url) if url else DatabaseProvider.SQLITE
if url:
return detect_provider(url)
return DatabaseProvider.SQLITE


def create_module_base(
Expand Down
13 changes: 9 additions & 4 deletions framework/db/simple_module_db/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,27 @@ def init_db(
max_overflow: int = 20,
pool_pre_ping: bool = True,
pool_recycle: int = 1800,
poolclass: type | None = None,
) -> DatabaseState:
"""Create an async engine and session factory.

The pool options only take effect for server-side providers (Postgres).
SQLite uses SQLAlchemy's default pool (single-file, no network), so
passing ``pool_size``/etc. would raise ``TypeError`` — skipped below.
Pool tuning only applies to server-side providers (Postgres) — SQLite
rejects ``pool_size``/etc. Pass ``poolclass=NullPool`` from test
fixtures running against asyncpg/Postgres so pytest-asyncio's per-test
event loops don't outlive pooled connections; the pool-tuning kwargs
are ignored in that case.

Returns a ``DatabaseState`` that should be stored on ``app.state.db``.
"""
provider = detect_provider(database_url)

connect_args: dict = {}
engine_kwargs: dict = {"echo": echo, "connect_args": connect_args}
if poolclass is not None:
engine_kwargs["poolclass"] = poolclass
if provider == DatabaseProvider.SQLITE:
connect_args["check_same_thread"] = False
else:
elif poolclass is None:
engine_kwargs.update(
pool_size=pool_size,
max_overflow=max_overflow,
Expand Down
19 changes: 18 additions & 1 deletion framework/hosting/simple_module_hosting/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import inspect
import logging
import os
from collections.abc import AsyncGenerator
Expand Down Expand Up @@ -75,6 +76,22 @@ def _resolve_project_root() -> Path:
_PROJECT_ROOT = _resolve_project_root()


def _register_event_handlers(mod, event_bus: EventBus, app: FastAPI) -> None:
"""Dispatch to ``mod.register_event_handlers`` with or without ``app``.

Back-compat shim for modules that still override the one-arg form
``(self, bus)``; passing ``app=`` to those crashes.
"""
sig = inspect.signature(mod.register_event_handlers)
accepts_app = "app" in sig.parameters or any(
p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
)
if accepts_app:
mod.register_event_handlers(event_bus, app=app)
else:
mod.register_event_handlers(event_bus)


def create_app(settings: Settings | None = None) -> FastAPI:
"""Build and configure the full FastAPI application.

Expand Down Expand Up @@ -218,7 +235,7 @@ class _HostServices:
mod.register_menu_items(menu_registry)
mod.register_permissions(perm_registry)
mod.register_feature_flags(ff_registry)
mod.register_event_handlers(event_bus)
_register_event_handlers(mod, event_bus, app)
mod.register_health_checks(health_registry)

logger.info(
Expand Down
7 changes: 7 additions & 0 deletions framework/hosting/simple_module_hosting/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ class SecurityHeadersMiddleware:
"img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com data:; "
"connect-src 'self'; "
# Allow blob: workers — MapLibre, comlink, web-tree-sitter and most
# WASM libs ship their worker as a Blob URL. `child-src` is the
# legacy fallback some browsers consult before `worker-src`.
"worker-src 'self' blob:; "
"child-src 'self' blob:; "
"frame-ancestors 'self'; "
"base-uri 'self'; "
"form-action 'self'"
Expand All @@ -104,6 +109,8 @@ def dev_csp(vite_dev_url: str) -> str:
"img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com data:; "
f"connect-src 'self' {vite_dev_url} {ws_url}; "
"worker-src 'self' blob:; "
"child-src 'self' blob:; "
"frame-ancestors 'self'; "
"base-uri 'self'; "
"form-action 'self'"
Expand Down
20 changes: 20 additions & 0 deletions framework/testing/simple_module_test/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,32 @@

from __future__ import annotations

import contextlib

import pytest

from simple_module_test.app_factory import build_test_app as _build_test_app
from simple_module_test.fake_events import FakeEventBus


def _bootstrap_eager_celery() -> None:
"""Register a process-wide eager Celery app before any test runs.

Tests that don't use the ``client`` fixture never trigger the lifespan,
so the host's ``build_celery`` call never runs and ``task.delay()``
falls through to the broker. Skipped silently when ``background_tasks``
isn't installed.
"""
with contextlib.suppress(ImportError):
from background_tasks.celery_app import build_celery
from background_tasks.settings import BackgroundTasksSettings

build_celery(BackgroundTasksSettings(task_always_eager=True, task_eager_propagates=True))


_bootstrap_eager_celery()


@pytest.fixture
def fake_event_bus() -> FakeEventBus:
"""Fresh recording EventBus for each test."""
Expand Down
14 changes: 14 additions & 0 deletions host/client_app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ export default defineConfig({
// feed Vite the per-module tsconfigs.
resolve: {
tsconfigPaths: true,
// Force every importer (host, workspace module, wheel-installed module)
// to resolve to one React copy — without it, plugin-react's Fast
// Refresh preamble check fires in a realm where its global was never
// set ("can't detect preamble").
dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
},
optimizeDeps: {
include: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'react/jsx-dev-runtime',
],
},
root: __dirname,
build: {
Expand Down
5 changes: 5 additions & 0 deletions modules/background_tasks/background_tasks/celery_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ def build_celery(settings: BackgroundTasksSettings) -> Celery:
broker_url=settings.broker_url,
result_backend=settings.result_backend,
task_default_queue=settings.task_default_queue,
# Run tasks synchronously inside the calling process. Tests
# toggle this on (via ``SM_BG_TASKS_TASK_ALWAYS_EAGER=true``) so
# ``task.delay()`` doesn't reach for a real broker.
task_always_eager=settings.task_always_eager,
task_eager_propagates=settings.task_eager_propagates,
# ``task_track_started`` gives us the ``STARTED`` state so
# ``task_prerun`` can flip our row to ``running``.
task_track_started=True,
Expand Down
8 changes: 8 additions & 0 deletions modules/background_tasks/background_tasks/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from simple_module_core.dotenv import env_bool
from simple_module_core.environments import NON_PROD_ENVIRONMENTS

from background_tasks.constants import (
Expand All @@ -47,6 +48,13 @@ class BackgroundTasksSettings(BaseSettings):
result_backend: str = Field(default=DEFAULT_RESULT_BACKEND, json_schema_extra=_CELERY_RESTART)
task_default_queue: str = Field(default=DEFAULT_QUEUE, json_schema_extra=_CELERY_RESTART)

# Run tasks synchronously inside the calling process. Read at
# module-import time so tests can flip it on via ``SM_BG_TASKS_*``
# without going through DB-backed hydration (which never fires for
# suites that don't use the FastAPI lifespan).
task_always_eager: bool = env_bool("SM_BG_TASKS_TASK_ALWAYS_EAGER")
task_eager_propagates: bool = True

# A task that has been ``running`` longer than this without a heartbeat is
# flipped to ``stuck`` by the beat sweep. 5 min is long enough to cover
# normal slow jobs while still surfacing wedged workers within one UI
Expand Down
6 changes: 5 additions & 1 deletion modules/users/users/endpoints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse:
dev_accounts.append({"label": "User", "email": user_email, "password": user_password})
return await inertia.render(
_PAGE_LOGIN,
{"allow_signup": users_settings.allow_signup, "dev_accounts": dev_accounts},
{
"allow_signup": users_settings.allow_signup,
"dev_accounts": dev_accounts,
"login_redirect_url": users_settings.login_redirect_url,
},
)


Expand Down
8 changes: 8 additions & 0 deletions modules/users/users/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ async def on_startup(self, app: FastAPI) -> None:
window_seconds=s.auth_rate_limit_window_seconds,
)

# Auto-fall-back from the default ``/dashboard/`` to ``/`` when the
# Dashboard module isn't installed, so ``--preset minimal`` doesn't
# 404 on login. Operator-set overrides are preserved.
if s.login_redirect_url == "/dashboard/" and not any(
m.meta.name == "Dashboard" for m in app.state.sm.modules
):
s.login_redirect_url = "/"

reconfigure_cookie_transport(auth_backend, s)

await asyncio.gather(
Expand Down
Loading
Loading