diff --git a/CHANGELOG.md b/CHANGELOG.md index 97658c6bc..4f6651a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project are documented in this file. The format is b ### Changed - Scaffold new modules with `core/` and `web/` areas and mirrored nested Python tests; existing modules migrate individually. See `docs/module-layout.md`. +- `mail` follows the module layout: `mail.core.{constants,settings,state,data_compatibility}` and `mail.delivery.{sender,runtime}`. `mail.settings` and `mail.constants` still import, with a `DeprecationWarning`, until 0.0.46. ### Fixed diff --git a/docs/module-layout.md b/docs/module-layout.md index 7cadcbfa8..1288ce1b3 100644 --- a/docs/module-layout.md +++ b/docs/module-layout.md @@ -131,29 +131,36 @@ Inventory old import paths used outside the module, both in this repository and downstream. Every such old path becomes a shim using `simple_module_core.deprecation.deprecated_module`, with `remove_in="0.0.46"`. Update internal imports to the new path; do not make new code depend on shims. -Register every shim in `host/tests/test_deprecation_expiry.py`'s import list. -The expiry test fails once the installed framework reaches the removal release. +A shim built as the `sys.modules` alias below is discovered automatically by +`host/tests/test_deprecation_expiry.py` (`_layout_shims()`), so it needs no manual +registration. The expiry test fails once the installed framework reaches the removal release. See [Deprecations](framework/deprecations.md) for the API. For example, after moving `orders.service` to `orders.web.service`, leave this at `orders/service.py` if external callers use that old path: ```python -"""Compatibility import for the module-layout migration.""" +"""Moved to :mod:`orders.web.service`; this alias goes in 0.0.46 (module layout).""" -from simple_module_core.deprecation import deprecated_module +from __future__ import annotations + +import importlib +import sys -from orders.web.service import OrderService as OrderService +from simple_module_core.deprecation import deprecated_module deprecated_module("orders.service", remove_in="0.0.46", use="orders.web.service") +sys.modules[__name__] = importlib.import_module("orders.web.service") ``` -Add `"orders.service"` to both the expiry test's `_SHIM_MODULES` tuple and -`_EXPECTED` set, and pin that -both paths expose the same class, with an import-time `DeprecationWarning`. -Keep `service.py` in the module's frozen legacy entries until the shim is -removed. A completely migrated module leaves `LEGACY_FLAT` only when its extra -roots, including compatibility shims, are gone and its Python tests are nested. +The alias makes both paths the same module object, so mocks and +`monkeypatch` strings keep working. Files containing both `deprecated_module(` +and `sys.modules[__name__]` are auto-discovered by the expiry test and ignored +by the root-layout test, so the module can leave `LEGACY_FLAT` while they +remain. A shim that is a plain re-export instead (not an alias) must be added +by hand to the expiry test's `_SHIM_MODULES` tuple and `_EXPECTED` set and kept +in the module's frozen legacy entries until removed. A completely migrated +module leaves `LEGACY_FLAT` once its Python tests are nested. ## Moving an existing module @@ -169,8 +176,9 @@ One module per PR; no logic changes in a move PR. 3. Move tests to matching areas/features, keep root `conftest.py`, remove test package initializers and prefix each basename with the module name. JS tests remain in `tests-js/`. -4. Add external-import shims with removal release `0.0.46`, register them in - the expiry test, shrink the exact frozen root exceptions, and update the +4. Add external-import shims with removal release `0.0.46`, alias shims are + auto-discovered by the expiry test (re-export shims are registered by hand), + shrink the exact frozen root exceptions, and update the status table below. Domain areas must be named in `DOMAIN_AREAS`. 5. Run `python -c "import ."` in a **fresh interpreter for every module**, not one interpreter importing all modules in sequence. @@ -183,7 +191,7 @@ One module per PR; no logic changes in a move PR. ## Migration status (2026-11-05) Counts refer to the source package root and direct `tests/test_*.py` files; -cache directories are ignored. All modules are flat in PR 0. Update this table +cache directories are ignored. Shim files left at old paths are counted separately. Update this table when a later module PR moves files. | Module | Root `.py` files | Flat `tests/test_*.py` | State | @@ -197,7 +205,7 @@ when a later module PR moves files. | feature_flags | 8 | 9 | flat | | file_storage | 39 | 65 | flat | | keycloak | 13 | 19 | flat | -| mail | 8 | 5 | flat | +| mail | 2 (+2 shims) | 0 | follows this layout | | oidc | 16 | 24 | flat | | permissions | 12 | 14 | flat | | settings | 31 | 43 | flat | diff --git a/framework/cli/tests/test_cli_prepare_data_migrations.py b/framework/cli/tests/test_cli_prepare_data_migrations.py index 5dc615830..17b074754 100644 --- a/framework/cli/tests/test_cli_prepare_data_migrations.py +++ b/framework/cli/tests/test_cli_prepare_data_migrations.py @@ -178,7 +178,7 @@ def find_spec(self, fullname, path=None, target=None): def test_cli_recipe_ids_match_the_module_constants(): - from mail.constants import UPGRADE_SETTINGS_NAMESPACE + from mail.core.constants import UPGRADE_SETTINGS_NAMESPACE from simple_module_cli.migrations_cmd import _RECIPES from users.constants import UPGRADE_HASHED_BEARER_TOKENS diff --git a/host/tests/test_deprecation_expiry.py b/host/tests/test_deprecation_expiry.py index 3c2298435..e2e81acf9 100644 --- a/host/tests/test_deprecation_expiry.py +++ b/host/tests/test_deprecation_expiry.py @@ -11,6 +11,7 @@ import importlib import warnings from importlib.metadata import version +from pathlib import Path import pytest from simple_module_core.deprecation import ( @@ -48,10 +49,27 @@ } +_MODULES_DIR = Path(__file__).resolve().parents[2] / "modules" + + +def _layout_shims() -> list[str]: + """Old import paths left behind by module-layout moves (docs/module-layout.md).""" + found = [] + # Package sources only: ``modules///``, not tests or node_modules. + for path in sorted(_MODULES_DIR.glob("*/*/**/*.py")): + if path.relative_to(_MODULES_DIR).parts[0] != path.relative_to(_MODULES_DIR).parts[1]: + continue + text = path.read_text(encoding="utf-8") + if "deprecated_module(" in text and "sys.modules[__name__]" in text: + rel = path.relative_to(_MODULES_DIR).with_suffix("").parts[1:] + found.append(".".join(rel)) + return found + + def _register_all() -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - for name in _SHIM_MODULES: + for name in (*_SHIM_MODULES, *_layout_shims()): importlib.import_module(name) # Overrides own event-hook calls, so omission cannot warn reliably at the base. from simple_module_core.module import EVENT_HANDLERS_OPTIONAL_APP_REMOVE_IN @@ -73,6 +91,12 @@ def test_every_known_shim_is_registered() -> None: assert {d.name for d in registered_deprecations()} >= _EXPECTED +def test_every_layout_shim_registers_its_old_path() -> None: + _register_all() + registered = {d.name for d in registered_deprecations()} + assert set(_layout_shims()) <= registered + + def test_no_shim_outlives_its_remove_in_release() -> None: _register_all() expired = expired_deprecations(version("simple_module_core")) diff --git a/host/tests/test_module_layout.py b/host/tests/test_module_layout.py index 15604ca19..e6664c9a8 100644 --- a/host/tests/test_module_layout.py +++ b/host/tests/test_module_layout.py @@ -31,7 +31,9 @@ } ) # Add named domain areas here only when they contain several related features. -DOMAIN_AREAS: dict[str, frozenset[str]] = {} +DOMAIN_AREAS: dict[str, frozenset[str]] = { + "mail": frozenset({"delivery"}), +} # Frozen inventory, not a computed baseline: moves must remove their old entries. LEGACY_FLAT: dict[str, frozenset[str]] = { @@ -98,11 +100,6 @@ renewal.py settings.py state.py tokens.py """.split() ), - "mail": frozenset( - """ - constants.py data_compatibility.py runtime.py sender.py settings.py state.py - """.split() - ), "oidc": frozenset( """ client.py constants.py directory.py discovery.py endpoints jwks.py persistence.py @@ -156,6 +153,12 @@ } +def _is_layout_shim(path: Path) -> bool: + """A moved module's old path: a deprecated alias, allowed until its remove_in.""" + text = path.read_text(encoding="utf-8") + return "deprecated_module(" in text and "sys.modules[__name__]" in text + + @pytest.mark.parametrize("module", sorted(p.name for p in MODULES.iterdir() if p.is_dir())) def test_module_root_layout(module: str) -> None: package = MODULES / module / module @@ -163,7 +166,8 @@ def test_module_root_layout(module: str) -> None: entries = { p.name for p in package.iterdir() - if p.suffix == ".py" or (p.is_dir() and p.name != "__pycache__") + if (p.suffix == ".py" and not _is_layout_shim(p)) + or (p.is_dir() and p.name != "__pycache__") } allowed = ALLOWED_ROOT | DOMAIN_AREAS.get(module, frozenset()) legacy = LEGACY_FLAT.get(module, frozenset()) diff --git a/modules/mail/mail/constants.py b/modules/mail/mail/constants.py index c6448183e..e449384f0 100644 --- a/modules/mail/mail/constants.py +++ b/modules/mail/mail/constants.py @@ -1,35 +1,11 @@ -"""Every literal the mail module depends on.""" +"""Moved to :mod:`mail.core.constants`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -MODULE_NAME = "Mail" -MODULE_PACKAGE = "mail" +import importlib +import sys -# Dependency names must match the other modules' ``ModuleMeta.name``. -MODULE_SETTINGS = "Settings" +from simple_module_core.deprecation import deprecated_module -BACKEND_CONSOLE = "console" -BACKEND_SMTP = "smtp" - -# On-demand health check; also drives the settings page's "Test connection". -CHECK_MAILER = "mail.mailer" - -DEFAULT_FROM = "no-reply@localhost" -DEFAULT_SMTP_PORT = 587 -MIN_PORT = 1 -MAX_PORT = 65535 - -#: Seconds per SMTP operation (aiosmtplib defaults to 60). A send can run in a -#: request's after-commit hook, so a hung server must not hold it for a minute. -SMTP_TIMEOUT_SECONDS = 15.0 - -UPGRADE_SETTINGS_NAMESPACE = "mail.settings_namespace_v1" -LEGACY_MAIL_KEYS = ( - "users.mailer", - "users.smtp_host", - "users.smtp_port", - "users.smtp_username", - "users.smtp_password", - "users.smtp_from", - "users.smtp_tls", -) +deprecated_module("mail.constants", remove_in="0.0.46", use="mail.core.constants") +sys.modules[__name__] = importlib.import_module("mail.core.constants") diff --git a/modules/mail/mail/core/__init__.py b/modules/mail/mail/core/__init__.py new file mode 100644 index 000000000..c1c28ae89 --- /dev/null +++ b/modules/mail/mail/core/__init__.py @@ -0,0 +1 @@ +"""What every mail feature builds on: constants, settings, app state, upgrade checks.""" diff --git a/modules/mail/mail/core/constants.py b/modules/mail/mail/core/constants.py new file mode 100644 index 000000000..c6448183e --- /dev/null +++ b/modules/mail/mail/core/constants.py @@ -0,0 +1,35 @@ +"""Every literal the mail module depends on.""" + +from __future__ import annotations + +MODULE_NAME = "Mail" +MODULE_PACKAGE = "mail" + +# Dependency names must match the other modules' ``ModuleMeta.name``. +MODULE_SETTINGS = "Settings" + +BACKEND_CONSOLE = "console" +BACKEND_SMTP = "smtp" + +# On-demand health check; also drives the settings page's "Test connection". +CHECK_MAILER = "mail.mailer" + +DEFAULT_FROM = "no-reply@localhost" +DEFAULT_SMTP_PORT = 587 +MIN_PORT = 1 +MAX_PORT = 65535 + +#: Seconds per SMTP operation (aiosmtplib defaults to 60). A send can run in a +#: request's after-commit hook, so a hung server must not hold it for a minute. +SMTP_TIMEOUT_SECONDS = 15.0 + +UPGRADE_SETTINGS_NAMESPACE = "mail.settings_namespace_v1" +LEGACY_MAIL_KEYS = ( + "users.mailer", + "users.smtp_host", + "users.smtp_port", + "users.smtp_username", + "users.smtp_password", + "users.smtp_from", + "users.smtp_tls", +) diff --git a/modules/mail/mail/data_compatibility.py b/modules/mail/mail/core/data_compatibility.py similarity index 93% rename from modules/mail/mail/data_compatibility.py rename to modules/mail/mail/core/data_compatibility.py index 3217c912c..0dde0ce5e 100644 --- a/modules/mail/mail/data_compatibility.py +++ b/modules/mail/mail/core/data_compatibility.py @@ -10,7 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from mail.constants import LEGACY_MAIL_KEYS, UPGRADE_SETTINGS_NAMESPACE +from mail.core.constants import LEGACY_MAIL_KEYS, UPGRADE_SETTINGS_NAMESPACE async def check_data_compatibility(db: AsyncSession) -> list[DataCompatibilityIssue]: diff --git a/modules/mail/mail/core/settings.py b/modules/mail/mail/core/settings.py new file mode 100644 index 000000000..e2a27b0fe --- /dev/null +++ b/modules/mail/mail/core/settings.py @@ -0,0 +1,30 @@ +"""Mail module settings — DB-backed via ``register_module_settings``. + +``smtp_password`` is masked by the settings UI because its name matches the +secret-field pattern (``simple_module_core.secret_fields.is_secret_field``). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field +from pydantic_settings import SettingsConfigDict +from simple_module_core.settings_base import DbBackedSettings + +from mail.core import constants as c + + +class MailSettings(DbBackedSettings): + """Which transport sends mail, and the SMTP account it uses.""" + + # ``DbBackedSettings`` so the DB is the only source (GH #283). + model_config = SettingsConfigDict(extra="ignore") + + backend: Literal["console", "smtp"] = c.BACKEND_CONSOLE + smtp_host: str = "" + smtp_port: int = Field(default=c.DEFAULT_SMTP_PORT, ge=c.MIN_PORT, le=c.MAX_PORT) + smtp_username: str = "" + smtp_password: str = "" + smtp_from: str = c.DEFAULT_FROM + smtp_tls: bool = True diff --git a/modules/mail/mail/state.py b/modules/mail/mail/core/state.py similarity index 85% rename from modules/mail/mail/state.py rename to modules/mail/mail/core/state.py index 56b1e9093..586668f1f 100644 --- a/modules/mail/mail/state.py +++ b/modules/mail/mail/core/state.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from mail.settings import MailSettings +from mail.core.settings import MailSettings @dataclass diff --git a/modules/mail/mail/delivery/__init__.py b/modules/mail/mail/delivery/__init__.py new file mode 100644 index 000000000..1b81b13ec --- /dev/null +++ b/modules/mail/mail/delivery/__init__.py @@ -0,0 +1 @@ +"""Sending mail: the SMTP/console senders and the app-free resolver and health check.""" diff --git a/modules/mail/mail/runtime.py b/modules/mail/mail/delivery/runtime.py similarity index 94% rename from modules/mail/mail/runtime.py rename to modules/mail/mail/delivery/runtime.py index be700e4db..cdbff1dec 100644 --- a/modules/mail/mail/runtime.py +++ b/modules/mail/mail/delivery/runtime.py @@ -12,9 +12,9 @@ from simple_module_core.mail import MailSender, current_mail_sender, install_mail_sender from simple_module_core.settings_hydrate import hydrate_settings -from mail import constants as c -from mail.sender import build_sender -from mail.settings import MailSettings +from mail.core import constants as c +from mail.core.settings import MailSettings +from mail.delivery.sender import build_sender async def resolve_sender_from_db(db: Any) -> MailSender: diff --git a/modules/mail/mail/sender.py b/modules/mail/mail/delivery/sender.py similarity index 97% rename from modules/mail/mail/sender.py rename to modules/mail/mail/delivery/sender.py index a5d0ae8a9..e9d809c6a 100644 --- a/modules/mail/mail/sender.py +++ b/modules/mail/mail/delivery/sender.py @@ -8,8 +8,8 @@ import aiosmtplib from simple_module_core.mail import ConsoleMailSender, MailMessage, MailSender -from mail import constants as c -from mail.settings import MailSettings +from mail.core import constants as c +from mail.core.settings import MailSettings class SmtpMailSender: diff --git a/modules/mail/mail/module.py b/modules/mail/mail/module.py index ce0f0976c..688f24970 100644 --- a/modules/mail/mail/module.py +++ b/modules/mail/mail/module.py @@ -8,7 +8,7 @@ from simple_module_core.mail import install_mail_resolver, install_mail_sender from simple_module_core.module import ModuleBase, ModuleMeta -from mail import constants as c +from mail.core import constants as c if TYPE_CHECKING: from fastapi import FastAPI @@ -26,15 +26,15 @@ class MailModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: from simple_module_core.module_settings import register_module_settings - from mail.settings import MailSettings - from mail.state import MailState + from mail.core.settings import MailSettings + from mail.core.state import MailState register_module_settings( app, c.MODULE_PACKAGE, MailSettings, lambda s: MailState(settings=s) ) def register_health_checks(self, registry: HealthRegistry) -> None: - from mail.runtime import check_smtp + from mail.delivery.runtime import check_smtp registry.add( HealthCheck( @@ -49,7 +49,7 @@ def register_health_checks(self, registry: HealthRegistry) -> None: def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: # Before the ``app is None`` return: the outbox worker builds modules # without an app and still has to send durable mail. - from mail.runtime import resolve_sender_from_db + from mail.delivery.runtime import resolve_sender_from_db install_mail_resolver(resolve_sender_from_db) if app is None: @@ -57,7 +57,7 @@ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> from settings.contracts.events import SettingsReloaded - from mail.runtime import install_app_sender + from mail.delivery.runtime import install_app_sender async def _reinstall(event: SettingsReloaded) -> None: if event.package != c.MODULE_PACKAGE: @@ -67,12 +67,12 @@ async def _reinstall(event: SettingsReloaded) -> None: bus.subscribe(SettingsReloaded, _reinstall) async def check_data_compatibility(self, db: Any) -> list[DataCompatibilityIssue]: - from mail.data_compatibility import check_data_compatibility + from mail.core.data_compatibility import check_data_compatibility return await check_data_compatibility(db) async def on_startup(self, app: FastAPI) -> None: - from mail.runtime import install_app_sender + from mail.delivery.runtime import install_app_sender install_app_sender(app) diff --git a/modules/mail/mail/settings.py b/modules/mail/mail/settings.py index bdde2b3e7..e867b59f6 100644 --- a/modules/mail/mail/settings.py +++ b/modules/mail/mail/settings.py @@ -1,30 +1,11 @@ -"""Mail module settings — DB-backed via ``register_module_settings``. - -``smtp_password`` is masked by the settings UI because its name matches the -secret-field pattern (``simple_module_core.secret_fields.is_secret_field``). -""" +"""Moved to :mod:`mail.core.settings`; this alias goes in 0.0.46 (module layout).""" from __future__ import annotations -from typing import Literal - -from pydantic import Field -from pydantic_settings import SettingsConfigDict -from simple_module_core.settings_base import DbBackedSettings - -from mail import constants as c - - -class MailSettings(DbBackedSettings): - """Which transport sends mail, and the SMTP account it uses.""" +import importlib +import sys - # ``DbBackedSettings`` so the DB is the only source (GH #283). - model_config = SettingsConfigDict(extra="ignore") +from simple_module_core.deprecation import deprecated_module - backend: Literal["console", "smtp"] = c.BACKEND_CONSOLE - smtp_host: str = "" - smtp_port: int = Field(default=c.DEFAULT_SMTP_PORT, ge=c.MIN_PORT, le=c.MAX_PORT) - smtp_username: str = "" - smtp_password: str = "" - smtp_from: str = c.DEFAULT_FROM - smtp_tls: bool = True +deprecated_module("mail.settings", remove_in="0.0.46", use="mail.core.settings") +sys.modules[__name__] = importlib.import_module("mail.core.settings") diff --git a/modules/mail/tests/test_mail_data_compatibility.py b/modules/mail/tests/core/data_compatibility/test_mail_data_compatibility.py similarity index 100% rename from modules/mail/tests/test_mail_data_compatibility.py rename to modules/mail/tests/core/data_compatibility/test_mail_data_compatibility.py diff --git a/modules/mail/tests/test_mail_lifecycle.py b/modules/mail/tests/core/module/test_mail_lifecycle.py similarity index 98% rename from modules/mail/tests/test_mail_lifecycle.py rename to modules/mail/tests/core/module/test_mail_lifecycle.py index 155e07c82..55e7b990a 100644 --- a/modules/mail/tests/test_mail_lifecycle.py +++ b/modules/mail/tests/core/module/test_mail_lifecycle.py @@ -2,7 +2,7 @@ from __future__ import annotations -from mail.sender import SmtpMailSender +from mail.delivery.sender import SmtpMailSender from settings.contracts.events import SettingsReloaded from settings.reload import apply_changes_and_reload from settings.service import SettingService diff --git a/modules/mail/tests/test_mail_settings_migration.py b/modules/mail/tests/core/settings/test_mail_settings_migration.py similarity index 98% rename from modules/mail/tests/test_mail_settings_migration.py rename to modules/mail/tests/core/settings/test_mail_settings_migration.py index ec9d61646..557f3221a 100644 --- a/modules/mail/tests/test_mail_settings_migration.py +++ b/modules/mail/tests/core/settings/test_mail_settings_migration.py @@ -11,7 +11,7 @@ BEFORE = "c8e4f1a2b6d9" REVISION = "d7b3a9e5c1f2" -SCRIPTS = Path(__file__).resolve().parents[3] / "host" / "migrations" +SCRIPTS = Path(__file__).resolve().parents[5] / "host" / "migrations" pytestmark = pytest.mark.filterwarnings("ignore:Skipped unsupported reflection") diff --git a/modules/mail/tests/test_mail_health.py b/modules/mail/tests/delivery/runtime/test_mail_health.py similarity index 90% rename from modules/mail/tests/test_mail_health.py rename to modules/mail/tests/delivery/runtime/test_mail_health.py index 8180b1fb2..f548d95be 100644 --- a/modules/mail/tests/test_mail_health.py +++ b/modules/mail/tests/delivery/runtime/test_mail_health.py @@ -5,8 +5,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from mail.runtime import check_smtp -from mail.sender import SmtpMailSender +from mail.delivery.runtime import check_smtp +from mail.delivery.sender import SmtpMailSender from simple_module_core import mail as core_mail from simple_module_core.health import HealthStatus from simple_module_core.mail import ConsoleMailSender @@ -30,7 +30,7 @@ def smtp_client(monkeypatch) -> MagicMock: client.connect = AsyncMock() client.login = AsyncMock() client.quit = AsyncMock() - monkeypatch.setattr("mail.sender.aiosmtplib.SMTP", MagicMock(return_value=client)) + monkeypatch.setattr("mail.delivery.sender.aiosmtplib.SMTP", MagicMock(return_value=client)) core_mail.install_mail_sender(SmtpMailSender("h", 587, "user", "pw", "f@test", True)) return client diff --git a/modules/mail/tests/test_mail_sender.py b/modules/mail/tests/delivery/sender/test_mail_sender.py similarity index 87% rename from modules/mail/tests/test_mail_sender.py rename to modules/mail/tests/delivery/sender/test_mail_sender.py index 9687131d9..6ae95a23b 100644 --- a/modules/mail/tests/test_mail_sender.py +++ b/modules/mail/tests/delivery/sender/test_mail_sender.py @@ -5,8 +5,8 @@ from unittest.mock import AsyncMock import pytest -from mail.sender import SmtpMailSender, build_sender -from mail.settings import MailSettings +from mail.core.settings import MailSettings +from mail.delivery.sender import SmtpMailSender, build_sender from simple_module_core.mail import ConsoleMailSender, MailMessage @@ -42,7 +42,7 @@ def _smtp() -> SmtpMailSender: async def test_send_builds_a_plain_text_message(monkeypatch) -> None: send = AsyncMock() - monkeypatch.setattr("mail.sender.aiosmtplib.send", send) + monkeypatch.setattr("mail.delivery.sender.aiosmtplib.send", send) await _smtp().send(MailMessage(to="to@test", subject="Hi", text="Body")) @@ -62,7 +62,7 @@ async def test_send_builds_a_plain_text_message(monkeypatch) -> None: async def test_send_adds_an_html_alternative(monkeypatch) -> None: send = AsyncMock() - monkeypatch.setattr("mail.sender.aiosmtplib.send", send) + monkeypatch.setattr("mail.delivery.sender.aiosmtplib.send", send) await _smtp().send(MailMessage(to="to@test", subject="Hi", text="Body", html="Body")) @@ -74,7 +74,7 @@ async def test_send_adds_an_html_alternative(monkeypatch) -> None: async def test_blank_credentials_are_sent_as_none(monkeypatch) -> None: send = AsyncMock() - monkeypatch.setattr("mail.sender.aiosmtplib.send", send) + monkeypatch.setattr("mail.delivery.sender.aiosmtplib.send", send) sender = SmtpMailSender("h", 25, "", "", "f@test", False) await sender.send(MailMessage(to="t@test", subject="s", text="b")) @@ -85,8 +85,8 @@ async def test_blank_credentials_are_sent_as_none(monkeypatch) -> None: def test_console_backend_outside_development_does_not_log_bodies() -> None: """Reset and invite bodies carry one-time tokens (production console backend).""" - from mail.sender import build_sender - from mail.settings import MailSettings + from mail.core.settings import MailSettings + from mail.delivery.sender import build_sender assert build_sender(MailSettings(), log_bodies=False).include_body is False assert build_sender(MailSettings()).include_body is True @@ -94,7 +94,7 @@ def test_console_backend_outside_development_does_not_log_bodies() -> None: @pytest.mark.parametrize("port", [-1, 0, 65536, 99999]) def test_smtp_port_outside_the_tcp_range_is_rejected(port) -> None: - from mail.settings import MailSettings + from mail.core.settings import MailSettings from pydantic import ValidationError with pytest.raises(ValidationError): diff --git a/modules/settings/tests/test_known_keys_meta.py b/modules/settings/tests/test_known_keys_meta.py index cf39f6504..de4f0a440 100644 --- a/modules/settings/tests/test_known_keys_meta.py +++ b/modules/settings/tests/test_known_keys_meta.py @@ -168,7 +168,7 @@ def test_an_unset_env_var_has_no_value_at_all(self, monkeypatch: pytest.MonkeyPa def test_a_db_backed_class_reports_no_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None: """``MailSettings`` declares no ``env_prefix``: the var is not a fallback.""" - from mail.settings import MailSettings + from mail.core.settings import MailSettings from settings._module_settings import _field_view monkeypatch.setenv("SM_MAIL_SMTP_HOST", "not-a-fallback.example.com")