plain 0.68.0__py3-none-any.whl → 0.103.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.
Files changed (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/logs/README.md CHANGED
@@ -1,139 +1,238 @@
1
1
  # Logs
2
2
 
3
- **Logging configuration and utilities.**
3
+ **Structured logging with sensible defaults and zero configuration.**
4
4
 
5
5
  - [Overview](#overview)
6
- - [`app_logger`](#app_logger)
6
+ - [Using app_logger](#using-app_logger)
7
+ - [Basic logging](#basic-logging)
8
+ - [Adding context](#adding-context)
7
9
  - [Output formats](#output-formats)
10
+ - [Key-value format](#key-value-format)
11
+ - [JSON format](#json-format)
12
+ - [Standard format](#standard-format)
8
13
  - [Context management](#context-management)
14
+ - [Persistent context](#persistent-context)
15
+ - [Temporary context](#temporary-context)
9
16
  - [Debug mode](#debug-mode)
10
- - [Advanced usage](#advanced-usage)
11
- - [Logging settings](#logging-settings)
17
+ - [Output streams](#output-streams)
18
+ - [Settings reference](#settings-reference)
19
+ - [FAQs](#faqs)
20
+ - [Installation](#installation)
12
21
 
13
22
  ## Overview
14
23
 
15
- In Python, configuring logging can be surprisingly complex. For most use cases, Plain provides a [default configuration](./configure.py) that "just works".
24
+ Python's logging module is powerful but notoriously difficult to configure. Plain provides a ready-to-use logging setup that works out of the box while supporting structured logging for production environments.
16
25
 
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.
26
+ You get two pre-configured loggers: `plain` (for framework internals) and `app` (for your application code). Both default to the `INFO` level and can be adjusted via environment variables.
18
27
 
19
- The `app_logger` supports multiple output formats and provides a friendly kwargs-based API for structured logging.
28
+ ```python
29
+ from plain.logs import app_logger
30
+
31
+ # Simple message logging
32
+ app_logger.info("Application started")
33
+
34
+ # Structured logging with context data
35
+ app_logger.info("User logged in", context={"user_id": 123, "method": "oauth"})
36
+
37
+ # All log levels work the same way
38
+ app_logger.warning("Rate limit approaching", context={"requests": 95, "limit": 100})
39
+ app_logger.error("Payment failed", context={"order_id": "abc-123", "reason": "insufficient_funds"})
40
+ ```
41
+
42
+ ## Using app_logger
20
43
 
21
- ## `app_logger`
44
+ ### Basic logging
22
45
 
23
- The `app_logger` is an enhanced logger that supports kwargs-style logging and multiple output formats.
46
+ The [`app_logger`](./app.py#AppLogger) supports all standard logging levels: `debug`, `info`, `warning`, `error`, and `critical`.
24
47
 
25
48
  ```python
26
- from plain.logs import app_logger
49
+ app_logger.debug("Entering validation step")
50
+ app_logger.info("Request processed successfully")
51
+ app_logger.warning("Cache miss, falling back to database")
52
+ app_logger.error("Failed to connect to external service")
53
+ app_logger.critical("Database connection pool exhausted")
54
+ ```
27
55
 
56
+ ### Adding context
28
57
 
29
- def example_function():
30
- # Basic logging
31
- app_logger.info("User logged in")
58
+ Pass structured data using the `context` parameter. This data appears in your log output based on your chosen format.
32
59
 
33
- # With structured context data (explicit **context parameter)
34
- app_logger.info("User action", user_id=123, action="login", success=True)
60
+ ```python
61
+ app_logger.info("Order placed", context={
62
+ "order_id": "ord-456",
63
+ "items": 3,
64
+ "total": 99.99,
65
+ })
66
+ ```
35
67
 
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")
68
+ You can also include exception tracebacks:
40
69
 
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
- )
70
+ ```python
71
+ try:
72
+ process_payment()
73
+ except PaymentError:
74
+ app_logger.error(
75
+ "Payment processing failed",
76
+ exc_info=True,
77
+ context={"order_id": "ord-456"},
78
+ )
52
79
  ```
53
80
 
54
81
  ## Output formats
55
82
 
56
- The `app_logger` supports three output formats controlled by the `APP_LOG_FORMAT` environment variable:
83
+ Control the log format with the `APP_LOG_FORMAT` environment variable.
84
+
85
+ ### Key-value format
57
86
 
58
- ### Key-Value format (default)
87
+ The default format. Context data appears as `key=value` pairs, easy for humans to read and machines to parse.
59
88
 
60
89
  ```bash
61
- export APP_LOG_FORMAT=keyvalue # or leave unset for default
90
+ export APP_LOG_FORMAT=keyvalue
62
91
  ```
63
92
 
64
93
  ```
65
- [INFO] User action user_id=123 action=login success=True
66
- [ERROR] Database error error_code=500 table=users
94
+ [INFO] User logged in user_id=123 method=oauth
95
+ [ERROR] Payment failed order_id="abc-123" reason="insufficient_funds"
67
96
  ```
68
97
 
69
98
  ### JSON format
70
99
 
100
+ Each log entry is a single JSON object. Ideal for log aggregation services like Datadog, Splunk, or CloudWatch.
101
+
71
102
  ```bash
72
103
  export APP_LOG_FORMAT=json
73
104
  ```
74
105
 
75
106
  ```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"}
107
+ {"timestamp": "2024-01-15 10:30:00,123", "level": "INFO", "message": "User logged in", "logger": "app", "user_id": 123, "method": "oauth"}
78
108
  ```
79
109
 
80
110
  ### Standard format
81
111
 
112
+ A minimal format that omits context data entirely.
113
+
82
114
  ```bash
83
115
  export APP_LOG_FORMAT=standard
84
116
  ```
85
117
 
86
118
  ```
87
- [INFO] User action
88
- [ERROR] Database error
119
+ [INFO] User logged in
89
120
  ```
90
121
 
91
- Note: In standard format, the context kwargs are ignored and not displayed.
92
-
93
122
  ## Context management
94
123
 
95
- The `app_logger` provides powerful context management for adding data to multiple log statements.
96
-
97
124
  ### Persistent context
98
125
 
99
- Use the `context` dict to add data that persists across log calls:
126
+ Add context that applies to all subsequent log calls by modifying the `context` dict directly.
100
127
 
101
128
  ```python
102
- # Set persistent context
103
- app_logger.context["user_id"] = 123
104
- app_logger.context["request_id"] = "abc456"
129
+ # Set context at the start of a request
130
+ app_logger.context["request_id"] = "req-789"
131
+ app_logger.context["user_id"] = 42
105
132
 
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
133
+ app_logger.info("Starting request") # Includes request_id and user_id
134
+ app_logger.info("Fetching data") # Includes request_id and user_id
109
135
 
110
- # Clear context
136
+ # Clear when done
111
137
  app_logger.context.clear()
112
138
  ```
113
139
 
114
140
  ### Temporary context
115
141
 
116
- Use `include_context()` for temporary context that only applies within a block:
142
+ Use `include_context()` when you need context for a specific block of code.
117
143
 
118
144
  ```python
119
- app_logger.context["user_id"] = 123 # Persistent
145
+ app_logger.context["user_id"] = 42
120
146
 
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
147
+ with app_logger.include_context(operation="checkout", cart_id="cart-123"):
148
+ app_logger.info("Starting checkout") # Has user_id, operation, cart_id
149
+ app_logger.info("Validating items") # Has user_id, operation, cart_id
124
150
 
125
- app_logger.info("Payment complete") # Only has user_id
151
+ app_logger.info("Checkout complete") # Only has user_id
126
152
  ```
127
153
 
128
154
  ## Debug mode
129
155
 
130
- The `force_debug()` context manager allows temporarily enabling DEBUG level logging:
156
+ When you need to temporarily see debug-level logs (even if the logger is set to `INFO`), use `force_debug()`.
131
157
 
132
158
  ```python
133
- # Debug messages might not show at INFO level
134
- app_logger.debug("This might not appear")
159
+ # These won't show if log level is INFO
160
+ app_logger.debug("Detailed trace info")
135
161
 
136
- # Temporarily enable debug logging
162
+ # Temporarily enable debug output
137
163
  with app_logger.force_debug():
138
- app_logger.debug("This will definitely appear", extra_data="debug_info")
164
+ app_logger.debug("This will appear")
165
+ app_logger.debug("So will this", context={"step": "validation"})
166
+
167
+ # Back to normal
168
+ app_logger.debug("This won't show again")
169
+ ```
170
+
171
+ The [`DebugMode`](./debug.py#DebugMode) class handles reference counting, so nested `force_debug()` calls work correctly.
172
+
173
+ ## Output streams
174
+
175
+ By default, Plain splits log output by severity:
176
+
177
+ - **DEBUG, INFO** go to `stdout`
178
+ - **WARNING, ERROR, CRITICAL** go to `stderr`
179
+
180
+ This helps cloud platforms automatically classify log severity. You can change this behavior with `PLAIN_LOG_STREAM`:
181
+
182
+ ```bash
183
+ # Default: split by level
184
+ export PLAIN_LOG_STREAM=split
185
+
186
+ # All logs to stdout
187
+ export PLAIN_LOG_STREAM=stdout
188
+
189
+ # All logs to stderr (traditional Python behavior)
190
+ export PLAIN_LOG_STREAM=stderr
191
+ ```
192
+
193
+ ## Settings reference
194
+
195
+ All logging settings use environment variables:
196
+
197
+ | Environment Variable | Default | Description |
198
+ | --------------------------- | ---------- | ------------------------------------------------ |
199
+ | `PLAIN_FRAMEWORK_LOG_LEVEL` | `INFO` | Log level for the `plain` logger |
200
+ | `PLAIN_LOG_LEVEL` | `INFO` | Log level for the `app` logger |
201
+ | `PLAIN_LOG_FORMAT` | `keyvalue` | Output format: `json`, `keyvalue`, or `standard` |
202
+ | `PLAIN_LOG_STREAM` | `split` | Output stream: `split`, `stdout`, or `stderr` |
203
+
204
+ Valid log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
205
+
206
+ ## FAQs
207
+
208
+ #### How do I use a custom logger instead of app_logger?
209
+
210
+ You can use Python's standard `logging.getLogger()` for additional loggers. They won't have the context features of `app_logger`, but they'll use Plain's output configuration.
211
+
212
+ #### Can I use app_logger in library code?
213
+
214
+ The `app_logger` is designed for application code. If you're writing a reusable library, use `logging.getLogger(__name__)` to allow users to configure logging independently.
215
+
216
+ #### Why are my debug logs not showing?
217
+
218
+ The default log level is `INFO`. Set `PLAIN_LOG_LEVEL=DEBUG` in your environment or use `app_logger.force_debug()` temporarily.
219
+
220
+ #### How do I add context to exception logs?
221
+
222
+ Pass both `exc_info=True` and `context` to include both the traceback and structured data:
223
+
224
+ ```python
225
+ except Exception:
226
+ app_logger.error("Operation failed", exc_info=True, context={"operation": "sync"})
227
+ ```
228
+
229
+ ## Installation
230
+
231
+ `plain.logs` is included with Plain by default. No additional installation is required.
232
+
233
+ To adjust log levels for development, add environment variables to your shell or `.env` file:
234
+
235
+ ```bash
236
+ PLAIN_LOG_LEVEL=DEBUG
237
+ PLAIN_FRAMEWORK_LOG_LEVEL=WARNING
139
238
  ```
plain/logs/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- from .loggers import app_logger
1
+ from .app import app_logger
2
2
 
3
3
  __all__ = ["app_logger"]
@@ -1,19 +1,23 @@
1
+ from __future__ import annotations
2
+
1
3
  import logging
4
+ from collections.abc import Generator
2
5
  from contextlib import contextmanager
6
+ from typing import Any
3
7
 
4
8
  from .debug import DebugMode
5
9
 
6
10
 
7
11
  class AppLogger(logging.Logger):
8
- """Enhanced logger that supports kwargs-style logging and context management."""
12
+ """Enhanced logger that supports context dict logging and context management."""
9
13
 
10
- def __init__(self, name):
14
+ def __init__(self, name: str):
11
15
  super().__init__(name)
12
- self.context = {} # Public, mutable context dict
16
+ self.context: dict[str, Any] = {} # Public, mutable context dict
13
17
  self.debug_mode = DebugMode(self)
14
18
 
15
19
  @contextmanager
16
- def include_context(self, **kwargs):
20
+ def include_context(self, **kwargs: Any) -> Generator[None, None, None]:
17
21
  """Context manager for temporary context."""
18
22
  # Store original context
19
23
  original_context = self.context.copy()
@@ -27,21 +31,21 @@ class AppLogger(logging.Logger):
27
31
  # Restore original context
28
32
  self.context = original_context
29
33
 
30
- def force_debug(self):
34
+ def force_debug(self) -> DebugMode:
31
35
  """Return context manager for temporarily enabling DEBUG level logging."""
32
36
  return self.debug_mode
33
37
 
34
38
  # Override logging methods with explicit parameters for IDE support
35
- def debug(
39
+ def debug( # type: ignore[override]
36
40
  self,
37
- msg,
38
- *args,
39
- exc_info=None,
40
- extra=None,
41
- stack_info=False,
42
- stacklevel=1,
43
- **context,
44
- ):
41
+ msg: object,
42
+ *args: object,
43
+ exc_info: Any = None,
44
+ extra: dict[str, Any] | None = None,
45
+ stack_info: bool = False,
46
+ stacklevel: int = 1,
47
+ context: dict[str, Any] | None = None,
48
+ ) -> None:
45
49
  if self.isEnabledFor(logging.DEBUG):
46
50
  self._log(
47
51
  logging.DEBUG,
@@ -51,19 +55,19 @@ class AppLogger(logging.Logger):
51
55
  extra=extra,
52
56
  stack_info=stack_info,
53
57
  stacklevel=stacklevel,
54
- **context,
58
+ context=context,
55
59
  )
56
60
 
57
- def info(
61
+ def info( # type: ignore[override]
58
62
  self,
59
- msg,
60
- *args,
61
- exc_info=None,
62
- extra=None,
63
- stack_info=False,
64
- stacklevel=1,
65
- **context,
66
- ):
63
+ msg: object,
64
+ *args: object,
65
+ exc_info: Any = None,
66
+ extra: dict[str, Any] | None = None,
67
+ stack_info: bool = False,
68
+ stacklevel: int = 1,
69
+ context: dict[str, Any] | None = None,
70
+ ) -> None:
67
71
  if self.isEnabledFor(logging.INFO):
68
72
  self._log(
69
73
  logging.INFO,
@@ -73,19 +77,19 @@ class AppLogger(logging.Logger):
73
77
  extra=extra,
74
78
  stack_info=stack_info,
75
79
  stacklevel=stacklevel,
76
- **context,
80
+ context=context,
77
81
  )
78
82
 
79
- def warning(
83
+ def warning( # type: ignore[override]
80
84
  self,
81
- msg,
82
- *args,
83
- exc_info=None,
84
- extra=None,
85
- stack_info=False,
86
- stacklevel=1,
87
- **context,
88
- ):
85
+ msg: object,
86
+ *args: object,
87
+ exc_info: Any = None,
88
+ extra: dict[str, Any] | None = None,
89
+ stack_info: bool = False,
90
+ stacklevel: int = 1,
91
+ context: dict[str, Any] | None = None,
92
+ ) -> None:
89
93
  if self.isEnabledFor(logging.WARNING):
90
94
  self._log(
91
95
  logging.WARNING,
@@ -95,19 +99,19 @@ class AppLogger(logging.Logger):
95
99
  extra=extra,
96
100
  stack_info=stack_info,
97
101
  stacklevel=stacklevel,
98
- **context,
102
+ context=context,
99
103
  )
100
104
 
101
- def error(
105
+ def error( # type: ignore[override]
102
106
  self,
103
- msg,
104
- *args,
105
- exc_info=None,
106
- extra=None,
107
- stack_info=False,
108
- stacklevel=1,
109
- **context,
110
- ):
107
+ msg: object,
108
+ *args: object,
109
+ exc_info: Any = None,
110
+ extra: dict[str, Any] | None = None,
111
+ stack_info: bool = False,
112
+ stacklevel: int = 1,
113
+ context: dict[str, Any] | None = None,
114
+ ) -> None:
111
115
  if self.isEnabledFor(logging.ERROR):
112
116
  self._log(
113
117
  logging.ERROR,
@@ -117,19 +121,19 @@ class AppLogger(logging.Logger):
117
121
  extra=extra,
118
122
  stack_info=stack_info,
119
123
  stacklevel=stacklevel,
120
- **context,
124
+ context=context,
121
125
  )
122
126
 
123
- def critical(
127
+ def critical( # type: ignore[override]
124
128
  self,
125
- msg,
126
- *args,
127
- exc_info=None,
128
- extra=None,
129
- stack_info=False,
130
- stacklevel=1,
131
- **context,
132
- ):
129
+ msg: object,
130
+ *args: object,
131
+ exc_info: Any = None,
132
+ extra: dict[str, Any] | None = None,
133
+ stack_info: bool = False,
134
+ stacklevel: int = 1,
135
+ context: dict[str, Any] | None = None,
136
+ ) -> None:
133
137
  if self.isEnabledFor(logging.CRITICAL):
134
138
  self._log(
135
139
  logging.CRITICAL,
@@ -139,20 +143,20 @@ class AppLogger(logging.Logger):
139
143
  extra=extra,
140
144
  stack_info=stack_info,
141
145
  stacklevel=stacklevel,
142
- **context,
146
+ context=context,
143
147
  )
144
148
 
145
- def _log(
149
+ def _log( # type: ignore[override]
146
150
  self,
147
- level,
148
- msg,
149
- args,
150
- exc_info=None,
151
- extra=None,
152
- stack_info=False,
153
- stacklevel=1,
154
- **context,
155
- ):
151
+ level: int,
152
+ msg: object,
153
+ args: tuple[object, ...],
154
+ exc_info: Any = None,
155
+ extra: dict[str, Any] | None = None,
156
+ stack_info: bool = False,
157
+ stacklevel: int = 1,
158
+ context: dict[str, Any] | None = None,
159
+ ) -> None:
156
160
  """Low-level logging routine which creates a LogRecord and then calls all handlers."""
157
161
  # Check if extra already has a 'context' key
158
162
  if extra and "context" in extra:
@@ -163,9 +167,9 @@ class AppLogger(logging.Logger):
163
167
  # Build final extra with context
164
168
  extra = extra.copy() if extra else {}
165
169
 
166
- # Add our context (persistent + kwargs) to extra["context"]
170
+ # Add our context (persistent + passed context) to extra["context"]
167
171
  if self.context or context:
168
- extra["context"] = {**self.context, **context}
172
+ extra["context"] = {**self.context, **(context or {})}
169
173
 
170
174
  # Call the parent logger's _log method with explicit parameters
171
175
  super()._log(
plain/logs/configure.py CHANGED
@@ -1,36 +1,85 @@
1
1
  import logging
2
+ import sys
3
+ from typing import TextIO
2
4
 
5
+ from .filters import DebugInfoFilter, WarningErrorCriticalFilter
3
6
  from .formatters import JSONFormatter, KeyValueFormatter
4
7
 
5
8
 
6
- def configure_logging(*, plain_log_level, app_log_level, app_log_format):
9
+ def attach_log_handlers(
10
+ *,
11
+ logger: logging.Logger,
12
+ info_stream: TextIO,
13
+ warning_stream: TextIO,
14
+ formatter: logging.Formatter,
15
+ ) -> None:
16
+ """Attach two handlers to a logger that split by log level.
17
+
18
+ INFO and below go to info_stream, WARNING and above go to warning_stream.
19
+ """
20
+ # DEBUG and INFO handler
21
+ info_handler = logging.StreamHandler(info_stream)
22
+ info_handler.addFilter(DebugInfoFilter())
23
+ info_handler.setFormatter(formatter)
24
+ logger.addHandler(info_handler)
25
+
26
+ # WARNING, ERROR, and CRITICAL handler
27
+ warning_handler = logging.StreamHandler(warning_stream)
28
+ warning_handler.addFilter(WarningErrorCriticalFilter())
29
+ warning_handler.setFormatter(formatter)
30
+ logger.addHandler(warning_handler)
31
+
32
+
33
+ def configure_logging(
34
+ *,
35
+ plain_log_level: int | str,
36
+ app_log_level: int | str,
37
+ app_log_format: str,
38
+ log_stream: str = "split",
39
+ ) -> None:
40
+ # Determine which streams to use based on log_stream setting
41
+ if log_stream == "split":
42
+ info_stream = sys.stdout
43
+ warning_stream = sys.stderr
44
+ elif log_stream == "stdout":
45
+ info_stream = sys.stdout
46
+ warning_stream = sys.stdout
47
+ else: # stderr (or any other value defaults to stderr for backwards compat)
48
+ info_stream = sys.stderr
49
+ warning_stream = sys.stderr
50
+
7
51
  # Create and configure the plain logger (uses standard Logger, not AppLogger)
8
- plain_logger = logging.Logger("plain")
52
+ plain_logger = logging.getLogger("plain")
9
53
  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)
54
+ attach_log_handlers(
55
+ logger=plain_logger,
56
+ info_stream=info_stream,
57
+ warning_stream=warning_stream,
58
+ formatter=logging.Formatter("[%(levelname)s] %(message)s"),
59
+ )
13
60
  plain_logger.propagate = False
14
- logging.root.manager.loggerDict["plain"] = plain_logger
15
61
 
16
62
  # Configure the existing app_logger
17
- from .loggers import app_logger
63
+ from .app import app_logger
18
64
 
19
65
  app_logger.setLevel(app_log_level)
20
66
  app_logger.propagate = False
21
67
 
22
- app_handler = logging.StreamHandler()
68
+ # Determine formatter based on app_log_format
23
69
  match app_log_format:
24
70
  case "json":
25
- app_handler.setFormatter(JSONFormatter("%(json)s"))
71
+ formatter = JSONFormatter("%(json)s")
26
72
  case "keyvalue":
27
- app_handler.setFormatter(
28
- KeyValueFormatter("[%(levelname)s] %(message)s %(keyvalue)s")
29
- )
73
+ formatter = KeyValueFormatter("[%(levelname)s] %(message)s %(keyvalue)s")
30
74
  case _:
31
- app_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
75
+ formatter = logging.Formatter("[%(levelname)s] %(message)s")
32
76
 
33
- app_logger.addHandler(app_handler)
77
+ attach_log_handlers(
78
+ logger=app_logger,
79
+ info_stream=info_stream,
80
+ warning_stream=warning_stream,
81
+ formatter=formatter,
82
+ )
34
83
 
35
84
  # Register the app_logger in the logging system so getLogger("app") returns it
36
85
  logging.root.manager.loggerDict["app"] = app_logger