simple-module-core 0.0.30__tar.gz → 0.0.31__tar.gz
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.
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/.gitignore +1 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/PKG-INFO +2 -2
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/pyproject.toml +1 -1
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/__init__.py +6 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/__main__.py +8 -3
- simple_module_core-0.0.31/simple_module_core/audit_links.py +84 -0
- simple_module_core-0.0.31/simple_module_core/csp.py +140 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_module.py +1 -0
- simple_module_core-0.0.31/simple_module_core/dotenv.py +141 -0
- simple_module_core-0.0.31/simple_module_core/health.py +84 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/menu.py +20 -1
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/module.py +41 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/services.py +2 -0
- simple_module_core-0.0.31/tests/test_audit_links.py +57 -0
- simple_module_core-0.0.31/tests/test_csp_registry.py +116 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_dotenv.py +21 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_health_registry.py +35 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_menu.py +49 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_services.py +3 -0
- simple_module_core-0.0.30/simple_module_core/dotenv.py +0 -69
- simple_module_core-0.0.30/simple_module_core/health.py +0 -46
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/LICENSE +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/README.md +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/design_packs.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/__init__.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_coupling.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_css.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_i18n.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_inertia_api.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_js_workspace.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_migration.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_pages.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_runner.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_types.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/discovery.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/environments.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/events.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/exceptions.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/feature_flags.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/i18n.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/permissions.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/public_routes.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/py.typed +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/versioning.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_auth_provider_selection.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_css_diagnostics.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_css_scanner.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_design_packs.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_diagnostics.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_discovery.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_environments.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_events.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_feature_flags.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_feature_flags_decorator.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_i18n.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_i18n_diagnostics.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_module_base.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_module_diagnostics.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_permissions.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_public_routes.py +0 -0
- {simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_versioning.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_core
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.31
|
|
4
4
|
Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""SimpleModule Core - Module system, menu, permissions, events, and diagnostics."""
|
|
2
2
|
|
|
3
|
+
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
|
|
4
|
+
from simple_module_core.csp import CspSourceError, CspSourceRegistry
|
|
3
5
|
from simple_module_core.design_packs import DesignPack, DesignPackRegistry
|
|
4
6
|
from simple_module_core.diagnostics import (
|
|
5
7
|
DiagnosticLevel,
|
|
@@ -44,7 +46,11 @@ from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework
|
|
|
44
46
|
__all__ = [
|
|
45
47
|
"DEFAULT_AUTH_PROVIDER",
|
|
46
48
|
"FRAMEWORK_API_VERSION",
|
|
49
|
+
"AuditLink",
|
|
50
|
+
"AuditLinkRegistry",
|
|
47
51
|
"CircularDependencyError",
|
|
52
|
+
"CspSourceError",
|
|
53
|
+
"CspSourceRegistry",
|
|
48
54
|
"DesignPack",
|
|
49
55
|
"DesignPackRegistry",
|
|
50
56
|
"DiagnosticLevel",
|
|
@@ -9,7 +9,8 @@ Exits with status 1 if any ERROR-level diagnostics are reported.
|
|
|
9
9
|
|
|
10
10
|
i18n checks are included when ``SM_I18N_SUPPORTED_LOCALES`` is set in env
|
|
11
11
|
(or ``.env``). Host-level ``host/locales/`` and shared ``packages/ui/locales/``
|
|
12
|
-
are picked up relative to ``SM_PROJECT_ROOT
|
|
12
|
+
are picked up relative to the project root (``SM_PROJECT_ROOT``, else the
|
|
13
|
+
directory of the discovered ``.env``, else the current working dir).
|
|
13
14
|
"""
|
|
14
15
|
|
|
15
16
|
from __future__ import annotations
|
|
@@ -31,7 +32,7 @@ from simple_module_core.discovery import (
|
|
|
31
32
|
select_auth_provider,
|
|
32
33
|
topological_sort,
|
|
33
34
|
)
|
|
34
|
-
from simple_module_core.dotenv import parse_dotenv
|
|
35
|
+
from simple_module_core.dotenv import find_env_file, parse_dotenv
|
|
35
36
|
from simple_module_core.exceptions import InvalidModuleError
|
|
36
37
|
|
|
37
38
|
|
|
@@ -64,7 +65,11 @@ def _load_i18n_settings_from_env() -> tuple[list[str], str] | tuple[None, None]:
|
|
|
64
65
|
|
|
65
66
|
def _discover_extra_locale_sources() -> list[tuple[str, str, Path]]:
|
|
66
67
|
"""Return ``[(reporter, namespace, path), ...]`` for host + ui locale dirs."""
|
|
67
|
-
root
|
|
68
|
+
# Anchor on the same project root the `.env` was loaded from
|
|
69
|
+
# (`parse_dotenv` walks up from the cwd) — resolving against the bare cwd
|
|
70
|
+
# here would look for `host/locales` in the wrong directory whenever
|
|
71
|
+
# doctor runs from a subdirectory.
|
|
72
|
+
root = find_env_file().parent
|
|
68
73
|
out: list[tuple[str, str, Path]] = []
|
|
69
74
|
host_locales = root / "host" / "locales"
|
|
70
75
|
if host_locales.is_dir():
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Audit-link registry — modules teach the audit log how to reach their records.
|
|
2
|
+
|
|
3
|
+
An audit entry stores the *model class name* and primary key of the row that
|
|
4
|
+
changed (``StoredFile``, ``a91f3c2b…`` — ``snapshot_changes`` records
|
|
5
|
+
``type(obj).__name__``, never ``__tablename__``). That is enough to prove what
|
|
6
|
+
happened and useless for doing anything about it: the reader has an id and no
|
|
7
|
+
way to open the record it names.
|
|
8
|
+
|
|
9
|
+
A module declares where its rows live via
|
|
10
|
+
:meth:`~simple_module_core.module.ModuleBase.register_audit_links`; the host
|
|
11
|
+
collects them into one registry at boot and stores it on
|
|
12
|
+
``app.state.sm.audit_links``.
|
|
13
|
+
|
|
14
|
+
**The registry maps model class names to URL templates, nothing more.** It does
|
|
15
|
+
not verify the row exists or that the reader may open it — following a link to a
|
|
16
|
+
deleted record lands on that screen's own 404, and permissions are enforced by
|
|
17
|
+
the target route as usual.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
_ID_PLACEHOLDER = "{id}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AuditLink:
|
|
29
|
+
"""Where the records of one audited model can be viewed.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
entity_type: The **model class name**, matching
|
|
33
|
+
``AuditEntry.entity_type`` (e.g. ``"User"``, not ``"users_user"``).
|
|
34
|
+
``snapshot_changes`` records ``type(obj).__name__``, so keying this
|
|
35
|
+
off ``__tablename__`` silently never matches — and because an
|
|
36
|
+
unmatched lookup falls back to showing ``entity_type`` as the
|
|
37
|
+
label, a table-name key looks like it worked.
|
|
38
|
+
url_template: Path containing ``{id}``, substituted with the entity id
|
|
39
|
+
(e.g. ``"/admin/users/{id}/edit"``).
|
|
40
|
+
label: Human-readable name for the entity kind, shown instead of the
|
|
41
|
+
raw class name (e.g. ``"User account"``).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
entity_type: str
|
|
45
|
+
url_template: str
|
|
46
|
+
label: str = ""
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if _ID_PLACEHOLDER not in self.url_template:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"AuditLink for {self.entity_type!r} has url_template "
|
|
52
|
+
f"{self.url_template!r}, which contains no {_ID_PLACEHOLDER} — "
|
|
53
|
+
f"every row would link to the same page"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def url_for(self, entity_id: str) -> str:
|
|
57
|
+
return self.url_template.replace(_ID_PLACEHOLDER, entity_id)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuditLinkRegistry:
|
|
61
|
+
"""Aggregates every module's :class:`AuditLink` declarations.
|
|
62
|
+
|
|
63
|
+
Populated once during boot (``register_audit_links`` hook) and read
|
|
64
|
+
thereafter by the audit log view when it renders each row.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._links: dict[str, AuditLink] = {}
|
|
69
|
+
|
|
70
|
+
def register(self, link: AuditLink) -> None:
|
|
71
|
+
existing = self._links.get(link.entity_type)
|
|
72
|
+
if existing is not None and existing != link:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Two modules claim audit links for {link.entity_type!r}: "
|
|
75
|
+
f"{existing.url_template!r} and {link.url_template!r}"
|
|
76
|
+
)
|
|
77
|
+
self._links[link.entity_type] = link
|
|
78
|
+
|
|
79
|
+
def get(self, entity_type: str) -> AuditLink | None:
|
|
80
|
+
return self._links.get(entity_type)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def all_links(self) -> dict[str, AuditLink]:
|
|
84
|
+
return dict(self._links)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Registry for module-contributed Content-Security-Policy sources.
|
|
2
|
+
|
|
3
|
+
Modules whose frontend loads assets from an external origin (a font CDN, a
|
|
4
|
+
tile server, an analytics endpoint) declare those origins through
|
|
5
|
+
``ModuleBase.register_csp_sources``. The host folds them into the CSP it
|
|
6
|
+
already ships — the module never rewrites the whole policy, and a typo'd
|
|
7
|
+
origin fails loudly at boot instead of silently weakening the header.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
__all__ = ["CspSourceError", "CspSourceRegistry"]
|
|
15
|
+
|
|
16
|
+
# Fetch directives a module may extend. Deliberately excludes the policy's
|
|
17
|
+
# structural directives (default-src, base-uri, form-action, frame-ancestors,
|
|
18
|
+
# sandbox): widening those changes the security posture of the whole app and
|
|
19
|
+
# belongs to the host operator, not a module.
|
|
20
|
+
_EXTENDABLE_DIRECTIVES = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"script-src",
|
|
23
|
+
"script-src-elem",
|
|
24
|
+
"style-src",
|
|
25
|
+
"style-src-elem",
|
|
26
|
+
"img-src",
|
|
27
|
+
"font-src",
|
|
28
|
+
"connect-src",
|
|
29
|
+
"media-src",
|
|
30
|
+
"frame-src",
|
|
31
|
+
"worker-src",
|
|
32
|
+
"child-src",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# A source is a scheme, an origin (optionally with scheme/wildcard/port), or
|
|
37
|
+
# a data-ish scheme keyword. One token — anything that could smuggle a second
|
|
38
|
+
# token or terminate/extend the clause (whitespace, ";", ",", quotes) is
|
|
39
|
+
# rejected, and a wildcard prefix must be followed by a real host (`*.` alone
|
|
40
|
+
# is not a source).
|
|
41
|
+
_SOURCE_RE = re.compile(r"^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://)?)?(?:(?:\*\.)?[^\s;,'\"*]+)?$")
|
|
42
|
+
|
|
43
|
+
# CSP3 fallback chains: when a directive is absent from a policy, the browser
|
|
44
|
+
# consults these directives in order (ending at ``default-src``). A clause we
|
|
45
|
+
# append for a previously-absent directive must be seeded from the nearest
|
|
46
|
+
# clause the policy already has along this chain — seeding with a bare
|
|
47
|
+
# ``'self'`` would *narrow* the policy (e.g. a fresh ``style-src-elem`` clause
|
|
48
|
+
# cuts off the fallback to ``style-src`` and silently drops its
|
|
49
|
+
# ``'unsafe-inline'`` and font origins).
|
|
50
|
+
_FALLBACK_CHAINS: dict[str, tuple[str, ...]] = {
|
|
51
|
+
"script-src-elem": ("script-src", "default-src"),
|
|
52
|
+
"style-src-elem": ("style-src", "default-src"),
|
|
53
|
+
"frame-src": ("child-src", "default-src"),
|
|
54
|
+
"worker-src": ("child-src", "script-src", "default-src"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CspSourceError(ValueError):
|
|
59
|
+
"""Invalid CSP directive or source declared by a module."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CspSourceRegistry:
|
|
63
|
+
"""Collects per-directive extra CSP sources from modules."""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._sources: dict[str, list[str]] = {}
|
|
67
|
+
|
|
68
|
+
def add(self, directive: str, source: str) -> None:
|
|
69
|
+
"""Allow ``source`` in ``directive``, e.g. ``add("style-src", "https://rsms.me")``."""
|
|
70
|
+
if directive not in _EXTENDABLE_DIRECTIVES:
|
|
71
|
+
raise CspSourceError(
|
|
72
|
+
f"CSP directive {directive!r} is not extendable; "
|
|
73
|
+
f"choose one of {sorted(_EXTENDABLE_DIRECTIVES)}"
|
|
74
|
+
)
|
|
75
|
+
token = source.strip()
|
|
76
|
+
if not token or not _SOURCE_RE.match(token):
|
|
77
|
+
raise CspSourceError(
|
|
78
|
+
f"invalid CSP source {source!r} for {directive}: must be a single "
|
|
79
|
+
"origin or scheme token (no spaces, quotes, wildcards-only, or ';')"
|
|
80
|
+
)
|
|
81
|
+
bucket = self._sources.setdefault(directive, [])
|
|
82
|
+
if token not in bucket:
|
|
83
|
+
bucket.append(token)
|
|
84
|
+
|
|
85
|
+
def __bool__(self) -> bool:
|
|
86
|
+
return bool(self._sources)
|
|
87
|
+
|
|
88
|
+
def extend_policy(self, policy: str) -> str:
|
|
89
|
+
"""Fold the registered sources into an existing policy string.
|
|
90
|
+
|
|
91
|
+
Existing clauses keep their order and gain only sources they don't
|
|
92
|
+
already list. A directive absent from the policy is appended as a new
|
|
93
|
+
clause seeded from the clause the browser would otherwise have fallen
|
|
94
|
+
back to (per ``_FALLBACK_CHAINS``, ending at ``default-src``) —
|
|
95
|
+
without that seed the new clause would *narrow* the policy, since the
|
|
96
|
+
browser stops consulting the fallback once the directive exists.
|
|
97
|
+
"""
|
|
98
|
+
if not self._sources:
|
|
99
|
+
return policy
|
|
100
|
+
directives: dict[str, list[str]] = {}
|
|
101
|
+
order: list[str] = []
|
|
102
|
+
for clause in policy.split(";"):
|
|
103
|
+
clause = clause.strip()
|
|
104
|
+
if not clause:
|
|
105
|
+
continue
|
|
106
|
+
directive, _, rest = clause.partition(" ")
|
|
107
|
+
directives[directive] = rest.split()
|
|
108
|
+
order.append(directive)
|
|
109
|
+
for directive, extras in self._sources.items():
|
|
110
|
+
if directive not in directives:
|
|
111
|
+
directives[directive] = list(self._seed_sources(directive, directives))
|
|
112
|
+
order.append(directive)
|
|
113
|
+
bucket = directives[directive]
|
|
114
|
+
bucket.extend(e for e in extras if e not in bucket)
|
|
115
|
+
# A more-specific directive *shadows* its fallback chain once present:
|
|
116
|
+
# with `script-src-elem` already in the policy, a module's
|
|
117
|
+
# `script-src` addition would never reach <script> elements — the
|
|
118
|
+
# very load the module declared it for. The same shadowing applies to
|
|
119
|
+
# every pair `_FALLBACK_CHAINS` encodes (an existing `frame-src` or
|
|
120
|
+
# `worker-src` clause shadows a module's `child-src` extras, and
|
|
121
|
+
# `worker-src` shadows `script-src` too). Mirror base-directive
|
|
122
|
+
# extras into each present shadowing clause so the declaration keeps
|
|
123
|
+
# exactly the meaning the fallback would have given it.
|
|
124
|
+
for shadowing, chain in _FALLBACK_CHAINS.items():
|
|
125
|
+
if shadowing not in directives:
|
|
126
|
+
continue
|
|
127
|
+
bucket = directives[shadowing]
|
|
128
|
+
for base in chain:
|
|
129
|
+
extras = self._sources.get(base)
|
|
130
|
+
if extras:
|
|
131
|
+
bucket.extend(e for e in extras if e not in bucket)
|
|
132
|
+
return "; ".join(f"{d} {' '.join(directives[d])}".rstrip() for d in order)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _seed_sources(directive: str, directives: dict[str, list[str]]) -> list[str]:
|
|
136
|
+
"""Sources a brand-new clause inherits from its CSP fallback chain."""
|
|
137
|
+
for fallback in (*_FALLBACK_CHAINS.get(directive, ()), "default-src"):
|
|
138
|
+
if fallback in directives:
|
|
139
|
+
return directives[fallback]
|
|
140
|
+
return ["'self'"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Minimal ``.env`` parser + env-var helpers — 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
|
+
BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
|
|
15
|
+
BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})
|
|
16
|
+
|
|
17
|
+
# How many parent directories to probe for a `.env` above the cwd. One level
|
|
18
|
+
# covers the workspace layout (`host/` → root); a couple more cover running
|
|
19
|
+
# from `modules/<name>/`. Bounded so an unrelated `.env` far up the tree
|
|
20
|
+
# (e.g. in $HOME) is never picked up by accident.
|
|
21
|
+
_ENV_WALK_LIMIT = 4
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_env_file() -> Path:
|
|
25
|
+
"""Locate the project ``.env`` regardless of which subdirectory runs us.
|
|
26
|
+
|
|
27
|
+
``$SM_PROJECT_ROOT/.env`` wins when set. Otherwise walk up from the cwd:
|
|
28
|
+
the web process chdirs to the workspace root so this finds ``./.env``
|
|
29
|
+
immediately, while a CLI invoked from ``host/`` or ``modules/<name>/``
|
|
30
|
+
finds the same file its app uses instead of silently loading nothing.
|
|
31
|
+
When the walk hits a project-root marker (``.git`` / ``.env.example``)
|
|
32
|
+
without finding a ``.env``, the returned path is ``<root>/.env`` — it may
|
|
33
|
+
not exist, but its *parent* still anchors relative sqlite paths at the
|
|
34
|
+
project root (a fresh scaffold has ``.env.example`` before any ``.env``).
|
|
35
|
+
Falls back to a cwd-relative ``Path(".env")`` when nothing is found.
|
|
36
|
+
|
|
37
|
+
This is the one .env-resolution convention for the whole ecosystem: the
|
|
38
|
+
settings layer (``BootstrapSettings``) and every out-of-process tool
|
|
39
|
+
(diagnostics CLI, worker entrypoints, users bootstrap) resolve through
|
|
40
|
+
here, so they can never disagree about which file is in effect. Compare
|
|
41
|
+
``app_builder._resolve_project_root`` in the hosting package — a
|
|
42
|
+
separate walk that anchors the static/i18n root instead; the two are
|
|
43
|
+
kept distinct on purpose (see that function's docstring).
|
|
44
|
+
"""
|
|
45
|
+
explicit = os.environ.get("SM_PROJECT_ROOT")
|
|
46
|
+
if explicit:
|
|
47
|
+
return Path(explicit) / ".env"
|
|
48
|
+
current = Path.cwd()
|
|
49
|
+
try:
|
|
50
|
+
home: Path | None = Path.home()
|
|
51
|
+
except RuntimeError:
|
|
52
|
+
# $HOME unset and no passwd entry for the UID (rootless containers,
|
|
53
|
+
# some CI sandboxes) — Path.home() can't resolve. Skip the
|
|
54
|
+
# home-boundary check rather than crash; the walk is still bounded
|
|
55
|
+
# by _ENV_WALK_LIMIT and the world-writable-dir guard below.
|
|
56
|
+
home = None
|
|
57
|
+
for candidate in (current, *current.parents[:_ENV_WALK_LIMIT]):
|
|
58
|
+
if home is not None and candidate == home:
|
|
59
|
+
break
|
|
60
|
+
# Never probe a shared world-writable ancestor (/tmp, /var/tmp):
|
|
61
|
+
# its `.env` could belong to anyone — including another local user —
|
|
62
|
+
# and callers merge every key of the discovered file into os.environ.
|
|
63
|
+
# The starting cwd itself is exempt: running *from* such a directory
|
|
64
|
+
# keeps the pre-existing "load the cwd's .env" behavior via the fallback.
|
|
65
|
+
if candidate != current and _is_world_writable_dir(candidate):
|
|
66
|
+
break
|
|
67
|
+
env = candidate / ".env"
|
|
68
|
+
if env.is_file():
|
|
69
|
+
return env
|
|
70
|
+
# A `.git` or `.env.example` marks a project root: never ascend past
|
|
71
|
+
# one, or a nested checkout (a git worktree, a repo inside another
|
|
72
|
+
# repo, a fresh scaffold — which ships `.env.example` before any
|
|
73
|
+
# `.git` exists) would silently load the *outer* project's `.env`.
|
|
74
|
+
# The boundary directory IS the project root, so anchor there: a
|
|
75
|
+
# fresh scaffold with only `.env.example` must still resolve
|
|
76
|
+
# relative sqlite paths against its root, not the caller's cwd.
|
|
77
|
+
if (candidate / ".git").exists() or (candidate / ".env.example").is_file():
|
|
78
|
+
return candidate / ".env"
|
|
79
|
+
return Path(".env")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_world_writable_dir(path: Path) -> bool:
|
|
83
|
+
"""True for shared scratch dirs like ``/tmp`` (world-writable, sticky or not)."""
|
|
84
|
+
try:
|
|
85
|
+
mode = path.stat().st_mode
|
|
86
|
+
except OSError:
|
|
87
|
+
return False
|
|
88
|
+
return bool(mode & 0o002)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_dotenv(path: Path | None = None) -> dict[str, str]:
|
|
92
|
+
"""Parse a ``.env`` file into a dict. Empty dict if the file is missing.
|
|
93
|
+
|
|
94
|
+
Values surrounded by matching single or double quotes have the quotes
|
|
95
|
+
stripped. Does *not* handle escapes, ``export KEY=…``, or multiline
|
|
96
|
+
values — keep the file simple. Does *not* mutate ``os.environ``; the
|
|
97
|
+
caller decides whether to merge.
|
|
98
|
+
|
|
99
|
+
Without ``path``, resolves via :func:`find_env_file` — the convention
|
|
100
|
+
used by every tool in this repo.
|
|
101
|
+
"""
|
|
102
|
+
if path is None:
|
|
103
|
+
path = find_env_file()
|
|
104
|
+
if not path.is_file():
|
|
105
|
+
return {}
|
|
106
|
+
parsed: dict[str, str] = {}
|
|
107
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
108
|
+
line = raw.strip()
|
|
109
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
110
|
+
continue
|
|
111
|
+
key, _, value = line.partition("=")
|
|
112
|
+
parsed[key.strip()] = value.strip().strip('"').strip("'")
|
|
113
|
+
return parsed
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_dotenv_into_environ(path: Path | None = None) -> None:
|
|
117
|
+
"""Merge ``parse_dotenv(path)`` into ``os.environ`` via ``setdefault``.
|
|
118
|
+
|
|
119
|
+
Same precedence as the web process under uvicorn: real environment wins
|
|
120
|
+
over file values. Worker entrypoints call this before importing settings.
|
|
121
|
+
"""
|
|
122
|
+
for key, value in parse_dotenv(path).items():
|
|
123
|
+
os.environ.setdefault(key, value)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def env_str(name: str, default: str) -> str:
|
|
127
|
+
"""Return ``$name`` if set and non-empty, else ``default``."""
|
|
128
|
+
value = os.environ.get(name, "").strip()
|
|
129
|
+
return value or default
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def env_bool(name: str, default: bool = False) -> bool:
|
|
133
|
+
"""Parse ``$name`` as a boolean, returning ``default`` when unset/blank."""
|
|
134
|
+
raw = os.environ.get(name, "").strip().lower()
|
|
135
|
+
if not raw:
|
|
136
|
+
return default
|
|
137
|
+
if raw in BOOL_LITERALS_TRUE:
|
|
138
|
+
return True
|
|
139
|
+
if raw in BOOL_LITERALS_FALSE:
|
|
140
|
+
return False
|
|
141
|
+
return default
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Health check registry for module-contributed health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HealthStatus(StrEnum):
|
|
11
|
+
HEALTHY = "healthy"
|
|
12
|
+
DEGRADED = "degraded"
|
|
13
|
+
UNHEALTHY = "unhealthy"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class HealthCheckResult:
|
|
18
|
+
"""Result of a single health check."""
|
|
19
|
+
|
|
20
|
+
status: HealthStatus
|
|
21
|
+
detail: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
HealthCheckFn = Callable[[], Awaitable[HealthCheckResult]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class HealthCheck:
|
|
29
|
+
"""A named health check with an async callable."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
check: HealthCheckFn
|
|
33
|
+
module: str = ""
|
|
34
|
+
"""Module that contributed the check. Stamped by the registry during
|
|
35
|
+
``register_health_checks``; module authors never set it by hand."""
|
|
36
|
+
probe: bool = True
|
|
37
|
+
"""Whether automatic pollers may run this check.
|
|
38
|
+
|
|
39
|
+
Set ``False`` for checks that reach a third party — an SMTP login, an S3
|
|
40
|
+
request. Readiness is asked on a timer (a Kubernetes probe every 10s), and
|
|
41
|
+
a check that authenticates against a mail provider on that schedule earns
|
|
42
|
+
a rate-limit and binds probe latency to someone else's uptime. Such a
|
|
43
|
+
dependency is also not a readiness signal: the app serves pages fine while
|
|
44
|
+
its mailer is down.
|
|
45
|
+
|
|
46
|
+
``False`` checks still run when explicitly requested — that is what the
|
|
47
|
+
"Test connection" action on the module-settings screen invokes.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HealthRegistry:
|
|
52
|
+
"""Collects health checks contributed by modules."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._checks: list[HealthCheck] = []
|
|
56
|
+
self._current_owner: str = ""
|
|
57
|
+
|
|
58
|
+
def set_owner(self, module_name: str) -> None:
|
|
59
|
+
"""Attribute subsequently-added checks to ``module_name``.
|
|
60
|
+
|
|
61
|
+
The host calls this around each ``register_health_checks`` hook so a
|
|
62
|
+
check knows which module it belongs to without changing the ``add``
|
|
63
|
+
signature module authors already use. Attribution is what lets the
|
|
64
|
+
dashboard show health per module rather than one global number.
|
|
65
|
+
"""
|
|
66
|
+
self._current_owner = module_name
|
|
67
|
+
|
|
68
|
+
def add(self, check: HealthCheck) -> None:
|
|
69
|
+
if not check.module:
|
|
70
|
+
check.module = self._current_owner
|
|
71
|
+
self._checks.append(check)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def all_checks(self) -> list[HealthCheck]:
|
|
75
|
+
"""Every registered check, including on-demand ones.
|
|
76
|
+
|
|
77
|
+
Callers that poll on a timer want :attr:`probe_checks` instead.
|
|
78
|
+
"""
|
|
79
|
+
return list(self._checks)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def probe_checks(self) -> list[HealthCheck]:
|
|
83
|
+
"""Checks safe to run automatically, on a timer."""
|
|
84
|
+
return [c for c in self._checks if c.probe]
|
|
@@ -30,6 +30,15 @@ class MenuItem:
|
|
|
30
30
|
requires_auth: bool = True
|
|
31
31
|
roles: list[str] = field(default_factory=list)
|
|
32
32
|
"""Empty list = visible to all authenticated users."""
|
|
33
|
+
permissions: list[str] = field(default_factory=list)
|
|
34
|
+
"""Permission keys required to see this entry. Empty = no permission check.
|
|
35
|
+
|
|
36
|
+
Declare the same permission the target route enforces. Roles alone cannot
|
|
37
|
+
express this: a custom role holding ``settings.view`` should see Settings,
|
|
38
|
+
and hard-coding ``roles=["admin"]`` would hide it from them while still
|
|
39
|
+
showing it to any admin-adjacent role that cannot actually open it. An
|
|
40
|
+
entry that 403s on click is worse than no entry at all.
|
|
41
|
+
"""
|
|
33
42
|
method: MenuItemMethod = "get"
|
|
34
43
|
"""HTTP method used when the item is activated. ``"post"`` renders as an
|
|
35
44
|
Inertia form submission so the target endpoint can be POST-only (e.g. logout)."""
|
|
@@ -69,12 +78,18 @@ class MenuRegistry:
|
|
|
69
78
|
*,
|
|
70
79
|
is_authenticated: bool,
|
|
71
80
|
roles: list[str] | None = None,
|
|
81
|
+
permissions: list[str] | None = None,
|
|
72
82
|
) -> dict[str, list[dict]]:
|
|
73
|
-
"""Return menu items grouped by section, filtered by auth/roles.
|
|
83
|
+
"""Return menu items grouped by section, filtered by auth/roles/permissions.
|
|
84
|
+
|
|
85
|
+
``permissions`` is the caller's already-expanded permission list (no
|
|
86
|
+
wildcards). Items declaring permissions the caller lacks are dropped,
|
|
87
|
+
so the sidebar never offers a screen that will 403 on click.
|
|
74
88
|
|
|
75
89
|
Returns a dict ready to be serialized into Inertia shared props.
|
|
76
90
|
"""
|
|
77
91
|
roles = roles or []
|
|
92
|
+
granted = set(permissions or [])
|
|
78
93
|
result: dict[str, list[dict]] = {s.value: [] for s in MenuSection}
|
|
79
94
|
|
|
80
95
|
for item in self.all_items:
|
|
@@ -82,6 +97,10 @@ class MenuRegistry:
|
|
|
82
97
|
continue
|
|
83
98
|
if item.roles and not any(r in item.roles for r in roles):
|
|
84
99
|
continue
|
|
100
|
+
# All declared permissions must be held: an entry naming several is
|
|
101
|
+
# asking for all of them, matching how route guards compose.
|
|
102
|
+
if item.permissions and not granted.issuperset(item.permissions):
|
|
103
|
+
continue
|
|
85
104
|
result[item.section.value].append(
|
|
86
105
|
{
|
|
87
106
|
"label": item.label,
|
|
@@ -10,6 +10,8 @@ from typing import TYPE_CHECKING
|
|
|
10
10
|
if TYPE_CHECKING:
|
|
11
11
|
from fastapi import APIRouter, FastAPI
|
|
12
12
|
|
|
13
|
+
from simple_module_core.audit_links import AuditLinkRegistry
|
|
14
|
+
from simple_module_core.csp import CspSourceRegistry
|
|
13
15
|
from simple_module_core.design_packs import DesignPackRegistry
|
|
14
16
|
from simple_module_core.events import EventBus
|
|
15
17
|
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
@@ -147,6 +149,23 @@ class ModuleBase(ABC):
|
|
|
147
149
|
mutations. Called once at boot, in dependency order.
|
|
148
150
|
"""
|
|
149
151
|
|
|
152
|
+
def register_csp_sources(self, registry: CspSourceRegistry) -> None:
|
|
153
|
+
"""Declare external origins this module's frontend loads assets from.
|
|
154
|
+
|
|
155
|
+
The host ships a strict Content-Security-Policy; a module whose
|
|
156
|
+
pages pull a stylesheet, font, or API from another origin must
|
|
157
|
+
declare it here or the browser blocks the request::
|
|
158
|
+
|
|
159
|
+
def register_csp_sources(self, registry):
|
|
160
|
+
registry.add("style-src", "https://rsms.me")
|
|
161
|
+
registry.add("font-src", "https://rsms.me")
|
|
162
|
+
|
|
163
|
+
Only fetch directives can be extended (never default-src,
|
|
164
|
+
base-uri, form-action, frame-ancestors), and each source must be a
|
|
165
|
+
single origin/scheme token — invalid declarations raise at boot.
|
|
166
|
+
Called once, in dependency order.
|
|
167
|
+
"""
|
|
168
|
+
|
|
150
169
|
def register_design_packs(self, registry: DesignPackRegistry) -> None:
|
|
151
170
|
"""Declare design packs this module ships for the public site.
|
|
152
171
|
|
|
@@ -163,6 +182,28 @@ class ModuleBase(ABC):
|
|
|
163
182
|
order.
|
|
164
183
|
"""
|
|
165
184
|
|
|
185
|
+
def register_audit_links(self, registry: AuditLinkRegistry) -> None:
|
|
186
|
+
"""Declare where this module's audited records can be viewed.
|
|
187
|
+
|
|
188
|
+
An audit entry stores a table name and a primary key, which tells the
|
|
189
|
+
reader what changed but gives them no way to open it. Override this
|
|
190
|
+
hook to make your module's rows reachable from the audit log::
|
|
191
|
+
|
|
192
|
+
def register_audit_links(self, registry):
|
|
193
|
+
registry.register(
|
|
194
|
+
AuditLink(
|
|
195
|
+
entity_type="users_user",
|
|
196
|
+
url_template="/admin/users/{id}/edit",
|
|
197
|
+
label="User",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
``entity_type`` is the ``__tablename__`` the rows are audited under.
|
|
202
|
+
Registering only supplies the URL — the target route still enforces
|
|
203
|
+
its own permissions, so linking never widens access. Called once at
|
|
204
|
+
boot, in dependency order.
|
|
205
|
+
"""
|
|
206
|
+
|
|
166
207
|
def register_middleware(self, app: FastAPI) -> None:
|
|
167
208
|
"""Add middleware to the application.
|
|
168
209
|
|
|
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
|
|
20
20
|
from simple_module_db.session import DatabaseState
|
|
21
21
|
from simple_module_hosting.settings import Settings
|
|
22
22
|
|
|
23
|
+
from simple_module_core.audit_links import AuditLinkRegistry
|
|
23
24
|
from simple_module_core.design_packs import DesignPackRegistry
|
|
24
25
|
from simple_module_core.events import EventBus
|
|
25
26
|
from simple_module_core.feature_flags import FeatureFlagRegistry
|
|
@@ -44,6 +45,7 @@ class Services:
|
|
|
44
45
|
health_registry: HealthRegistry
|
|
45
46
|
public_routes: PublicRouteRegistry
|
|
46
47
|
design_packs: DesignPackRegistry
|
|
48
|
+
audit_links: AuditLinkRegistry
|
|
47
49
|
i18n_registry: I18nRegistry
|
|
48
50
|
inertia_config: InertiaConfig
|
|
49
51
|
modules: tuple[ModuleBase, ...]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Tests for AuditLink / AuditLinkRegistry.
|
|
2
|
+
|
|
3
|
+
The audit log stores a table name and a primary key. This registry is how a
|
|
4
|
+
module says where those rows can be opened, so the log stops being a wall of
|
|
5
|
+
unactionable uuids.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestAuditLink:
|
|
15
|
+
def test_url_for_substitutes_the_id(self):
|
|
16
|
+
link = AuditLink(entity_type="User", url_template="/users/admin/{id}")
|
|
17
|
+
assert link.url_for("a91f3c2b") == "/users/admin/a91f3c2b"
|
|
18
|
+
|
|
19
|
+
def test_template_without_placeholder_is_rejected(self):
|
|
20
|
+
"""Every row would otherwise link to the same page."""
|
|
21
|
+
with pytest.raises(ValueError, match="contains no"):
|
|
22
|
+
AuditLink(entity_type="User", url_template="/users/admin")
|
|
23
|
+
|
|
24
|
+
def test_label_defaults_to_empty(self):
|
|
25
|
+
assert AuditLink(entity_type="t", url_template="/t/{id}").label == ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestAuditLinkRegistry:
|
|
29
|
+
def test_get_returns_none_for_unclaimed_tables(self):
|
|
30
|
+
"""Join tables and blob stores have no screen; that is not an error."""
|
|
31
|
+
assert AuditLinkRegistry().get("UserPermission") is None
|
|
32
|
+
|
|
33
|
+
def test_register_and_get(self):
|
|
34
|
+
reg = AuditLinkRegistry()
|
|
35
|
+
link = AuditLink(entity_type="User", url_template="/users/admin/{id}", label="User")
|
|
36
|
+
reg.register(link)
|
|
37
|
+
assert reg.get("User") is link
|
|
38
|
+
|
|
39
|
+
def test_conflicting_claims_raise(self):
|
|
40
|
+
"""Two modules mapping one table means one of them silently loses."""
|
|
41
|
+
reg = AuditLinkRegistry()
|
|
42
|
+
reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
|
|
43
|
+
with pytest.raises(ValueError, match="Two modules claim"):
|
|
44
|
+
reg.register(AuditLink(entity_type="User", url_template="/b/{id}"))
|
|
45
|
+
|
|
46
|
+
def test_registering_the_same_link_twice_is_allowed(self):
|
|
47
|
+
"""Idempotent re-registration must not break a re-entrant boot."""
|
|
48
|
+
reg = AuditLinkRegistry()
|
|
49
|
+
for _ in range(2):
|
|
50
|
+
reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
|
|
51
|
+
assert len(reg.all_links) == 1
|
|
52
|
+
|
|
53
|
+
def test_all_links_is_a_copy(self):
|
|
54
|
+
reg = AuditLinkRegistry()
|
|
55
|
+
reg.register(AuditLink(entity_type="User", url_template="/a/{id}"))
|
|
56
|
+
reg.all_links.clear()
|
|
57
|
+
assert reg.get("User") is not None
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Modules can extend the host Content-Security-Policy with extra origins.
|
|
2
|
+
|
|
3
|
+
Found in the field: the pagebuilder module's editor loads a stylesheet from
|
|
4
|
+
``rsms.me``, which the framework CSP blocks — and there was no way for the
|
|
5
|
+
module (or the host) to declare that origin short of patching the installed
|
|
6
|
+
package. ``register_csp_sources`` closes that gap the same way
|
|
7
|
+
``register_public_routes`` does for auth exemptions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from simple_module_core import ModuleBase, ModuleMeta
|
|
14
|
+
from simple_module_core.csp import CspSourceError, CspSourceRegistry
|
|
15
|
+
|
|
16
|
+
_POLICY = (
|
|
17
|
+
"default-src 'self'; "
|
|
18
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
|
19
|
+
"font-src 'self' https://fonts.gstatic.com data:"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestRegistry:
|
|
24
|
+
def test_extends_existing_directive(self) -> None:
|
|
25
|
+
reg = CspSourceRegistry()
|
|
26
|
+
reg.add("style-src", "https://rsms.me")
|
|
27
|
+
out = reg.extend_policy(_POLICY)
|
|
28
|
+
assert (
|
|
29
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://rsms.me" in out
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def test_duplicate_and_already_present_sources_are_not_repeated(self) -> None:
|
|
33
|
+
reg = CspSourceRegistry()
|
|
34
|
+
reg.add("style-src", "https://rsms.me")
|
|
35
|
+
reg.add("style-src", "https://rsms.me")
|
|
36
|
+
reg.add("style-src", "https://fonts.googleapis.com") # already in the policy
|
|
37
|
+
out = reg.extend_policy(_POLICY)
|
|
38
|
+
assert out.count("https://rsms.me") == 1
|
|
39
|
+
assert out.count("https://fonts.googleapis.com") == 1
|
|
40
|
+
|
|
41
|
+
def test_missing_directive_gets_self_plus_source(self) -> None:
|
|
42
|
+
"""A brand-new clause must keep 'self', or it would *narrow* the policy:
|
|
43
|
+
without the clause the browser falls back to default-src 'self'."""
|
|
44
|
+
reg = CspSourceRegistry()
|
|
45
|
+
reg.add("connect-src", "https://api.example.com")
|
|
46
|
+
out = reg.extend_policy(_POLICY)
|
|
47
|
+
assert "connect-src 'self' https://api.example.com" in out
|
|
48
|
+
|
|
49
|
+
def test_missing_elem_directive_inherits_its_fallback_clause(self) -> None:
|
|
50
|
+
"""style-src-elem falls back to style-src (not default-src): a fresh
|
|
51
|
+
clause must inherit style-src's sources, or creating it would silently
|
|
52
|
+
drop 'unsafe-inline' and the font origins from element styles."""
|
|
53
|
+
reg = CspSourceRegistry()
|
|
54
|
+
reg.add("style-src-elem", "https://rsms.me")
|
|
55
|
+
out = reg.extend_policy(_POLICY)
|
|
56
|
+
assert (
|
|
57
|
+
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://rsms.me"
|
|
58
|
+
in out
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_empty_registry_returns_policy_unchanged(self) -> None:
|
|
62
|
+
reg = CspSourceRegistry()
|
|
63
|
+
assert reg.extend_policy(_POLICY) == _POLICY
|
|
64
|
+
assert not reg
|
|
65
|
+
|
|
66
|
+
def test_unknown_directive_rejected(self) -> None:
|
|
67
|
+
reg = CspSourceRegistry()
|
|
68
|
+
with pytest.raises(CspSourceError, match="directive"):
|
|
69
|
+
reg.add("script-src-attr; evil", "https://x.example")
|
|
70
|
+
|
|
71
|
+
def test_injection_shaped_sources_rejected(self) -> None:
|
|
72
|
+
reg = CspSourceRegistry()
|
|
73
|
+
for bad in (
|
|
74
|
+
"https://x; script-src *",
|
|
75
|
+
"https://x 'unsafe-eval'",
|
|
76
|
+
"",
|
|
77
|
+
"'*'",
|
|
78
|
+
"https://a.example,https://b.example", # comma would smuggle a 2nd policy
|
|
79
|
+
"*.", # wildcard with no host
|
|
80
|
+
):
|
|
81
|
+
with pytest.raises(CspSourceError):
|
|
82
|
+
reg.add("style-src", bad)
|
|
83
|
+
|
|
84
|
+
def test_base_directive_extras_reach_existing_elem_clause(self) -> None:
|
|
85
|
+
"""`script-src-elem` shadows `script-src` once present: without the
|
|
86
|
+
mirror, a module's script-src origin never reaches <script> elements —
|
|
87
|
+
the very load it declared the origin for."""
|
|
88
|
+
reg = CspSourceRegistry()
|
|
89
|
+
reg.add("script-src", "https://cdn.example.com")
|
|
90
|
+
policy = "default-src 'self'; script-src 'self'; script-src-elem 'self' 'unsafe-inline'"
|
|
91
|
+
out = reg.extend_policy(policy)
|
|
92
|
+
assert "script-src 'self' https://cdn.example.com" in out
|
|
93
|
+
assert "script-src-elem 'self' 'unsafe-inline' https://cdn.example.com" in out
|
|
94
|
+
|
|
95
|
+
def test_child_src_extras_reach_existing_frame_and_worker_clauses(self) -> None:
|
|
96
|
+
"""`frame-src` and `worker-src` shadow `child-src` once present (the
|
|
97
|
+
host default policy ships both): without the mirror, a module's
|
|
98
|
+
child-src origin would never apply to frames or workers — the loads
|
|
99
|
+
the fallback chain says child-src governs."""
|
|
100
|
+
reg = CspSourceRegistry()
|
|
101
|
+
reg.add("child-src", "https://embed.example")
|
|
102
|
+
policy = "default-src 'self'; frame-src 'self'; worker-src 'self' blob:"
|
|
103
|
+
out = reg.extend_policy(policy)
|
|
104
|
+
assert "frame-src 'self' https://embed.example" in out
|
|
105
|
+
assert "worker-src 'self' blob: https://embed.example" in out
|
|
106
|
+
assert "child-src 'self' https://embed.example" in out # new clause, seeded
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestHook:
|
|
110
|
+
def test_register_csp_sources_is_a_noop_by_default(self) -> None:
|
|
111
|
+
class Quiet(ModuleBase):
|
|
112
|
+
meta = ModuleMeta(name="Quiet")
|
|
113
|
+
|
|
114
|
+
reg = CspSourceRegistry()
|
|
115
|
+
Quiet().register_csp_sources(reg)
|
|
116
|
+
assert not reg
|
|
@@ -7,10 +7,14 @@ here is hard to debug because it manifests as "the setting just isn't there".
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import pathlib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
10
13
|
import pytest
|
|
11
14
|
from simple_module_core.dotenv import (
|
|
12
15
|
env_bool,
|
|
13
16
|
env_str,
|
|
17
|
+
find_env_file,
|
|
14
18
|
load_dotenv_into_environ,
|
|
15
19
|
parse_dotenv,
|
|
16
20
|
)
|
|
@@ -73,6 +77,23 @@ class TestParseDotenv:
|
|
|
73
77
|
assert parse_dotenv() == {"CWD_KEY": "present"}
|
|
74
78
|
|
|
75
79
|
|
|
80
|
+
class TestFindEnvFile:
|
|
81
|
+
def test_home_lookup_failure_skips_boundary_check(self, tmp_path, monkeypatch):
|
|
82
|
+
"""``Path.home()`` raises ``RuntimeError`` when ``$HOME`` is unset and
|
|
83
|
+
the UID has no passwd entry (rootless containers). ``find_env_file``
|
|
84
|
+
must not propagate that — it just skips the home-boundary check and
|
|
85
|
+
keeps walking.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def _raise_runtime_error() -> Path:
|
|
89
|
+
raise RuntimeError("no home directory for this uid")
|
|
90
|
+
|
|
91
|
+
monkeypatch.setattr(pathlib.Path, "home", staticmethod(_raise_runtime_error))
|
|
92
|
+
monkeypatch.delenv("SM_PROJECT_ROOT", raising=False)
|
|
93
|
+
monkeypatch.chdir(tmp_path)
|
|
94
|
+
assert find_env_file() == Path(".env")
|
|
95
|
+
|
|
96
|
+
|
|
76
97
|
class TestLoadDotenvIntoEnviron:
|
|
77
98
|
def test_setdefault_semantics_preserves_existing_env(self, tmp_path, monkeypatch):
|
|
78
99
|
"""Real ``os.environ`` wins over file values — same precedence as uvicorn."""
|
|
@@ -33,6 +33,41 @@ class TestHealthRegistry:
|
|
|
33
33
|
reg.add(HealthCheck(name="b", check=check_b))
|
|
34
34
|
assert len(reg.all_checks) == 2
|
|
35
35
|
|
|
36
|
+
async def test_checks_are_attributed_to_the_owning_module(self):
|
|
37
|
+
"""The dashboard shows health per module, which needs this attribution."""
|
|
38
|
+
reg = HealthRegistry()
|
|
39
|
+
|
|
40
|
+
async def check() -> HealthCheckResult:
|
|
41
|
+
return HealthCheckResult(status=HealthStatus.HEALTHY)
|
|
42
|
+
|
|
43
|
+
reg.set_owner("FileStorage")
|
|
44
|
+
reg.add(HealthCheck(name="s3", check=check))
|
|
45
|
+
reg.set_owner("BackgroundTasks")
|
|
46
|
+
reg.add(HealthCheck(name="broker", check=check))
|
|
47
|
+
|
|
48
|
+
owners = {c.name: c.module for c in reg.all_checks}
|
|
49
|
+
assert owners == {"s3": "FileStorage", "broker": "BackgroundTasks"}
|
|
50
|
+
|
|
51
|
+
async def test_explicit_module_survives_the_current_owner(self):
|
|
52
|
+
reg = HealthRegistry()
|
|
53
|
+
|
|
54
|
+
async def check() -> HealthCheckResult:
|
|
55
|
+
return HealthCheckResult(status=HealthStatus.HEALTHY)
|
|
56
|
+
|
|
57
|
+
reg.set_owner("Dashboard")
|
|
58
|
+
reg.add(HealthCheck(name="db", check=check, module="Users"))
|
|
59
|
+
assert reg.all_checks[0].module == "Users"
|
|
60
|
+
|
|
61
|
+
async def test_unowned_checks_have_no_module(self):
|
|
62
|
+
"""Checks added outside a register_health_checks hook belong to nobody."""
|
|
63
|
+
reg = HealthRegistry()
|
|
64
|
+
|
|
65
|
+
async def check() -> HealthCheckResult:
|
|
66
|
+
return HealthCheckResult(status=HealthStatus.HEALTHY)
|
|
67
|
+
|
|
68
|
+
reg.add(HealthCheck(name="db", check=check))
|
|
69
|
+
assert reg.all_checks[0].module == ""
|
|
70
|
+
|
|
36
71
|
async def test_check_result_defaults(self):
|
|
37
72
|
result = HealthCheckResult(status=HealthStatus.HEALTHY)
|
|
38
73
|
assert result.detail is None
|
|
@@ -114,3 +114,52 @@ class TestMenuRegistryAdvanced:
|
|
|
114
114
|
result = reg.get_for_user(is_authenticated=True)
|
|
115
115
|
groups = [i["group"] for i in result["sidebar"]]
|
|
116
116
|
assert groups == ["Administration", "System"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestPermissionFiltering:
|
|
120
|
+
"""An entry that 403s on click is worse than no entry at all.
|
|
121
|
+
|
|
122
|
+
Roles alone could not express this: hard-coding ``roles=["admin"]`` hides
|
|
123
|
+
the screen from a custom role that legitimately holds the permission, while
|
|
124
|
+
still showing it to admin-adjacent roles that cannot open it.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
async def test_entry_hidden_without_the_permission(self):
|
|
128
|
+
reg = MenuRegistry()
|
|
129
|
+
reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
|
|
130
|
+
result = reg.get_for_user(is_authenticated=True, permissions=["users.manage"])
|
|
131
|
+
assert result["sidebar"] == []
|
|
132
|
+
|
|
133
|
+
async def test_entry_shown_with_the_permission(self):
|
|
134
|
+
reg = MenuRegistry()
|
|
135
|
+
reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
|
|
136
|
+
result = reg.get_for_user(is_authenticated=True, permissions=["settings.view"])
|
|
137
|
+
assert [i["label"] for i in result["sidebar"]] == ["Settings"]
|
|
138
|
+
|
|
139
|
+
async def test_permission_granted_by_a_custom_role_is_enough(self):
|
|
140
|
+
"""No role check involved — holding the key is what matters."""
|
|
141
|
+
reg = MenuRegistry()
|
|
142
|
+
reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
|
|
143
|
+
result = reg.get_for_user(
|
|
144
|
+
is_authenticated=True, roles=["auditor"], permissions=["settings.view"]
|
|
145
|
+
)
|
|
146
|
+
assert len(result["sidebar"]) == 1
|
|
147
|
+
|
|
148
|
+
async def test_all_declared_permissions_are_required(self):
|
|
149
|
+
reg = MenuRegistry()
|
|
150
|
+
reg.add(MenuItem(label="X", url="/x", permissions=["a.view", "a.manage"]))
|
|
151
|
+
assert reg.get_for_user(is_authenticated=True, permissions=["a.view"])["sidebar"] == []
|
|
152
|
+
both = reg.get_for_user(is_authenticated=True, permissions=["a.view", "a.manage"])
|
|
153
|
+
assert len(both["sidebar"]) == 1
|
|
154
|
+
|
|
155
|
+
async def test_no_declared_permissions_means_no_check(self):
|
|
156
|
+
"""Existing entries must keep working without opting in."""
|
|
157
|
+
reg = MenuRegistry()
|
|
158
|
+
reg.add(MenuItem(label="Home", url="/"))
|
|
159
|
+
assert len(reg.get_for_user(is_authenticated=True, permissions=[])["sidebar"]) == 1
|
|
160
|
+
|
|
161
|
+
async def test_omitting_permissions_entirely_hides_gated_entries(self):
|
|
162
|
+
"""A caller that passes no permissions holds none — fail closed."""
|
|
163
|
+
reg = MenuRegistry()
|
|
164
|
+
reg.add(MenuItem(label="Settings", url="/settings/", permissions=["settings.view"]))
|
|
165
|
+
assert reg.get_for_user(is_authenticated=True)["sidebar"] == []
|
|
@@ -31,6 +31,7 @@ class TestServices:
|
|
|
31
31
|
assert s.health_registry is _SENTINEL_HEALTH
|
|
32
32
|
assert s.public_routes is _SENTINEL_PUBLIC_ROUTES
|
|
33
33
|
assert s.design_packs is _SENTINEL_DESIGN_PACKS
|
|
34
|
+
assert s.audit_links is _SENTINEL_AUDIT_LINKS
|
|
34
35
|
assert s.i18n_registry is _SENTINEL_I18N
|
|
35
36
|
assert s.inertia_config is _SENTINEL_INERTIA
|
|
36
37
|
assert s.modules == ()
|
|
@@ -45,6 +46,7 @@ _SENTINEL_FLAGS = object()
|
|
|
45
46
|
_SENTINEL_HEALTH = object()
|
|
46
47
|
_SENTINEL_PUBLIC_ROUTES = object()
|
|
47
48
|
_SENTINEL_DESIGN_PACKS = object()
|
|
49
|
+
_SENTINEL_AUDIT_LINKS = object()
|
|
48
50
|
_SENTINEL_I18N = object()
|
|
49
51
|
_SENTINEL_INERTIA = object()
|
|
50
52
|
|
|
@@ -61,6 +63,7 @@ def _make_services() -> Services:
|
|
|
61
63
|
health_registry=_SENTINEL_HEALTH, # type: ignore[arg-type]
|
|
62
64
|
public_routes=_SENTINEL_PUBLIC_ROUTES, # type: ignore[arg-type]
|
|
63
65
|
design_packs=_SENTINEL_DESIGN_PACKS, # type: ignore[arg-type]
|
|
66
|
+
audit_links=_SENTINEL_AUDIT_LINKS, # type: ignore[arg-type]
|
|
64
67
|
i18n_registry=_SENTINEL_I18N, # type: ignore[arg-type]
|
|
65
68
|
inertia_config=_SENTINEL_INERTIA, # type: ignore[arg-type]
|
|
66
69
|
modules=(),
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
"""Minimal ``.env`` parser + env-var helpers — 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
|
-
BOOL_LITERALS_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
|
|
15
|
-
BOOL_LITERALS_FALSE = frozenset({"0", "false", "f", "no", "n", "off"})
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def parse_dotenv(path: Path | None = None) -> dict[str, str]:
|
|
19
|
-
"""Parse a ``.env`` file into a dict. Empty dict if the file is missing.
|
|
20
|
-
|
|
21
|
-
Values surrounded by matching single or double quotes have the quotes
|
|
22
|
-
stripped. Does *not* handle escapes, ``export KEY=…``, or multiline
|
|
23
|
-
values — keep the file simple. Does *not* mutate ``os.environ``; the
|
|
24
|
-
caller decides whether to merge.
|
|
25
|
-
|
|
26
|
-
Without ``path``, looks up ``$SM_PROJECT_ROOT/.env`` (falling back to
|
|
27
|
-
``$CWD/.env``) — the convention used by every tool in this repo.
|
|
28
|
-
"""
|
|
29
|
-
if path is None:
|
|
30
|
-
root = Path(os.environ.get("SM_PROJECT_ROOT") or Path.cwd())
|
|
31
|
-
path = root / ".env"
|
|
32
|
-
if not path.is_file():
|
|
33
|
-
return {}
|
|
34
|
-
parsed: dict[str, str] = {}
|
|
35
|
-
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
36
|
-
line = raw.strip()
|
|
37
|
-
if not line or line.startswith("#") or "=" not in line:
|
|
38
|
-
continue
|
|
39
|
-
key, _, value = line.partition("=")
|
|
40
|
-
parsed[key.strip()] = value.strip().strip('"').strip("'")
|
|
41
|
-
return parsed
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def load_dotenv_into_environ(path: Path | None = None) -> None:
|
|
45
|
-
"""Merge ``parse_dotenv(path)`` into ``os.environ`` via ``setdefault``.
|
|
46
|
-
|
|
47
|
-
Same precedence as the web process under uvicorn: real environment wins
|
|
48
|
-
over file values. Worker entrypoints call this before importing settings.
|
|
49
|
-
"""
|
|
50
|
-
for key, value in parse_dotenv(path).items():
|
|
51
|
-
os.environ.setdefault(key, value)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def env_str(name: str, default: str) -> str:
|
|
55
|
-
"""Return ``$name`` if set and non-empty, else ``default``."""
|
|
56
|
-
value = os.environ.get(name, "").strip()
|
|
57
|
-
return value or default
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def env_bool(name: str, default: bool = False) -> bool:
|
|
61
|
-
"""Parse ``$name`` as a boolean, returning ``default`` when unset/blank."""
|
|
62
|
-
raw = os.environ.get(name, "").strip().lower()
|
|
63
|
-
if not raw:
|
|
64
|
-
return default
|
|
65
|
-
if raw in BOOL_LITERALS_TRUE:
|
|
66
|
-
return True
|
|
67
|
-
if raw in BOOL_LITERALS_FALSE:
|
|
68
|
-
return False
|
|
69
|
-
return default
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""Health check registry for module-contributed health checks."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from collections.abc import Awaitable, Callable
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from enum import StrEnum
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class HealthStatus(StrEnum):
|
|
11
|
-
HEALTHY = "healthy"
|
|
12
|
-
DEGRADED = "degraded"
|
|
13
|
-
UNHEALTHY = "unhealthy"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@dataclass
|
|
17
|
-
class HealthCheckResult:
|
|
18
|
-
"""Result of a single health check."""
|
|
19
|
-
|
|
20
|
-
status: HealthStatus
|
|
21
|
-
detail: str | None = None
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
HealthCheckFn = Callable[[], Awaitable[HealthCheckResult]]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@dataclass
|
|
28
|
-
class HealthCheck:
|
|
29
|
-
"""A named health check with an async callable."""
|
|
30
|
-
|
|
31
|
-
name: str
|
|
32
|
-
check: HealthCheckFn
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class HealthRegistry:
|
|
36
|
-
"""Collects health checks contributed by modules."""
|
|
37
|
-
|
|
38
|
-
def __init__(self) -> None:
|
|
39
|
-
self._checks: list[HealthCheck] = []
|
|
40
|
-
|
|
41
|
-
def add(self, check: HealthCheck) -> None:
|
|
42
|
-
self._checks.append(check)
|
|
43
|
-
|
|
44
|
-
@property
|
|
45
|
-
def all_checks(self) -> list[HealthCheck]:
|
|
46
|
-
return list(self._checks)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/__init__.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_coupling.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_css.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_i18n.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_migration.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_pages.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_runner.py
RENAMED
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/simple_module_core/diagnostics/_types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_auth_provider_selection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simple_module_core-0.0.30 → simple_module_core-0.0.31}/tests/test_feature_flags_decorator.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|