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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ All notable changes to this project are documented in this file. The format is b
- `api_tokens` follows the module layout: `api_tokens.core.{constants,settings,services,tenant_cleanup,user_cleanup}`, `api_tokens.credentials.{format,scopes,owner,service,resolver,audit}` (`tokens.py` is now `credentials/format.py`), `api_tokens.web.{deps,endpoints}`, and the page components and types move to `ui/`. `api_tokens.{constants,service,resolver,tokens}` still import, with a `DeprecationWarning`, until 0.0.46.
- `branding` follows the module layout: `branding.core.{constants,settings,services,service,presets,tenant_definitions,tenant_branding}`, `branding.assets.{images,reaper}`, `branding.web.{deps,shared_props,endpoints}`, and the form components move from `components/` to `ui/components/`. `branding.{constants,settings,services,tenant_branding,images}` and `branding.endpoints.assets` still import, with a `DeprecationWarning`, until 0.0.46.
- `permissions` follows the module layout: `permissions.core.{constants,service,group_labels,write_safety}`, `permissions.grants.{source,role_invalidation,user_cleanup}` (`grants.py` is now `grants/source.py`), `permissions.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `permissions.deps` and `permissions.group_labels` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler name `permissions.user_cleanup.purge_user_grants` is unchanged.
- `settings` follows the module layout: `settings.core.{constants,settings,services,errors,exceptions,env_vars,service,invalidation,tenant_scope,tenant_view,...}`, `settings.module_settings.{discovery,props,store,reload}` (`_module_settings.py` is now `module_settings/discovery.py`, `_module_settings_props.py` is `module_settings/props.py`), `settings.web.{deps,browse_query,known_keys,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. `settings.service`, `settings.reload`, `settings.deps`, `settings.tenant_scope`, `settings.tenant_view` and `settings.endpoints.module_api` still import, with a `DeprecationWarning`, until 0.0.46, and `settings.store`, `settings.constants` and `settings._secrets` until 0.1.0 (the release their re-exports were already promised for); `settings.errors`, `settings.exceptions`, `settings.services`, `settings.settings`, `settings.env_vars`, `settings.invalidation`, `settings.known_keys`, `settings.browse_query` and `settings.endpoints.{api,views,tenant_api}` moved with no alias. The deprecated `settings.hydrate`, `settings.module_registry` and `settings.registration` re-exports (#500) are unchanged. Permission names, i18n keys, the `settings.values` invalidation channel and `app.state.settings` are unchanged.
- `audit_log` follows the module layout: `audit_log.core.{constants,settings,services,capture}`, `audit_log.entries.{service,filters,resolve,activity,export}`, `audit_log.web.{deps,endpoints}`, and the page components move from `pages/components/` to `ui/components/`. The `simple_module.audit_sinks` entry point now targets `audit_log.core.capture:audit_callback`. Nothing outside the module imported the old paths, so no aliases remain.
- `auth` follows the module layout: `auth.core.{constants,state}` and `auth.web.{deps,middleware}`; `auth.oidc_common` stays as a declared domain area. `auth.constants`, `auth.state`, `auth.deps` and `auth.middleware` still import, with a `DeprecationWarning`, until 0.0.46.
- `tenants` follows the module layout: `tenants.core.{constants,settings,services,errors,service,user_cleanup}` (with the membership, owner-guard and deletion helpers), `tenants.resolution.{resolver,host_resolver,token_tenant}`, `tenants.invitations.{service,mail}`, `tenants.web.{deps,shared_props,endpoints}`, and the page components and hooks move to `ui/components` and `ui/hooks`. The invitation mail template moves to `invitations/templates/invitation.txt`. `tenants.constants`, `tenants.errors`, `tenants.host_resolver`, `tenants.resolver`, `tenants.service` and `tenants.deps` still import, with a `DeprecationWarning`, until 0.0.46. The durable handler names `tenants.user_cleanup.purge_user_memberships` and `tenants.send_invitation_email` are unchanged.
Expand Down
12 changes: 7 additions & 5 deletions docs/framework/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ reload and the generic key/value API on top.

The old plugin paths (`settings.registration`, `settings.module_registry`,
`settings.hydrate`) still re-export these and emit a `DeprecationWarning`;
`settings.models`, `settings._secrets` and the moved names in
`settings.store` / `settings.constants` re-export them silently. They go no
earlier than 0.1.0.
`settings.models` and the moved names in `settings.module_settings.store` /
`settings.core.constants` / `settings.core._secrets` re-export them silently.
The old module paths `settings.store`, `settings.constants` and
`settings._secrets` are aliases of those modules that warn on import. All of
these go no earlier than 0.1.0.

A Celery worker or CLI script never runs the lifespan, so it hydrates
synchronously:
Expand Down Expand Up @@ -163,10 +165,10 @@ async def users_config(state: UsersStateDep):

## DB-backed settings

After bootstrap, most configuration lives in the `settings_setting` table and is edited via the admin UI at `/admin/settings/`. The CRUD layer is `SettingService` (in `settings.service`); typed reads (with USER > TENANT > SYSTEM resolution and registered defaults) go through a `SettingsAccessor` that wraps it:
After bootstrap, most configuration lives in the `settings_setting` table and is edited via the admin UI at `/admin/settings/`. The CRUD layer is `SettingService` (in `settings.core.service`); typed reads (with USER > TENANT > SYSTEM resolution and registered defaults) go through a `SettingsAccessor` that wraps it:

```python
from settings.service import SettingService
from settings.core.service import SettingService
from settings.contracts.accessor import SettingsAccessor

accessor = SettingsAccessor(SettingService(session), registry)
Expand Down
2 changes: 1 addition & 1 deletion docs/module-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ when a later module PR moves files.
| mail | 2 (+2 shims) | 0 | follows this layout |
| oidc | 3 (+3 shims) | 0 | follows this layout |
| permissions | 3 (+2 shims) | 0 | follows this layout |
| settings | 31 | 43 | flat |
| settings | 4 (+8 shims, +3 deprecated re-exports) | 0 | follows this layout |
| sharing | 3 (+3 shims) | 0 | follows this layout |
| site_lock | 2 (+1 shim) | 0 | follows this layout |
| tenants | 3 (+6 shims) | 0 | follows this layout |
Expand Down
2 changes: 1 addition & 1 deletion framework/core/simple_module_core/settings_base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Base class for DB-backed settings that must not read the environment.

Module and host settings are hydrated from the settings store at boot and
hot-swapped through ``settings.reload.apply_changes_and_reload``. The DB is
hot-swapped through ``settings.module_settings.reload.apply_changes_and_reload``. The DB is
meant to be the only source of truth for them.

Subclassing ``BaseSettings`` and simply omitting ``env_prefix`` does **not**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]
continue
# Match the convention actually used by modules: `app.state.<package>`
# (snake_case package name, e.g. `background_tasks`), which aligns
# with Settings-module autodiscovery in `settings._module_settings`.
# with Settings-module autodiscovery in `settings.module_settings.discovery`.
package = cls.__module__.split(".", 1)[0]
candidates = (package, mod.meta.name.lower())
if any(hasattr(app.state, c) for c in candidates):
Expand Down
2 changes: 1 addition & 1 deletion framework/hosting/tests/test_invalidation_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ async def test_both_new_fields_reach_the_settings_admin_surface(self, app):
and ``import-from-env`` actually read — rather than through
``model_fields``, which would only restate the dataclass back to itself.
"""
from settings._module_settings import collect_module_settings
from settings.module_settings.discovery import collect_module_settings

views = collect_module_settings(app)
background = next((v for v in views if v.package == "background_tasks"), None)
Expand Down
2 changes: 1 addition & 1 deletion host/tests/test_admin_overview_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async def test_permission_holder_without_the_admin_role_is_admitted(host_app) ->
This account can open ``/admin/settings/`` on its ``settings.view`` grant,
so refusing it the overview that links there is self-contradictory.
"""
from settings.constants import PERM_VIEW
from settings.core.constants import PERM_VIEW

client = await _client_for(
host_app,
Expand Down
23 changes: 13 additions & 10 deletions host/tests/test_module_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,17 @@
"mail": frozenset({"delivery"}),
"oidc": frozenset({"identity"}),
"permissions": frozenset({"grants"}),
"settings": frozenset({"module_settings"}),
"tenants": frozenset({"invitations", "resolution"}),
}

# Plain re-export shims (#500) that predate the layout: public compat paths kept
# at the root until their own 0.1.0 deprecation expires (host/tests/test_deprecation_expiry.py),
# then deleted together with the entry here.
DEPRECATED_ROOT: dict[str, frozenset[str]] = {
"settings": frozenset({"hydrate.py", "module_registry.py", "registration.py"}),
}

# Frozen inventory, not a computed baseline: moves must remove their old entries.
LEGACY_FLAT: dict[str, frozenset[str]] = {
"background_tasks": frozenset(
Expand All @@ -70,15 +78,6 @@
uploads.py visibility.py worker.py worker_support.py
""".split()
),
"settings": frozenset(
"""
_announce.py _field_meta.py _listing.py _managed_keys.py _module_settings.py
_module_settings_props.py _row_masking.py _secrets.py _unique_write.py
browse_query.py constants.py deps.py endpoints env_vars.py errors.py exceptions.py
hydrate.py invalidation.py known_keys.py module_registry.py registration.py
reload.py service.py services.py settings.py store.py tenant_scope.py tenant_view.py
""".split()
),
"users": frozenset(
"""
admin administrators.py audit.py auth_local backend.py bootstrap.py constants.py
Expand Down Expand Up @@ -124,7 +123,11 @@ def test_module_root_layout(module: str) -> None:
and not _is_shim_package(p)
)
}
allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset())
deprecated = DEPRECATED_ROOT.get(module, frozenset())
assert deprecated <= {p.name for p in package.iterdir()}, (
f"{module}: remove expired DEPRECATED_ROOT entries"
)
allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset()) | deprecated
legacy = LEGACY_FLAT.get(module, frozenset())
if module in LEGACY_FLAT:
assert legacy, f"{module}: remove the empty LEGACY_FLAT entry"
Expand Down
2 changes: 1 addition & 1 deletion modules/background_tasks/background_tasks/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Values come from defaults at boot, then get hydrated from the DB by the
hosting lifespan before module ``on_startup`` runs. Runtime changes go
through ``settings.reload.apply_changes_and_reload``.
through ``settings.module_settings.reload.apply_changes_and_reload``.

The broker and result-backend URLs are deployment plumbing rather than module
config, so they stay env-readable through ``SM_REDIS_URL`` — one variable that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ def test_soft_time_limit_must_be_below_hard_limit() -> None:

@pytest.mark.asyncio
async def test_stored_invalid_limit_pair_does_not_break_lenient_hydration(db_session) -> None:
from settings.service import SettingService
from settings.store import SettingsStore
from settings.core.service import SettingService
from settings.module_settings.store import SettingsStore
from simple_module_core.settings_hydrate import hydrate_settings

store = SettingsStore(SettingService(db_session))
Expand All @@ -64,8 +64,8 @@ async def test_hydration_drops_the_stored_soft_limit(db_session, caplog, monkeyp
"""With the hard limit pinned by env, the pair rule reported on
``task_time_limit`` cannot be satisfied by dropping it; the stored soft
limit is the half to drop (#548)."""
from settings.service import SettingService
from settings.store import SettingsStore
from settings.core.service import SettingService
from settings.module_settings.store import SettingsStore
from simple_module_core.settings_hydrate import hydrate_settings

monkeypatch.setenv("SM_BG_TASKS_TASK_TIME_LIMIT", "50")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- N1: ``before_commit`` also fires when a SAVEPOINT is released, so the guard
refused ``begin_nested()`` — the race-safe insert idiom, used by
``settings._unique_write`` — and such a handler could never deliver.
``settings.core._unique_write`` — and such a handler could never deliver.
- N2: committing through the session's ``Connection`` skipped
``Session.before_commit``, made the effect durable, and then failed the
attempt anyway — so every retry committed the effect again.
Expand Down
4 changes: 2 additions & 2 deletions modules/background_tasks/tests/test_bg_routes_override_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ async def test_invalid_override_is_rejected_with_422(authenticated_client, app,


async def test_stored_invalid_override_does_not_break_web_hydration(app, db_session, caplog):
from settings.service import SettingService
from settings.store import SettingsStore
from settings.core.service import SettingService
from settings.module_settings.store import SettingsStore
from simple_module_hosting._hydrate_step import hydrate_all

store = SettingsStore(SettingService(db_session))
Expand Down
2 changes: 1 addition & 1 deletion modules/branding/branding/assets/reaper.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
async def referenced_ids(db: AsyncSession, tenant_id: str | None) -> set[str]:
"""File ids the owner's image fields hold (``None`` = the system's)."""
from settings.contracts.schemas import SettingScope
from settings.service import SettingService
from settings.core.service import SettingService

scope, scope_id = (SettingScope.TENANT, tenant_id) if tenant_id else (SettingScope.SYSTEM, "")
rows = await SettingService(db).list_by_scope_unmasked(scope, scope_id)
Expand Down
12 changes: 6 additions & 6 deletions modules/branding/branding/core/service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Branding service — reads current branding and applies admin changes.

There is no branding DB table: values live in the shared settings store. Writes
go through ``settings.reload.apply_changes_and_reload`` which validates against
go through ``settings.module_settings.reload.apply_changes_and_reload`` which validates against
``BrandingSettings``, persists (SYSTEM scope), hot-swaps ``app.state.branding``
and publishes ``SettingsReloaded``.
"""
Expand Down Expand Up @@ -70,9 +70,9 @@ async def apply(self, changes: dict[str, Any]) -> BrandingOut:
"""Persist and hot-swap the given field changes, then return current."""
# Plugin→plugin imports (settings is a declared dependency); kept local
# so import order during discovery stays tolerant.
from settings.reload import apply_changes_and_reload
from settings.service import SettingService
from settings.store import SettingsStore
from settings.core.service import SettingService
from settings.module_settings.reload import apply_changes_and_reload
from settings.module_settings.store import SettingsStore

# With the bus, so the write publishes ``settings.values`` and other
# workers' merged tenant caches (which inherit system values) drop.
Expand All @@ -97,8 +97,8 @@ async def _swap_asset(self, field: str, file_id: str) -> BrandingOut:
"""
# The pre-save value comes from the DB-backed settings, not app.state:
# the in-process object only changes after commit.
from settings.service import SettingService
from settings.store import SettingsStore
from settings.core.service import SettingService
from settings.module_settings.store import SettingsStore
from simple_module_core.settings_hydrate import hydrate_settings

from branding.core.settings import BrandingSettings
Expand Down
2 changes: 1 addition & 1 deletion modules/branding/branding/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Stored as ``app.state.branding`` by
:meth:`BrandingModule.register_settings` (via ``register_module_settings``).
``settings`` is hot-swapped by ``settings.reload.apply_changes_and_reload`` when
``settings`` is hot-swapped by ``settings.module_settings.reload.apply_changes_and_reload`` when
an admin saves changes.
"""

Expand Down
2 changes: 1 addition & 1 deletion modules/branding/branding/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The four values an administrator can customise (app name, logo, favicon,
primary colour) are stored in the shared settings store at SYSTEM scope,
hydrated into ``app.state.branding.settings`` at boot, and hot-swapped on save
through ``settings.reload.apply_changes_and_reload``.
through ``settings.module_settings.reload.apply_changes_and_reload``.

Logo/favicon are stored as ``file_storage`` UUIDs (empty string = unset); the
frontend derives a download URL from the id.
Expand Down
6 changes: 3 additions & 3 deletions modules/branding/branding/core/tenant_branding.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

if TYPE_CHECKING:
from fastapi import FastAPI
from settings.service import SettingService
from settings.core.service import SettingService
from simple_module_core.invalidation import Invalidation, InvalidationBus
from starlette.requests import Request

Expand Down Expand Up @@ -94,8 +94,8 @@ def forget(app: FastAPI, tenant_id: str | None = None) -> None:


def subscribe(bus: InvalidationBus, app: FastAPI) -> None:
from settings.constants import INVALIDATION_CHANNEL
from settings.contracts.invalidation import parse_invalidation_key
from settings.core.constants import INVALIDATION_CHANNEL

def on_settings_changed(inv: Invalidation) -> None:
tenant_id, key = parse_invalidation_key(inv.key)
Expand Down Expand Up @@ -138,7 +138,7 @@ async def read_overrides(app: FastAPI, tenant_id: str) -> dict[str, str]:
Only for reads ahead of a request's own work (shared props, the asset
routes): a request that has already written must use its own session.
"""
from settings.service import SettingService
from settings.core.service import SettingService

async with app.state.sm.db.session_factory() as db:
return await overrides_from(SettingService(db), tenant_id)
Expand Down
8 changes: 4 additions & 4 deletions modules/branding/branding/web/endpoints/tenant_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from file_storage.deps import get_file_storage_service
from file_storage.service import FileStorageService
from settings.constants import PERM_TENANT_EDIT
from settings.contracts.schemas import SettingScope, SettingUpsert
from settings.deps import get_setting_service
from settings.service import SettingService
from settings.tenant_scope import active_tenant
from settings.core.constants import PERM_TENANT_EDIT
from settings.core.service import SettingService
from settings.core.tenant_scope import active_tenant
from settings.web.deps import get_setting_service
from simple_module_hosting.permissions import RequiresPermission

from branding.assets.images import store_image, validate_image
Expand Down
2 changes: 1 addition & 1 deletion modules/branding/tests/assets/reaper/test_branding_reap.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from branding.web.endpoints import tenant_api
from file_storage.models import StoredFile
from settings.contracts.schemas import SettingScope, SettingUpsert
from settings.service import SettingService
from settings.core.service import SettingService
from simple_module_db import all_tenants
from sqlalchemy import select

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from branding.core.tenant_branding import TenantCache
from file_storage.models import StoredFile
from settings.contracts.schemas import SettingScope
from settings.service import SettingService
from settings.core.service import SettingService
from simple_module_db import all_tenants
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down
6 changes: 3 additions & 3 deletions modules/branding/tests/core/presets/test_branding_presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
import httpx
import pytest
from branding.core.presets import BUILTIN_PRESETS, PRESET_FIELDS, BrandingPreset, find_preset
from settings.invalidation import reload_package
from settings.service import SettingService
from settings.store import SettingsStore
from settings.core.invalidation import reload_package
from settings.core.service import SettingService
from settings.module_settings.store import SettingsStore

# ── Unit: definitions ──────────────────────────────────────────────────

Expand Down
Loading
Loading