Skip to content

Commit 8713dc0

Browse files
committed
Add Secret type, configurable env prefixes, and settings CLI
- Add Secret[T] type annotation for masking sensitive settings - Add ENV_SETTINGS_PREFIXES setting for configurable env var prefixes - Add preflight check for unused env vars matching configured prefixes - Add `plain settings list` and `plain settings get` CLI commands - Show env-loaded settings on server startup - Track which env var provided each setting (env_var_name)
1 parent 006efae commit 8713dc0

File tree

9 files changed

+226
-68
lines changed

9 files changed

+226
-68
lines changed

plain/plain/cli/core.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .registry import cli_registry
2121
from .scaffold import create
2222
from .server import server
23-
from .settings import setting
23+
from .settings import settings
2424
from .shell import run, shell
2525
from .upgrade import upgrade
2626
from .urls import urls
@@ -41,7 +41,7 @@ def plain_cli() -> None:
4141
plain_cli.add_command(utils)
4242
plain_cli.add_command(urls)
4343
plain_cli.add_command(changelog)
44-
plain_cli.add_command(setting)
44+
plain_cli.add_command(settings)
4545
plain_cli.add_command(shell)
4646
plain_cli.add_command(run)
4747
plain_cli.add_command(install)

plain/plain/cli/server.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ def server(
111111
pidfile: str | None,
112112
) -> None:
113113
"""Production-ready WSGI server"""
114+
from plain.runtime import settings
115+
116+
# Show settings loaded from environment
117+
if env_settings := settings.get_env_settings():
118+
click.secho("Settings from env:", dim=True)
119+
for name, defn in env_settings:
120+
click.secho(
121+
f" {defn.env_var_name} -> {name}={defn.display_value()}", dim=True
122+
)
123+
114124
from plain.server import ServerApplication
115125
from plain.server.config import Config
116126

plain/plain/cli/settings.py

Lines changed: 53 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,62 @@
33
import plain.runtime
44

55

6-
@click.command()
6+
@click.group()
7+
def settings() -> None:
8+
"""View and inspect settings"""
9+
pass
10+
11+
12+
@settings.command()
713
@click.argument("setting_name")
8-
def setting(setting_name: str) -> None:
9-
"""Print the value of a setting at runtime"""
14+
def get(setting_name: str) -> None:
15+
"""Get the value of a specific setting"""
1016
try:
11-
setting = getattr(plain.runtime.settings, setting_name)
12-
click.echo(setting)
17+
value = getattr(plain.runtime.settings, setting_name)
18+
click.echo(value)
1319
except AttributeError:
1420
click.secho(f'Setting "{setting_name}" not found', fg="red")
1521

1622

17-
# @plain_cli.command()
18-
# @click.option("--filter", "-f", "name_filter", help="Filter settings by name")
19-
# @click.option("--overridden", is_flag=True, help="Only show overridden settings")
20-
# def settings(name_filter, overridden):
21-
# """Print Plain settings"""
22-
# table = Table(box=box.MINIMAL)
23-
# table.add_column("Setting")
24-
# table.add_column("Default value")
25-
# table.add_column("App value")
26-
# table.add_column("Type")
27-
# table.add_column("Module")
28-
29-
# for setting in dir(settings):
30-
# if setting.isupper():
31-
# if name_filter and name_filter.upper() not in setting:
32-
# continue
33-
34-
# is_overridden = settings.is_overridden(setting)
35-
36-
# if overridden and not is_overridden:
37-
# continue
38-
39-
# default_setting = settings._default_settings.get(setting)
40-
# if default_setting:
41-
# default_value = default_setting.value
42-
# annotation = default_setting.annotation
43-
# module = default_setting.module
44-
# else:
45-
# default_value = ""
46-
# annotation = ""
47-
# module = ""
48-
49-
# table.add_row(
50-
# setting,
51-
# Pretty(default_value) if default_value else "",
52-
# Pretty(getattr(settings, setting))
53-
# if is_overridden
54-
# else Text("<Default>", style="italic dim"),
55-
# Pretty(annotation) if annotation else "",
56-
# str(module.__name__) if module else "",
57-
# )
58-
59-
# console = Console()
60-
# console.print(table)
23+
@settings.command(name="list")
24+
def list_settings() -> None:
25+
"""List all settings with their sources"""
26+
if not (items := plain.runtime.settings.get_settings()):
27+
click.echo("No settings configured.")
28+
return
29+
30+
# Calculate column widths
31+
max_name = max(len(name) for name, _ in items)
32+
max_source = max(len(defn.env_var_name or defn.source) for _, defn in items)
33+
34+
# Print header
35+
header = (
36+
click.style(f"{'Setting':<{max_name}}", bold=True)
37+
+ " "
38+
+ click.style(f"{'Source':<{max_source}}", bold=True)
39+
+ " "
40+
+ click.style("Value", bold=True)
41+
)
42+
click.echo(header)
43+
click.secho("-" * (max_name + max_source + 10), dim=True)
44+
45+
# Print each setting
46+
for name, defn in items:
47+
source_info = defn.env_var_name or defn.source
48+
value = defn.display_value()
49+
50+
# Style based on source
51+
if defn.source == "env":
52+
source_styled = click.style(f"{source_info:<{max_source}}", fg="green")
53+
elif defn.source == "explicit":
54+
source_styled = click.style(f"{source_info:<{max_source}}", fg="cyan")
55+
else:
56+
source_styled = click.style(f"{source_info:<{max_source}}", dim=True)
57+
58+
# Style secret values
59+
if defn.is_secret:
60+
value_styled = click.style(value, dim=True)
61+
else:
62+
value_styled = value
63+
64+
click.echo(f"{name:<{max_name}} {source_styled} {value_styled}")

