simple-module-core 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ """Structural diagnostics that validate discovered modules against invariants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.util
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from simple_module_core.diagnostics._coupling import check_framework_module_coupling
11
+ from simple_module_core.diagnostics._inertia_api import check_inertia_api_calls
12
+ from simple_module_core.diagnostics._js_workspace import check_js_workspace_files
13
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
14
+
15
+ if TYPE_CHECKING:
16
+ from simple_module_core.module import ModuleBase
17
+
18
+
19
+ def _iter_render_components(tree: ast.Module) -> list[str]:
20
+ """Yield ``X.render(component, ...)`` first-arg values, resolving Name constants."""
21
+ consts = {
22
+ s.targets[0].id: s.value.value
23
+ for s in tree.body
24
+ if isinstance(s, ast.Assign)
25
+ and len(s.targets) == 1
26
+ and isinstance(s.targets[0], ast.Name)
27
+ and isinstance(s.value, ast.Constant)
28
+ and isinstance(s.value.value, str)
29
+ }
30
+ found: list[str] = []
31
+ for node in ast.walk(tree):
32
+ if not (
33
+ isinstance(node, ast.Call)
34
+ and isinstance(node.func, ast.Attribute)
35
+ and node.func.attr == "render"
36
+ and node.args
37
+ ):
38
+ continue
39
+ first = node.args[0]
40
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
41
+ found.append(first.value)
42
+ elif isinstance(first, ast.Name) and first.id in consts:
43
+ found.append(consts[first.id])
44
+ return found
45
+
46
+
47
+ class ModuleDiagnostics:
48
+ """Validates module structure and configuration."""
49
+
50
+ def run(self, modules: list[ModuleBase]) -> list[Diagnostic]:
51
+ diagnostics: list[Diagnostic] = []
52
+ diagnostics.extend(self._check_duplicate_names(modules))
53
+ diagnostics.extend(self._check_schema_conflicts(modules))
54
+ diagnostics.extend(self._check_empty_modules(modules))
55
+ diagnostics.extend(self._check_missing_meta(modules))
56
+ diagnostics.extend(check_framework_module_coupling(modules))
57
+
58
+ # File-based checks (need to find module source directories)
59
+ for mod in modules:
60
+ src_dir = self._find_source_dir(mod)
61
+ if src_dir:
62
+ rendered_pages = self._find_render_calls(mod, src_dir)
63
+ diagnostics.extend(self._check_orphan_pages(mod, src_dir, rendered_pages))
64
+ diagnostics.extend(self._check_phantom_renders(mod, src_dir, rendered_pages))
65
+ diagnostics.extend(check_js_workspace_files(mod, src_dir))
66
+ diagnostics.extend(check_inertia_api_calls(mod, src_dir))
67
+
68
+ return diagnostics
69
+
70
+ def _check_duplicate_names(self, modules: list[ModuleBase]) -> list[Diagnostic]:
71
+ seen: dict[str, str] = {}
72
+ diags: list[Diagnostic] = []
73
+ for mod in modules:
74
+ name = mod.meta.name
75
+ cls_name = type(mod).__qualname__
76
+ if name in seen:
77
+ diags.append(
78
+ Diagnostic(
79
+ level=DiagnosticLevel.ERROR,
80
+ code="SM008",
81
+ message=f"Duplicate module name '{name}' (also in {seen[name]})",
82
+ module_name=cls_name,
83
+ suggestion="Each module must have a unique meta.name",
84
+ )
85
+ )
86
+ seen[name] = cls_name
87
+ return diags
88
+
89
+ def _check_schema_conflicts(self, modules: list[ModuleBase]) -> list[Diagnostic]:
90
+ """Check for modules that would create conflicting DB schemas."""
91
+ prefixes: dict[str, str] = {}
92
+ diags: list[Diagnostic] = []
93
+ for mod in modules:
94
+ prefix = mod.meta.name.lower()
95
+ if prefix in prefixes:
96
+ diags.append(
97
+ Diagnostic(
98
+ level=DiagnosticLevel.ERROR,
99
+ code="SM008",
100
+ message=(
101
+ f"Schema/table prefix '{prefix}' conflicts "
102
+ f"with module '{prefixes[prefix]}'"
103
+ ),
104
+ module_name=mod.meta.name,
105
+ suggestion="Use unique module names to avoid DB schema conflicts",
106
+ )
107
+ )
108
+ prefixes[prefix] = mod.meta.name
109
+ return diags
110
+
111
+ def _check_empty_modules(self, modules: list[ModuleBase]) -> list[Diagnostic]:
112
+ diags: list[Diagnostic] = []
113
+ for mod in modules:
114
+ cls = type(mod)
115
+ overridden = [
116
+ name
117
+ for name in (
118
+ "register_routes",
119
+ "register_menu_items",
120
+ "register_permissions",
121
+ "register_feature_flags",
122
+ "register_event_handlers",
123
+ "register_middleware",
124
+ "register_health_checks",
125
+ "register_exception_handlers",
126
+ "register_settings",
127
+ "template_dirs",
128
+ "static_mounts",
129
+ "locale_dirs",
130
+ "on_startup",
131
+ "on_shutdown",
132
+ )
133
+ if name in cls.__dict__
134
+ ]
135
+ if not overridden:
136
+ diags.append(
137
+ Diagnostic(
138
+ level=DiagnosticLevel.INFO,
139
+ code="SM007",
140
+ message="Module exists but overrides no registration methods",
141
+ module_name=mod.meta.name,
142
+ suggestion="Override register_routes() or other methods to add functionality", # noqa: E501
143
+ )
144
+ )
145
+ return diags
146
+
147
+ def _check_missing_meta(self, modules: list[ModuleBase]) -> list[Diagnostic]:
148
+ diags: list[Diagnostic] = []
149
+ for mod in modules:
150
+ if not hasattr(mod, "meta"):
151
+ diags.append(
152
+ Diagnostic(
153
+ level=DiagnosticLevel.ERROR,
154
+ code="SM001",
155
+ message="Module missing 'meta' class attribute",
156
+ module_name=type(mod).__qualname__,
157
+ suggestion="Add: meta = ModuleMeta(name='YourModule')",
158
+ )
159
+ )
160
+ return diags
161
+
162
+ def _find_package_dir(self, package_name: str) -> Path | None:
163
+ """Locate the source directory for a top-level package."""
164
+ spec = importlib.util.find_spec(package_name)
165
+ if spec and spec.submodule_search_locations:
166
+ locations = list(spec.submodule_search_locations)
167
+ if locations:
168
+ return Path(locations[0])
169
+ return None
170
+
171
+ def _collect_tsx_pages(self, pages_dir: Path) -> set[str]:
172
+ """Collect .tsx page identifiers relative to pages_dir, without extension.
173
+
174
+ Nested files are represented with forward slashes so the set compares
175
+ directly against inertia.render("Module/Sub/Page") keys. Subdirectories
176
+ whose names start with a lowercase letter (e.g. ``components/``,
177
+ ``hooks/``) are treated as helper folders — not Inertia page roots —
178
+ and skipped, matching the PascalCase convention Inertia uses.
179
+ """
180
+ if not pages_dir.exists():
181
+ return set()
182
+ pages: set[str] = set()
183
+ for f in pages_dir.rglob("*.tsx"):
184
+ rel = f.relative_to(pages_dir)
185
+ if any(part[:1].islower() for part in rel.parts[:-1]):
186
+ continue
187
+ pages.add(rel.with_suffix("").as_posix())
188
+ return pages
189
+
190
+ def _check_orphan_pages(
191
+ self,
192
+ mod: ModuleBase,
193
+ src_dir: Path,
194
+ rendered_pages: set[str],
195
+ ) -> list[Diagnostic]:
196
+ """Find .tsx pages that aren't referenced by any inertia.render() call."""
197
+ pages_dir = src_dir / "pages"
198
+ tsx_pages = self._collect_tsx_pages(pages_dir)
199
+ orphans = tsx_pages - rendered_pages
200
+
201
+ return [
202
+ Diagnostic(
203
+ level=DiagnosticLevel.WARNING,
204
+ code="SM003",
205
+ message=f"Page '{name}.tsx' exists but no matching inertia.render() found",
206
+ module_name=mod.meta.name,
207
+ file=str(pages_dir / f"{name}.tsx"),
208
+ suggestion=f'Add inertia.render("{mod.meta.name}/{name}", ...) in a view endpoint',
209
+ )
210
+ for name in orphans
211
+ ]
212
+
213
+ def _check_phantom_renders(
214
+ self,
215
+ mod: ModuleBase,
216
+ src_dir: Path,
217
+ rendered_pages: set[str],
218
+ ) -> list[Diagnostic]:
219
+ """Find inertia.render() calls that reference non-existent pages."""
220
+ pages_dir = src_dir / "pages"
221
+ tsx_pages = self._collect_tsx_pages(pages_dir)
222
+ phantoms = rendered_pages - tsx_pages
223
+
224
+ return [
225
+ Diagnostic(
226
+ level=DiagnosticLevel.WARNING,
227
+ code="SM004",
228
+ message=f'inertia.render("{mod.meta.name}/{name}") but no {name}.tsx exists',
229
+ module_name=mod.meta.name,
230
+ suggestion=f"Create {pages_dir / f'{name}.tsx'}",
231
+ )
232
+ for name in phantoms
233
+ ]
234
+
235
+ def _find_render_calls(self, mod: ModuleBase, src_dir: Path) -> set[str]:
236
+ """Find inertia.render("Module/Page") calls, resolving module-level string consts."""
237
+ rendered: set[str] = set()
238
+ prefix = f"{mod.meta.name}/"
239
+ for py_file in src_dir.rglob("*.py"):
240
+ try:
241
+ tree = ast.parse(py_file.read_text(), filename=str(py_file))
242
+ except SyntaxError:
243
+ continue
244
+ for component in _iter_render_components(tree):
245
+ if component.startswith(prefix):
246
+ rendered.add(component[len(prefix) :])
247
+ return rendered
248
+
249
+ def _find_source_dir(self, mod: ModuleBase) -> Path | None:
250
+ """Locate the source directory for a module's package."""
251
+ pkg_name = type(mod).__module__.rsplit(".", 1)[0]
252
+ return self._find_package_dir(pkg_name)
@@ -0,0 +1,81 @@
1
+ """Entry points that assemble and print diagnostic output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from simple_module_core.diagnostics._migration import MigrationDiagnostics
11
+ from simple_module_core.diagnostics._module import ModuleDiagnostics
12
+ from simple_module_core.diagnostics._types import Diagnostic, DiagnosticLevel
13
+
14
+ if TYPE_CHECKING:
15
+ from simple_module_core.module import ModuleBase
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def run_diagnostics(
21
+ modules: list[ModuleBase],
22
+ *,
23
+ migration_state: dict | None = None,
24
+ module_tables: set[str] | None = None,
25
+ migrated_tables: set[str] | None = None,
26
+ i18n_supported_locales: list[str] | None = None,
27
+ i18n_default_locale: str | None = None,
28
+ i18n_extra_sources: list[tuple[str, str, Path]] | None = None,
29
+ ) -> list[Diagnostic]:
30
+ """Convenience function to run all diagnostics.
31
+
32
+ When ``migration_state`` is provided, also runs migration diagnostics.
33
+ When ``i18n_supported_locales`` and ``i18n_default_locale`` are provided,
34
+ also runs i18n locale coverage diagnostics. ``i18n_extra_sources`` lets
35
+ callers include host/ui locale dirs that aren't owned by a ``ModuleBase``.
36
+ """
37
+ diagnostics = ModuleDiagnostics().run(modules)
38
+
39
+ if i18n_supported_locales and i18n_default_locale:
40
+ from simple_module_core.diagnostics._i18n import I18nDiagnostics
41
+
42
+ diagnostics.extend(
43
+ I18nDiagnostics(
44
+ supported_locales=i18n_supported_locales,
45
+ default_locale=i18n_default_locale,
46
+ extra_sources=i18n_extra_sources,
47
+ ).run(modules)
48
+ )
49
+
50
+ if migration_state is not None:
51
+ migration_diag = MigrationDiagnostics()
52
+ diagnostics.extend(
53
+ migration_diag.check_revision_mismatch(
54
+ current_revision=migration_state.get("current_revision"),
55
+ head_revision=migration_state.get("head_revision"),
56
+ )
57
+ )
58
+ if module_tables is not None and migrated_tables is not None:
59
+ diagnostics.extend(migration_diag.check_table_coverage(module_tables, migrated_tables))
60
+
61
+ return diagnostics
62
+
63
+
64
+ def print_diagnostics(diagnostics: list[Diagnostic]) -> None:
65
+ """Pretty-print diagnostics to stderr."""
66
+ if not diagnostics:
67
+ logger.info("\u2713 No module diagnostics issues found")
68
+ return
69
+
70
+ errors = [d for d in diagnostics if d.level == DiagnosticLevel.ERROR]
71
+ warnings = [d for d in diagnostics if d.level == DiagnosticLevel.WARNING]
72
+ infos = [d for d in diagnostics if d.level == DiagnosticLevel.INFO]
73
+
74
+ for d in diagnostics:
75
+ print(str(d), file=sys.stderr)
76
+ print(file=sys.stderr)
77
+
78
+ print(
79
+ f"Results: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info",
80
+ file=sys.stderr,
81
+ )
@@ -0,0 +1,33 @@
1
+ """Core diagnostic types: level enum and finding dataclass."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class DiagnosticLevel(StrEnum):
10
+ ERROR = "error"
11
+ WARNING = "warning"
12
+ INFO = "info"
13
+
14
+
15
+ @dataclass
16
+ class Diagnostic:
17
+ """A single diagnostic finding."""
18
+
19
+ level: DiagnosticLevel
20
+ code: str
21
+ message: str
22
+ module_name: str
23
+ file: str | None = None
24
+ suggestion: str | None = None
25
+
26
+ def __str__(self) -> str:
27
+ prefix = {"error": "\u2717", "warning": "\u26a0", "info": "\u2139"}[self.level]
28
+ parts = [f"{prefix} {self.code} [{self.level.upper()}] {self.module_name}: {self.message}"]
29
+ if self.file:
30
+ parts.append(f" \u21b3 {self.file}")
31
+ if self.suggestion:
32
+ parts.append(f" \u21b3 Suggestion: {self.suggestion}")
33
+ return "\n".join(parts)
@@ -0,0 +1,195 @@
1
+ """Module discovery via Python entry_points and dependency ordering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Sequence
7
+ from importlib.metadata import entry_points
8
+
9
+ from simple_module_core.exceptions import CircularDependencyError, InvalidModuleError
10
+ from simple_module_core.module import ModuleBase, ModuleMeta
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ ENTRY_POINT_GROUP = "simple_module"
15
+
16
+
17
+ def get_module_package_name(module: ModuleBase) -> str:
18
+ """Return the top-level Python package a module instance belongs to.
19
+
20
+ Used by Alembic env.py, the frontend manifest generator, and diagnostics
21
+ to locate a module's files (models, pages, templates) on disk.
22
+ """
23
+ return type(module).__module__.split(".")[0]
24
+
25
+
26
+ def discover_modules(
27
+ enabled: Sequence[str] | None = None,
28
+ *,
29
+ strict: bool = False,
30
+ ) -> list[ModuleBase]:
31
+ """Discover all installed modules via ``[project.entry-points.simple_module]``.
32
+
33
+ Returns instantiated module objects (unsorted). Raises
34
+ :class:`FrameworkVersionError` if any discovered module's
35
+ ``requires_framework`` spec rejects the current framework API version.
36
+
37
+ Parameters
38
+ ----------
39
+ enabled:
40
+ Optional allowlist of module names (case-insensitive matched against
41
+ ``ModuleMeta.name``). When ``None`` (default), every installed module
42
+ is loaded. When a list, only modules whose name appears in it are
43
+ loaded. Names that don't match any installed module log a warning.
44
+ An empty list loads nothing.
45
+ strict:
46
+ When ``True``, any invalid module (failed to load, not a
47
+ ``ModuleBase`` subclass, missing/invalid ``meta``) raises
48
+ :class:`InvalidModuleError` immediately. Callers in production
49
+ should pass ``strict=True`` so a broken deployment fails loudly
50
+ at boot rather than silently losing a feature.
51
+
52
+ When ``False`` (the default — preserves dev ergonomics), invalid
53
+ modules are logged and skipped.
54
+ """
55
+ # Imported here to avoid a circular import at module load time.
56
+ from simple_module_core.versioning import check_framework_compatibility
57
+
58
+ def fail(msg: str, exc: BaseException | None = None) -> None:
59
+ if strict:
60
+ raise InvalidModuleError(msg) from exc
61
+ if exc is not None:
62
+ logger.exception("%s — skipping", msg)
63
+ else:
64
+ logger.error("%s — skipping", msg)
65
+
66
+ eps = entry_points(group=ENTRY_POINT_GROUP)
67
+ modules: list[ModuleBase] = []
68
+ allowlist_lower = {name.lower() for name in enabled} if enabled is not None else None
69
+
70
+ for ep in eps:
71
+ try:
72
+ module_cls = ep.load()
73
+ except Exception as exc:
74
+ fail(f"Failed to load module entry point '{ep.name}': {exc}", exc)
75
+ continue
76
+
77
+ try:
78
+ instance = module_cls()
79
+ except Exception as exc:
80
+ fail(
81
+ f"Failed to instantiate module '{ep.name}' ({module_cls!r}): {exc}",
82
+ exc,
83
+ )
84
+ continue
85
+
86
+ if not isinstance(instance, ModuleBase):
87
+ fail(
88
+ f"Entry point '{ep.name}' loaded {module_cls!r} which is not a ModuleBase subclass"
89
+ )
90
+ continue
91
+
92
+ meta = getattr(instance, "meta", None)
93
+ if not isinstance(meta, ModuleMeta):
94
+ fail(
95
+ f"Module {module_cls.__qualname__!r} (entry point '{ep.name}') "
96
+ "is missing 'meta = ModuleMeta(...)'"
97
+ )
98
+ continue
99
+
100
+ if allowlist_lower is not None and meta.name.lower() not in allowlist_lower:
101
+ logger.info(
102
+ "Module '%s' is installed but not in modules_enabled — skipping",
103
+ meta.name,
104
+ )
105
+ continue
106
+
107
+ modules.append(instance)
108
+ logger.info("Discovered module: %s (v%s)", meta.name, meta.version)
109
+
110
+ # Warn about allowlist entries that didn't resolve to an installed module.
111
+ if allowlist_lower is not None:
112
+ loaded = {m.meta.name.lower() for m in modules}
113
+ missing = allowlist_lower - loaded
114
+ for name in missing:
115
+ logger.warning(
116
+ "modules_enabled references '%s' which is not installed — ignoring",
117
+ name,
118
+ )
119
+
120
+ check_framework_compatibility(modules)
121
+ return modules
122
+
123
+
124
+ def topological_sort(modules: Sequence[ModuleBase]) -> list[ModuleBase]:
125
+ """Sort modules so dependencies come before dependents.
126
+
127
+ Raises ``CircularDependencyError`` if a cycle is detected.
128
+ """
129
+ by_name: dict[str, ModuleBase] = {m.meta.name: m for m in modules}
130
+
131
+ # Kahn's algorithm
132
+ in_degree: dict[str, int] = dict.fromkeys(by_name, 0)
133
+ dependents: dict[str, list[str]] = {name: [] for name in by_name}
134
+
135
+ for mod in modules:
136
+ for dep_name in mod.meta.depends_on:
137
+ if dep_name not in by_name:
138
+ logger.warning(
139
+ "Module '%s' depends on '%s' which is not installed — ignoring",
140
+ mod.meta.name,
141
+ dep_name,
142
+ )
143
+ continue
144
+ in_degree[mod.meta.name] += 1
145
+ dependents[dep_name].append(mod.meta.name)
146
+
147
+ queue: list[str] = [name for name, deg in in_degree.items() if deg == 0]
148
+ result: list[str] = []
149
+
150
+ while queue:
151
+ # Sort queue for deterministic ordering
152
+ queue.sort()
153
+ current = queue.pop(0)
154
+ result.append(current)
155
+ for dependent in dependents[current]:
156
+ in_degree[dependent] -= 1
157
+ if in_degree[dependent] == 0:
158
+ queue.append(dependent)
159
+
160
+ if len(result) != len(by_name):
161
+ # Find the cycle for a helpful error message
162
+ remaining = set(by_name.keys()) - set(result)
163
+ cycle = _find_cycle(remaining, {n: m.meta.depends_on for n, m in by_name.items()})
164
+ raise CircularDependencyError(cycle)
165
+
166
+ return [by_name[name] for name in result]
167
+
168
+
169
+ def _find_cycle(nodes: set[str], deps: dict[str, list[str]]) -> list[str]:
170
+ """Find one cycle in the dependency graph for error reporting."""
171
+ visited: set[str] = set()
172
+ path: list[str] = []
173
+
174
+ def dfs(node: str) -> list[str] | None:
175
+ if node in visited:
176
+ idx = path.index(node) if node in path else 0
177
+ return [*path[idx:], node]
178
+ visited.add(node)
179
+ path.append(node)
180
+ for dep in deps.get(node, []):
181
+ if dep in nodes:
182
+ result = dfs(dep)
183
+ if result:
184
+ return result
185
+ path.pop()
186
+ return None
187
+
188
+ for node in nodes:
189
+ visited.clear()
190
+ path.clear()
191
+ cycle = dfs(node)
192
+ if cycle:
193
+ return cycle
194
+
195
+ return list(nodes) # fallback
@@ -0,0 +1,38 @@
1
+ """Minimal ``.env`` parser — dependency-free.
2
+
3
+ Used in places that can't or shouldn't pull in ``pydantic-settings`` (the
4
+ diagnostics CLI runs before the host package is imported; the users-module
5
+ bootstrap runs after settings are constructed and needs to read values that
6
+ ``UsersSettings`` deliberately omits from ``env_file``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+
14
+
15
+ def parse_dotenv(path: Path | None = None) -> dict[str, str]:
16
+ """Parse a ``.env`` file into a dict. Empty dict if the file is missing.
17
+
18
+ Values surrounded by matching single or double quotes have the quotes
19
+ stripped. Does *not* handle escapes, ``export KEY=…``, or multiline
20
+ values — keep the file simple. Does *not* mutate ``os.environ``; the
21
+ caller decides whether to merge.
22
+
23
+ Without ``path``, looks up ``$SM_PROJECT_ROOT/.env`` (falling back to
24
+ ``$CWD/.env``) — the convention used by every tool in this repo.
25
+ """
26
+ if path is None:
27
+ root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
28
+ path = root / ".env"
29
+ if not path.is_file():
30
+ return {}
31
+ parsed: dict[str, str] = {}
32
+ for raw in path.read_text(encoding="utf-8").splitlines():
33
+ line = raw.strip()
34
+ if not line or line.startswith("#") or "=" not in line:
35
+ continue
36
+ key, _, value = line.partition("=")
37
+ parsed[key.strip()] = value.strip().strip('"').strip("'")
38
+ return parsed
@@ -0,0 +1,15 @@
1
+ """Shared environment-classification constants.
2
+
3
+ Both the host and module settings validators need to know which ``SM_ENVIRONMENT``
4
+ values are "non-prod" — anything outside this set is treated as production
5
+ and subject to stricter defaults (e.g. placeholder-secret rejection).
6
+
7
+ Duplicating this constant in each settings module would mean an operator who
8
+ adds ``"staging"`` to one list but forgets the other gets inconsistent
9
+ validation. Lives in ``simple_module_core`` because both the host package and
10
+ module settings already depend on it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ NON_PROD_ENVIRONMENTS: frozenset[str] = frozenset({"development", "testing"})