plain 0.61.0__py3-none-any.whl → 0.62.0__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.
plain/AGENTS.md CHANGED
@@ -11,4 +11,8 @@ The `plain` CLI is the main entrypoint for the framework. If `plain` is not avai
11
11
  - `plain agent docs <package>`: Show README.md and symbolicated source files for a specific package.
12
12
  - `plain agent docs --list`: List packages with docs available.
13
13
  - `plain agent request <path> --user <user_id>`: Make an authenticated request to the application and inspect the output.
14
- - `plain --help`: List all available commands (including those from installed packages)
14
+ - `plain --help`: List all available commands (including those from installed packages).
15
+
16
+ ## Code style
17
+
18
+ - Imports should be at the top of the file, unless there is a specific reason to import later (e.g. to avoid circular imports).
plain/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.62.0](https://github.com/dropseed/plain/releases/plain@0.62.0) (2025-09-09)
4
+
5
+ ### What's changed
6
+
7
+ - Complete rewrite of logging settings and AppLogger with improved formatters and debug capabilities ([ea7c953](https://github.com/dropseed/plain/commit/ea7c9537e3))
8
+ - Added `app_logger.debug_mode()` context manager to temporarily change log level ([f535459](https://github.com/dropseed/plain/commit/f53545f9fa))
9
+ - Minimum Python version updated to 3.13 ([d86e307](https://github.com/dropseed/plain/commit/d86e307efb))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - Make sure you are using Python 3.13 or higher
14
+
3
15
  ## [0.61.0](https://github.com/dropseed/plain/releases/plain@0.61.0) (2025-09-03)
4
16
 
5
17
  ### What's changed
plain/csrf/middleware.py CHANGED
@@ -3,7 +3,7 @@ import re
3
3
  from urllib.parse import urlparse
4
4
 
5
5
  from plain.exceptions import DisallowedHost
6
- from plain.logs import log_response
6
+ from plain.logs.utils import log_response
7
7
  from plain.runtime import settings
8
8
 
9
9
  from .views import CsrfFailureView
@@ -5,7 +5,7 @@ from opentelemetry import baggage, trace
5
5
  from opentelemetry.semconv.attributes import http_attributes, url_attributes
6
6
 
7
7
  from plain.exceptions import ImproperlyConfigured
8
- from plain.logs import log_response
8
+ from plain.logs.utils import log_response
9
9
  from plain.runtime import settings
10
10
  from plain.urls import get_resolver
11
11
  from plain.utils.module_loading import import_string
@@ -12,7 +12,7 @@ from plain.exceptions import (
12
12
  )
13
13
  from plain.http import Http404, ResponseServerError
14
14
  from plain.http.multipartparser import MultiPartParserError
15
- from plain.logs import log_response
15
+ from plain.logs.utils import log_response
16
16
  from plain.runtime import settings
17
17
  from plain.utils.module_loading import import_string
18
18
  from plain.views.errors import ErrorView
plain/logs/README.md CHANGED
@@ -4,7 +4,10 @@
4
4
 
5
5
  - [Overview](#overview)
6
6
  - [`app_logger`](#app_logger)
7
- - [`app_logger.kv`](#app_loggerkv)
7
+ - [Output formats](#output-formats)
8
+ - [Context management](#context-management)
9
+ - [Debug mode](#debug-mode)
10
+ - [Advanced usage](#advanced-usage)
8
11
  - [Logging settings](#logging-settings)
9
12
 
10
13
  ## Overview
@@ -13,49 +16,124 @@ In Python, configuring logging can be surprisingly complex. For most use cases,
13
16
 
14
17
  By default, both the `plain` and `app` loggers are set to the `INFO` level. You can quickly change this by using the `PLAIN_LOG_LEVEL` and `APP_LOG_LEVEL` environment variables.
15
18
 
19
+ The `app_logger` supports multiple output formats and provides a friendly kwargs-based API for structured logging.
20
+
16
21
  ## `app_logger`
17
22
 
18
- The `app_logger` is a pre-configured logger you can use inside your app code.
23
+ The `app_logger` is an enhanced logger that supports kwargs-style logging and multiple output formats.
19
24
 
20
25
  ```python
21
26
  from plain.logs import app_logger
22
27
 
23
28
 
24
29
  def example_function():
25
- app_logger.info("Hey!")
30
+ # Basic logging
31
+ app_logger.info("User logged in")
32
+
33
+ # With structured context data (explicit **context parameter)
34
+ app_logger.info("User action", user_id=123, action="login", success=True)
35
+
36
+ # All log levels support context parameters
37
+ app_logger.debug("Debug info", step="validation", count=5)
38
+ app_logger.warning("Rate limit warning", user_id=456, limit_exceeded=True)
39
+ app_logger.error("Database error", error_code=500, table="users")
40
+
41
+ # Standard logging parameters with context
42
+ try:
43
+ risky_operation()
44
+ except Exception:
45
+ app_logger.error(
46
+ "Operation failed",
47
+ exc_info=True, # Include exception traceback
48
+ stack_info=True, # Include stack trace
49
+ user_id=789,
50
+ operation="risky_operation"
51
+ )
26
52
  ```
27
53
 
28
- ## `app_logger.kv`
54
+ ## Output formats
55
+
56
+ The `app_logger` supports three output formats controlled by the `APP_LOG_FORMAT` environment variable:
29
57
 
30
- The key-value logging format is popular for outputting more structured logs that are still human-readable.
58
+ ### Key-Value format (default)
59
+
60
+ ```bash
61
+ export APP_LOG_FORMAT=keyvalue # or leave unset for default
62
+ ```
63
+
64
+ ```
65
+ [INFO] User action user_id=123 action=login success=True
66
+ [ERROR] Database error error_code=500 table=users
67
+ ```
68
+
69
+ ### JSON format
70
+
71
+ ```bash
72
+ export APP_LOG_FORMAT=json
73
+ ```
74
+
75
+ ```json
76
+ {"timestamp": "2024-01-01 12:00:00,123", "level": "INFO", "message": "User action", "user_id": 123, "action": "login", "success": true}
77
+ {"timestamp": "2024-01-01 12:00:01,456", "level": "ERROR", "message": "Database error", "error_code": 500, "table": "users"}
78
+ ```
79
+
80
+ ### Standard format
81
+
82
+ ```bash
83
+ export APP_LOG_FORMAT=standard
84
+ ```
85
+
86
+ ```
87
+ [INFO] User action
88
+ [ERROR] Database error
89
+ ```
90
+
91
+ Note: In standard format, the context kwargs are ignored and not displayed.
92
+
93
+ ## Context management
94
+
95
+ The `app_logger` provides powerful context management for adding data to multiple log statements.
96
+
97
+ ### Persistent context
98
+
99
+ Use the `context` dict to add data that persists across log calls:
31
100
 
32
101
  ```python
33
- from plain.logs import app_logger
102
+ # Set persistent context
103
+ app_logger.context["user_id"] = 123
104
+ app_logger.context["request_id"] = "abc456"
34
105
 
106
+ app_logger.info("Started processing") # Includes user_id and request_id
107
+ app_logger.info("Validation complete") # Includes user_id and request_id
108
+ app_logger.info("Processing finished") # Includes user_id and request_id
35
109
 
36
- def example_function():
37
- app_logger.kv("Example log line with", example_key="example_value")
110
+ # Clear context
111
+ app_logger.context.clear()
38
112
  ```
39
113
 
40
- ## Logging settings
114
+ ### Temporary context
41
115
 
42
- You can further configure your logging with `settings.LOGGING`.
116
+ Use `include_context()` for temporary context that only applies within a block:
43
117
 
44
118
  ```python
45
- # app/settings.py
46
- LOGGING = {
47
- "version": 1,
48
- "disable_existing_loggers": False,
49
- "handlers": {
50
- "console": {
51
- "class": "logging.StreamHandler",
52
- },
53
- },
54
- "loggers": {
55
- "mylogger": {
56
- "handlers": ["console"],
57
- "level": "DEBUG",
58
- },
59
- },
60
- }
119
+ app_logger.context["user_id"] = 123 # Persistent
120
+
121
+ with app_logger.include_context(operation="payment", transaction_id="txn789"):
122
+ app_logger.info("Payment started") # Has user_id, operation, transaction_id
123
+ app_logger.info("Payment validated") # Has user_id, operation, transaction_id
124
+
125
+ app_logger.info("Payment complete") # Only has user_id
126
+ ```
127
+
128
+ ## Debug mode
129
+
130
+ The `force_debug()` context manager allows temporarily enabling DEBUG level logging:
131
+
132
+ ```python
133
+ # Debug messages might not show at INFO level
134
+ app_logger.debug("This might not appear")
135
+
136
+ # Temporarily enable debug logging
137
+ with app_logger.force_debug():
138
+ app_logger.debug("This will definitely appear", extra_data="debug_info")
61
139
  ```
plain/logs/__init__.py CHANGED
@@ -1,5 +1,3 @@
1
- from .configure import configure_logging
2
1
  from .loggers import app_logger
3
- from .utils import log_response
4
2
 
5
- __all__ = ["app_logger", "log_response", "configure_logging"]
3
+ __all__ = ["app_logger"]
plain/logs/configure.py CHANGED
@@ -1,44 +1,36 @@
1
1
  import logging
2
- import logging.config
3
- from os import environ
4
-
5
-
6
- def configure_logging(logging_settings):
7
- # Load the defaults
8
- default_logging = {
9
- "version": 1,
10
- "disable_existing_loggers": False,
11
- "formatters": {
12
- "simple": {
13
- "format": "[%(levelname)s] %(message)s",
14
- },
15
- },
16
- "handlers": {
17
- "plain_console": {
18
- "level": environ.get("PLAIN_LOG_LEVEL", "INFO"),
19
- "class": "logging.StreamHandler",
20
- "formatter": "simple",
21
- },
22
- "app_console": {
23
- "level": environ.get("APP_LOG_LEVEL", "INFO"),
24
- "class": "logging.StreamHandler",
25
- "formatter": "simple",
26
- },
27
- },
28
- "loggers": {
29
- "plain": {
30
- "handlers": ["plain_console"],
31
- "level": environ.get("PLAIN_LOG_LEVEL", "INFO"),
32
- },
33
- "app": {
34
- "handlers": ["app_console"],
35
- "level": environ.get("APP_LOG_LEVEL", "INFO"),
36
- "propagate": False,
37
- },
38
- },
39
- }
40
- logging.config.dictConfig(default_logging)
41
-
42
- # Then customize it from settings
43
- if logging_settings:
44
- logging.config.dictConfig(logging_settings)
2
+
3
+ from .formatters import JSONFormatter, KeyValueFormatter
4
+
5
+
6
+ def configure_logging(*, plain_log_level, app_log_level, app_log_format):
7
+ # Create and configure the plain logger (uses standard Logger, not AppLogger)
8
+ plain_logger = logging.Logger("plain")
9
+ plain_logger.setLevel(plain_log_level)
10
+ plain_handler = logging.StreamHandler()
11
+ plain_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
12
+ plain_logger.addHandler(plain_handler)
13
+ plain_logger.propagate = False
14
+ logging.root.manager.loggerDict["plain"] = plain_logger
15
+
16
+ # Configure the existing app_logger
17
+ from .loggers import app_logger
18
+
19
+ app_logger.setLevel(app_log_level)
20
+ app_logger.propagate = False
21
+
22
+ app_handler = logging.StreamHandler()
23
+ match app_log_format:
24
+ case "json":
25
+ app_handler.setFormatter(JSONFormatter("%(json)s"))
26
+ case "keyvalue":
27
+ app_handler.setFormatter(
28
+ KeyValueFormatter("[%(levelname)s] %(message)s %(keyvalue)s")
29
+ )
30
+ case _:
31
+ app_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
32
+
33
+ app_logger.addHandler(app_handler)
34
+
35
+ # Register the app_logger in the logging system so getLogger("app") returns it
36
+ logging.root.manager.loggerDict["app"] = app_logger
plain/logs/debug.py ADDED
@@ -0,0 +1,36 @@
1
+ import logging
2
+ import threading
3
+
4
+
5
+ class DebugMode:
6
+ """Context manager to temporarily set DEBUG level on a logger with reference counting."""
7
+
8
+ def __init__(self, logger):
9
+ self.logger = logger
10
+ self.original_level = None
11
+ self._ref_count = 0
12
+ self._lock = threading.Lock()
13
+
14
+ def __enter__(self):
15
+ """Store original level and set to DEBUG."""
16
+ self.start()
17
+ return self
18
+
19
+ def __exit__(self, exc_type, exc_val, exc_tb):
20
+ """Restore original level."""
21
+ self.end()
22
+
23
+ def start(self):
24
+ """Enable DEBUG logging level."""
25
+ with self._lock:
26
+ if self._ref_count == 0:
27
+ self.original_level = self.logger.level
28
+ self.logger.setLevel(logging.DEBUG)
29
+ self._ref_count += 1
30
+
31
+ def end(self):
32
+ """Restore original logging level."""
33
+ with self._lock:
34
+ self._ref_count = max(0, self._ref_count - 1)
35
+ if self._ref_count == 0:
36
+ self.logger.setLevel(self.original_level)
@@ -0,0 +1,70 @@
1
+ import json
2
+ import logging
3
+
4
+
5
+ class KeyValueFormatter(logging.Formatter):
6
+ """Formatter that outputs key-value pairs from Plain's context system."""
7
+
8
+ def format(self, record):
9
+ # Build key-value pairs from context
10
+ kv_pairs = []
11
+
12
+ # Look for Plain's context data
13
+ if hasattr(record, "context") and isinstance(record.context, dict):
14
+ for key, value in record.context.items():
15
+ formatted_value = self._format_value(value)
16
+ kv_pairs.append(f"{key}={formatted_value}")
17
+
18
+ # Add the keyvalue attribute to the record for %(keyvalue)s substitution
19
+ record.keyvalue = " ".join(kv_pairs)
20
+
21
+ # Let the parent formatter handle the format string with %(keyvalue)s
22
+ return super().format(record)
23
+
24
+ @staticmethod
25
+ def _format_value(value):
26
+ """Format a value for key-value output."""
27
+ if isinstance(value, str):
28
+ s = value
29
+ else:
30
+ s = str(value)
31
+
32
+ if '"' in s:
33
+ # Escape quotes and surround it
34
+ s = s.replace('"', '\\"')
35
+ s = f'"{s}"'
36
+ elif s == "":
37
+ # Quote empty strings instead of printing nothing
38
+ s = '""'
39
+ elif any(char in s for char in [" ", "/", "'", ":", "=", "."]):
40
+ # Surround these with quotes for parsers
41
+ s = f'"{s}"'
42
+
43
+ return s
44
+
45
+
46
+ class JSONFormatter(logging.Formatter):
47
+ """Formatter that outputs JSON from Plain's context system, with optional format string."""
48
+
49
+ def format(self, record):
50
+ # Build the JSON object from Plain's context data
51
+ log_obj = {
52
+ "timestamp": self.formatTime(record),
53
+ "level": record.levelname,
54
+ "message": record.getMessage(),
55
+ "logger": record.name,
56
+ }
57
+
58
+ # Add Plain's context data to the main JSON object
59
+ if hasattr(record, "context") and isinstance(record.context, dict):
60
+ log_obj.update(record.context)
61
+
62
+ # Handle exceptions
63
+ if record.exc_info:
64
+ log_obj["exception"] = self.formatException(record.exc_info)
65
+
66
+ # Add the json attribute to the record for %(json)s substitution
67
+ record.json = json.dumps(log_obj, default=str, ensure_ascii=False)
68
+
69
+ # Let the parent formatter handle the format string with %(json)s
70
+ return super().format(record)
plain/logs/loggers.py CHANGED
@@ -1,74 +1,183 @@
1
1
  import logging
2
-
3
- app_logger = logging.getLogger("app")
4
-
5
-
6
- class KVLogger:
7
- def __init__(self, logger):
8
- self.logger = logger
9
- self.context = {} # A dict that will be output in every log message
10
-
11
- def log(self, level, message, **kwargs):
12
- msg_kwargs = {
13
- **kwargs,
14
- **self.context, # Put these last so they're at the end of the line
15
- }
16
- self.logger.log(level, f"{message} {self._format_kwargs(msg_kwargs)}")
17
-
18
- def _format_kwargs(self, kwargs):
19
- outputs = []
20
-
21
- for k, v in kwargs.items():
22
- self._validate_key(k)
23
- formatted_value = self._format_value(v)
24
- outputs.append(f"{k}={formatted_value}")
25
-
26
- return " ".join(outputs)
27
-
28
- def _validate_key(self, key):
29
- if " " in key:
30
- raise ValueError("Keys cannot have spaces")
31
-
32
- if "=" in key:
33
- raise ValueError("Keys cannot have equals signs")
34
-
35
- if '"' in key or "'" in key:
36
- raise ValueError("Keys cannot have quotes")
37
-
38
- def _format_value(self, value):
39
- if isinstance(value, str):
40
- s = value
41
- else:
42
- s = str(value)
43
-
44
- if '"' in s:
45
- # Escape quotes and surround it
46
- s = s.replace('"', '\\"')
47
- s = f'"{s}"'
48
- elif s == "":
49
- # Quote empty strings instead of printing nothing
50
- s = '""'
51
- elif any(char in s for char in [" ", "/", "'", ":", "=", "."]):
52
- # Surround these with quotes for parsers
53
- s = f'"{s}"'
54
-
55
- return s
56
-
57
- def info(self, message, **kwargs):
58
- self.log(logging.INFO, message, **kwargs)
59
-
60
- def debug(self, message, **kwargs):
61
- self.log(logging.DEBUG, message, **kwargs)
62
-
63
- def warning(self, message, **kwargs):
64
- self.log(logging.WARNING, message, **kwargs)
65
-
66
- def error(self, message, **kwargs):
67
- self.log(logging.ERROR, message, **kwargs)
68
-
69
- def critical(self, message, **kwargs):
70
- self.log(logging.CRITICAL, message, **kwargs)
71
-
72
-
73
- # Make this accessible from the app_logger
74
- app_logger.kv = KVLogger(app_logger)
2
+ from contextlib import contextmanager
3
+
4
+ from .debug import DebugMode
5
+
6
+
7
+ class AppLogger(logging.Logger):
8
+ """Enhanced logger that supports kwargs-style logging and context management."""
9
+
10
+ def __init__(self, name):
11
+ super().__init__(name)
12
+ self.context = {} # Public, mutable context dict
13
+ self.debug_mode = DebugMode(self)
14
+
15
+ @contextmanager
16
+ def include_context(self, **kwargs):
17
+ """Context manager for temporary context."""
18
+ # Store original context
19
+ original_context = self.context.copy()
20
+
21
+ # Add temporary context
22
+ self.context.update(kwargs)
23
+
24
+ try:
25
+ yield
26
+ finally:
27
+ # Restore original context
28
+ self.context = original_context
29
+
30
+ def force_debug(self):
31
+ """Return context manager for temporarily enabling DEBUG level logging."""
32
+ return self.debug_mode
33
+
34
+ # Override logging methods with explicit parameters for IDE support
35
+ def debug(
36
+ self,
37
+ msg,
38
+ *args,
39
+ exc_info=None,
40
+ extra=None,
41
+ stack_info=False,
42
+ stacklevel=1,
43
+ **context,
44
+ ):
45
+ if self.isEnabledFor(logging.DEBUG):
46
+ self._log(
47
+ logging.DEBUG,
48
+ msg,
49
+ args,
50
+ exc_info=exc_info,
51
+ extra=extra,
52
+ stack_info=stack_info,
53
+ stacklevel=stacklevel,
54
+ **context,
55
+ )
56
+
57
+ def info(
58
+ self,
59
+ msg,
60
+ *args,
61
+ exc_info=None,
62
+ extra=None,
63
+ stack_info=False,
64
+ stacklevel=1,
65
+ **context,
66
+ ):
67
+ if self.isEnabledFor(logging.INFO):
68
+ self._log(
69
+ logging.INFO,
70
+ msg,
71
+ args,
72
+ exc_info=exc_info,
73
+ extra=extra,
74
+ stack_info=stack_info,
75
+ stacklevel=stacklevel,
76
+ **context,
77
+ )
78
+
79
+ def warning(
80
+ self,
81
+ msg,
82
+ *args,
83
+ exc_info=None,
84
+ extra=None,
85
+ stack_info=False,
86
+ stacklevel=1,
87
+ **context,
88
+ ):
89
+ if self.isEnabledFor(logging.WARNING):
90
+ self._log(
91
+ logging.WARNING,
92
+ msg,
93
+ args,
94
+ exc_info=exc_info,
95
+ extra=extra,
96
+ stack_info=stack_info,
97
+ stacklevel=stacklevel,
98
+ **context,
99
+ )
100
+
101
+ def error(
102
+ self,
103
+ msg,
104
+ *args,
105
+ exc_info=None,
106
+ extra=None,
107
+ stack_info=False,
108
+ stacklevel=1,
109
+ **context,
110
+ ):
111
+ if self.isEnabledFor(logging.ERROR):
112
+ self._log(
113
+ logging.ERROR,
114
+ msg,
115
+ args,
116
+ exc_info=exc_info,
117
+ extra=extra,
118
+ stack_info=stack_info,
119
+ stacklevel=stacklevel,
120
+ **context,
121
+ )
122
+
123
+ def critical(
124
+ self,
125
+ msg,
126
+ *args,
127
+ exc_info=None,
128
+ extra=None,
129
+ stack_info=False,
130
+ stacklevel=1,
131
+ **context,
132
+ ):
133
+ if self.isEnabledFor(logging.CRITICAL):
134
+ self._log(
135
+ logging.CRITICAL,
136
+ msg,
137
+ args,
138
+ exc_info=exc_info,
139
+ extra=extra,
140
+ stack_info=stack_info,
141
+ stacklevel=stacklevel,
142
+ **context,
143
+ )
144
+
145
+ def _log(
146
+ self,
147
+ level,
148
+ msg,
149
+ args,
150
+ exc_info=None,
151
+ extra=None,
152
+ stack_info=False,
153
+ stacklevel=1,
154
+ **context,
155
+ ):
156
+ """Low-level logging routine which creates a LogRecord and then calls all handlers."""
157
+ # Check if extra already has a 'context' key
158
+ if extra and "context" in extra:
159
+ raise ValueError(
160
+ "The 'context' key in extra is reserved for Plain's context system"
161
+ )
162
+
163
+ # Build final extra with context
164
+ extra = extra.copy() if extra else {}
165
+
166
+ # Add our context (persistent + kwargs) to extra["context"]
167
+ if self.context or context:
168
+ extra["context"] = {**self.context, **context}
169
+
170
+ # Call the parent logger's _log method with explicit parameters
171
+ super()._log(
172
+ level=level,
173
+ msg=msg,
174
+ args=args,
175
+ exc_info=exc_info,
176
+ extra=extra or None,
177
+ stack_info=stack_info,
178
+ stacklevel=stacklevel,
179
+ )
180
+
181
+
182
+ # Create the default app logger instance
183
+ app_logger = AppLogger("app")
plain/runtime/__init__.py CHANGED
@@ -3,6 +3,9 @@ import sys
3
3
  from importlib.metadata import entry_points
4
4
  from pathlib import Path
5
5
 
6
+ from plain.logs.configure import configure_logging
7
+ from plain.packages import packages_registry
8
+
6
9
  from .user_settings import Settings
7
10
 
8
11
  try:
@@ -48,9 +51,6 @@ def setup():
48
51
  for entry_point in entry_points().select(group="plain.setup"):
49
52
  entry_point.load()()
50
53
 
51
- from plain.logs import configure_logging
52
- from plain.packages import packages_registry
53
-
54
54
  if not APP_PATH.exists():
55
55
  raise AppPathNotFound(
56
56
  "No app directory found. Are you sure you're in a Plain project?"
@@ -62,7 +62,11 @@ def setup():
62
62
  if APP_PATH.parent.as_posix() not in sys.path:
63
63
  sys.path.insert(0, APP_PATH.parent.as_posix())
64
64
 
65
- configure_logging(settings.LOGGING)
65
+ configure_logging(
66
+ plain_log_level=settings.PLAIN_LOG_LEVEL,
67
+ app_log_level=settings.APP_LOG_LEVEL,
68
+ app_log_format=settings.APP_LOG_FORMAT,
69
+ )
66
70
 
67
71
  packages_registry.populate(settings.INSTALLED_PACKAGES)
68
72
 
@@ -3,6 +3,8 @@ Default Plain settings. Override these with settings in the module pointed to
3
3
  by the PLAIN_SETTINGS_MODULE environment variable.
4
4
  """
5
5
 
6
+ from os import environ
7
+
6
8
  from .utils import get_app_info_from_pyproject
7
9
 
8
10
  # MARK: Core Settings
@@ -137,9 +139,11 @@ CSRF_TRUSTED_ORIGINS: list[str] = []
137
139
  CSRF_EXEMPT_PATHS: list[str] = []
138
140
 
139
141
  # MARK: Logging
142
+ # (Uses some custom env names in addition to PLAIN_ prefixed )
140
143
 
141
- # Custom logging configuration.
142
- LOGGING = {}
144
+ PLAIN_LOG_LEVEL: str = environ.get("PLAIN_LOG_LEVEL", "INFO")
145
+ APP_LOG_LEVEL: str = environ.get("APP_LOG_LEVEL", "INFO")
146
+ APP_LOG_FORMAT: str = environ.get("APP_LOG_FORMAT", "keyvalue")
143
147
 
144
148
  # MARK: Assets
145
149
 
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.61.0
3
+ Version: 0.62.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
7
- Requires-Python: >=3.11
7
+ Requires-Python: >=3.13
8
8
  Requires-Dist: click>=8.0.0
9
9
  Requires-Dist: jinja2>=3.1.2
10
10
  Requires-Dist: opentelemetry-api>=1.34.1
@@ -1,5 +1,5 @@
1
- plain/AGENTS.md,sha256=Euyn6UG9gumJj1vXi6PFPmhRAlwedUfK7UcYTBF9Y9Y,893
2
- plain/CHANGELOG.md,sha256=X0ep2U-LXpociHtkEk-PTB30VVFiZHOocGunQgrqGAQ,11831
1
+ plain/AGENTS.md,sha256=5XMGBpJgbCNIpp60DPXB7bpAtFk8FAzqiZke95T965o,1038
2
+ plain/CHANGELOG.md,sha256=qEE2843NosxXT0v1-2HE02EX3pesOtoaBDYBqSFCI8k,12429
3
3
  plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
@@ -46,7 +46,7 @@ plain/cli/agent/md.py,sha256=7r1II8ckubBFOZNGPASWaPmJdgByWFPINLqIOzRetLQ,2581
46
46
  plain/cli/agent/prompt.py,sha256=rugYyQHV7JDNqGrx3_PPShwwqYlnEVbxw8RsczOo8tg,1253
47
47
  plain/cli/agent/request.py,sha256=JILrcxEMPagBXWrjNGMy3qatCYCXw-_uJMKkVHk_bho,6549
48
48
  plain/csrf/README.md,sha256=ApWpB-qlEf0LkOKm9Yr-6f_lB9XJEvGFDo_fraw8ghI,2391
49
- plain/csrf/middleware.py,sha256=d_vb8l0-KxzyqCivVq0jTCsFOm-ljwjmjVuZXKVYR5U,5113
49
+ plain/csrf/middleware.py,sha256=n5_7v6qwFKgiAnKVyJa7RhwHoWepLkPudzIgZtdku5A,5119
50
50
  plain/csrf/views.py,sha256=HwQqfI6KPelHP9gSXhjfZaTLQic71PKsoZ6DPhr1rKI,572
51
51
  plain/forms/README.md,sha256=7MJQxNBoKkg0rW16qF6bGpUBxZrMrWjl2DZZk6gjzAU,2258
52
52
  plain/forms/__init__.py,sha256=UxqPwB8CiYPCQdHmUc59jadqaXqDmXBH8y4bt9vTPms,226
@@ -70,17 +70,19 @@ plain/internal/files/uploadedfile.py,sha256=JRB7T3quQjg-1y3l1ASPxywtSQZhaeMc45uF
70
70
  plain/internal/files/uploadhandler.py,sha256=63_QUwAwfq3bevw79i0S7zt2EB2UBoO7MaauvezaVMY,7198
71
71
  plain/internal/files/utils.py,sha256=xN4HTJXDRdcoNyrL1dFd528MBwodRlHZM8DGTD_oBIg,2646
72
72
  plain/internal/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
- plain/internal/handlers/base.py,sha256=GFN5qoGqZmGinz6IOJgRW-dV4_nY5Dwo9L0F7muHvoc,6000
74
- plain/internal/handlers/exception.py,sha256=vfha_6-fz6S6VYCP1PMBfue2Gw-_th6jqaTE372fGlw,4809
73
+ plain/internal/handlers/base.py,sha256=ur-nYmpvXjXhu03aPP1KV5GSNaLL_QZoT8x0v8l6_wg,6006
74
+ plain/internal/handlers/exception.py,sha256=TbPYtgZ7ITJahUKhQWkptHK28Lb4zh_nOviNctC2EYs,4815
75
75
  plain/internal/handlers/wsgi.py,sha256=dgPT29t_F9llB-c5RYU3SHxGuZNaZ83xRjOfuOmtOl8,8209
76
76
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
77
77
  plain/internal/middleware/headers.py,sha256=ENIW1Gwat54hv-ejgp2R8QTZm-PlaI7k44WU01YQrNk,964
78
78
  plain/internal/middleware/https.py,sha256=mS1YejfLB_5qlAoMfanh8Wn2O-RdSpOBdhvw2DRcTHs,1257
79
79
  plain/internal/middleware/slash.py,sha256=JWcIfGbXEKH00I7STq1AMdHhFGmQHC8lkKENa6280ko,2846
80
- plain/logs/README.md,sha256=ON6Zylg_WA_V7QiIbRY7sgsl2nfG7KFzIbxK2x3rPuc,1419
81
- plain/logs/__init__.py,sha256=rASvo4qFBDIHfkACmGLNGa6lRGbG9PbNjW6FmBt95ys,168
82
- plain/logs/configure.py,sha256=2kDJ-WPv3PV4H46mz5tTfzIa2kvN6cjVlb3t-AEbMyk,1307
83
- plain/logs/loggers.py,sha256=iz9SYcwP9w5QAuwpULl48SFkVyJuuMoQ_fdLgdCHpNg,2121
80
+ plain/logs/README.md,sha256=rzOHfngjizLgXL21g0svC1Cdya2s_gBA_E-IljtHpy8,4069
81
+ plain/logs/__init__.py,sha256=gFVMcNn5D6z0JrvUJgGsOeYj1NKNtEXhw0MvPDtkN6w,58
82
+ plain/logs/configure.py,sha256=G5kLP-92hOWE7vlWG3lhSbzOKXobavFbqjohevJF1Jg,1322
83
+ plain/logs/debug.py,sha256=QBXA_M498uGtqFnwHN08z6fItiGR4A732JyIWG2b39Q,1048
84
+ plain/logs/formatters.py,sha256=sHB4yo7806YN_V6cCzs1WOGMIZLq5q_eOndRqODI-T4,2380
85
+ plain/logs/loggers.py,sha256=kV2uZDxA5XU4GJF8dAOqny5LmbC8nPKZmakS-xX4x1Y,4625
84
86
  plain/logs/utils.py,sha256=9UzdCCQXJinGDs71Ngw297mlWkhgZStSd67ya4NOW98,1257
85
87
  plain/packages/README.md,sha256=iNqMtwFDVNf2TqKUzLKQW5Y4_GsssmdB4cVerzu27Ro,2674
86
88
  plain/packages/__init__.py,sha256=OpQny0xLplPdPpozVUUkrW2gB-IIYyDT1b4zMzOcCC4,160
@@ -94,8 +96,8 @@ plain/preflight/registry.py,sha256=vcqzaE1MIneNL_ydKPy_1zrSThnzsrWARSClLCJ-4b8,2
94
96
  plain/preflight/security.py,sha256=oxUZBp2M0bpBfUoLYepIxoex2Y90nyjlrL8XU8UTHYY,2438
95
97
  plain/preflight/urls.py,sha256=cQ-WnFa_5oztpKdtwhuIGb7pXEml__bHsjs1SWO2YNI,1468
96
98
  plain/runtime/README.md,sha256=sTqXXJkckwqkk9O06XMMSNRokAYjrZBnB50JD36BsYI,4873
97
- plain/runtime/__init__.py,sha256=8GtvKROf3HUCtneDYXTbEioPcCtwnV76dP57n2PnjuE,2343
98
- plain/runtime/global_settings.py,sha256=LX4g0ncNif_STuM83Idcron1j_TnQ9TJwWbVywexyZo,5788
99
+ plain/runtime/__init__.py,sha256=byFYnHrpUCwkpkHtdNhxr9iUdLDCWJjy92HPj30Ilck,2478
100
+ plain/runtime/global_settings.py,sha256=cDhsZOh0FemxUQE41viBjoMOriXV9_JnbSu28Kon_uI,6014
99
101
  plain/runtime/user_settings.py,sha256=OzMiEkE6ZQ50nxd1WIqirXPiNuMAQULklYHEzgzLWgA,11027
100
102
  plain/runtime/utils.py,sha256=p5IuNTzc7Kq-9Ym7etYnt_xqHw5TioxfSkFeq1bKdgk,832
101
103
  plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
@@ -158,8 +160,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
158
160
  plain/views/objects.py,sha256=v3Vgvdoc1s0QW6JNWWrO5XXy9zF7vgwndgxX1eOSQoE,4999
159
161
  plain/views/redirect.py,sha256=Xpb3cB7nZYvKgkNqcAxf9Jwm2SWcQ0u2xz4oO5M3vP8,1909
160
162
  plain/views/templates.py,sha256=oAlebEyfES0rzBhfyEJzFmgLkpkbleA6Eip-8zDp-yk,1863
161
- plain-0.61.0.dist-info/METADATA,sha256=9fm4uV_Uo8yyhaMeWayg-UyS4_y1_W6sI_tKNONklAY,4488
162
- plain-0.61.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
163
- plain-0.61.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
164
- plain-0.61.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
165
- plain-0.61.0.dist-info/RECORD,,
163
+ plain-0.62.0.dist-info/METADATA,sha256=vjydB1elvpE9PC6UhqU1F8B5eVxzWsoz2lrDJTnEYL0,4488
164
+ plain-0.62.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
165
+ plain-0.62.0.dist-info/entry_points.txt,sha256=nn4uKTRRZuEKOJv3810s3jtSMW0Gew7XDYiKIvBRR6M,93
166
+ plain-0.62.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
167
+ plain-0.62.0.dist-info/RECORD,,
File without changes