plain/plain/preflight/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# Import these to force registration of checks
66
import plain.preflight.files # NOQA isort:skip
77
import plain.preflight.security # NOQA isort:skip
8+
import plain.preflight.settings # NOQA isort:skip
89
import plain.preflight.urls # NOQA isort:skip
910

1011

plain/plain/preflight/settings.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
from plain.runtime import settings
6+
7+
from .checks import PreflightCheck
8+
from .registry import register_check
9+
from .results import PreflightResult
10+
11+
12+
@register_check(name="settings.unused_env_vars")
13+
class CheckUnusedEnvVars(PreflightCheck):
14+
"""Detect environment variables that look like settings but aren't used."""
15+
16+
def run(self) -> list[PreflightResult]:
17+
results: list[PreflightResult] = []
18+
19+
# Get all env vars matching any configured prefix
20+
for prefix in settings._env_prefixes:
21+
for key in os.environ:
22+
if key.startswith(prefix) and key.isupper():
23+
setting_name = key[len(prefix) :]
24+
# Skip empty setting names (just the prefix itself)
25+
if setting_name and setting_name not in settings._settings:
26+
results.append(
27+
PreflightResult(
28+
fix=f"Environment variable '{key}' looks like a setting but "
29+
f"'{setting_name}' is not a recognized setting.",
30+
id="settings.unused_env_var",
31+
warning=True,
32+
)
33+
)
34+
35+
# Warn if PLAIN_ env vars exist but PLAIN_ not in prefixes
36+
if "PLAIN_" not in settings._env_prefixes:
37+
plain_vars = [
38+
k
39+
for k in os.environ
40+
if k.startswith("PLAIN_")
41+
and k.isupper()
42+
and k != "PLAIN_SETTINGS_MODULE" # This one is always valid
43+
]
44+
if plain_vars:
45+
results.append(
46+
PreflightResult(
47+
fix=f"Found PLAIN_ environment variables but 'PLAIN_' is not in "
48+
f"ENV_SETTINGS_PREFIXES: {', '.join(sorted(plain_vars))}",
49+
id="settings.plain_prefix_disabled",
50+
warning=True,
51+
)
52+
)
53+
54+
return results

plain/plain/runtime/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from plain.logs.configure import configure_logging
88
from plain.packages import packages_registry
99

10+
from .secret import Secret
1011
from .user_settings import Settings
1112

1213
try:
@@ -90,6 +91,7 @@ def __init__(self, setting_name: str):
9091
__all__ = [
9192
"setup",
9293
"settings",
94+
"Secret",
9395
"SettingsReference",
9496
"APP_PATH",
9597
"__version__",

plain/plain/runtime/global_settings.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
by the PLAIN_SETTINGS_MODULE environment variable.
44
"""
55

6+
from .secret import Secret
67
from .utils import get_app_info_from_pyproject
78

89
# MARK: Core Settings
@@ -18,6 +19,12 @@
1819

1920
URLS_ROUTER: str
2021

22+
# List of environment variable prefixes to check for settings.
23+
# Settings can be configured via environment variables using these prefixes.
24+
# Example: ENV_SETTINGS_PREFIXES = ["PLAIN_", "MYAPP_"]
25+
# Then both PLAIN_DEBUG and MYAPP_DEBUG would set the DEBUG setting.
26+
ENV_SETTINGS_PREFIXES: list[str] = ["PLAIN_"]
27+
2128
# MARK: HTTP and Security
2229

2330
# Hosts/domain names that are valid for this site.
@@ -66,11 +73,11 @@
6673
# A secret key for this particular Plain installation. Used in secret-key
6774
# hashing algorithms. Set this in your settings, or Plain will complain
6875
# loudly.
69-
SECRET_KEY: str
76+
SECRET_KEY: Secret[str]
7077

7178
# List of secret keys used to verify the validity of signatures. This allows
7279
# secret key rotation.
73-
SECRET_KEY_FALLBACKS: list[str] = []
80+
SECRET_KEY_FALLBACKS: Secret[list[str]] = [] # type: ignore[assignment]
7481

7582
# MARK: Internationalization
7683

plain/plain/runtime/secret.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import annotations
2+
3+
from typing import Generic, TypeVar
4+
5+
T = TypeVar("T")
6+
7+
8+
class Secret(Generic[T]):
9+
"""
10+
Marker type for sensitive settings. Values are masked in output/logs.
11+
12+
Usage:
13+
SECRET_KEY: Secret[str]
14+
DATABASE_PASSWORD: Secret[str]
15+
16+
At runtime, the value is still a plain str - this is purely for
17+
indicating that the setting should be masked when displayed.
18+
"""
19+
20+
pass

0 commit comments

Comments
 (0)