plain 0.61.0__tar.gz → 0.62.0__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.
Files changed (183) hide show
  1. {plain-0.61.0 → plain-0.62.0}/PKG-INFO +2 -2
  2. {plain-0.61.0 → plain-0.62.0}/plain/AGENTS.md +5 -1
  3. {plain-0.61.0 → plain-0.62.0}/plain/CHANGELOG.md +12 -0
  4. {plain-0.61.0 → plain-0.62.0}/plain/csrf/middleware.py +1 -1
  5. {plain-0.61.0 → plain-0.62.0}/plain/internal/handlers/base.py +1 -1
  6. {plain-0.61.0 → plain-0.62.0}/plain/internal/handlers/exception.py +1 -1
  7. plain-0.62.0/plain/logs/README.md +139 -0
  8. plain-0.62.0/plain/logs/__init__.py +3 -0
  9. plain-0.62.0/plain/logs/configure.py +36 -0
  10. plain-0.62.0/plain/logs/debug.py +36 -0
  11. plain-0.62.0/plain/logs/formatters.py +70 -0
  12. plain-0.62.0/plain/logs/loggers.py +183 -0
  13. {plain-0.61.0 → plain-0.62.0}/plain/runtime/__init__.py +8 -4
  14. {plain-0.61.0 → plain-0.62.0}/plain/runtime/global_settings.py +6 -2
  15. {plain-0.61.0 → plain-0.62.0}/pyproject.toml +2 -2
  16. plain-0.62.0/tests/test_logs.py +295 -0
  17. plain-0.61.0/plain/logs/README.md +0 -61
  18. plain-0.61.0/plain/logs/__init__.py +0 -5
  19. plain-0.61.0/plain/logs/configure.py +0 -44
  20. plain-0.61.0/plain/logs/loggers.py +0 -74
  21. {plain-0.61.0 → plain-0.62.0}/.gitignore +0 -0
  22. {plain-0.61.0 → plain-0.62.0}/LICENSE +0 -0
  23. {plain-0.61.0 → plain-0.62.0}/README.md +0 -0
  24. {plain-0.61.0 → plain-0.62.0}/plain/README.md +0 -0
  25. {plain-0.61.0 → plain-0.62.0}/plain/__main__.py +0 -0
  26. {plain-0.61.0 → plain-0.62.0}/plain/assets/README.md +0 -0
  27. {plain-0.61.0 → plain-0.62.0}/plain/assets/__init__.py +0 -0
  28. {plain-0.61.0 → plain-0.62.0}/plain/assets/compile.py +0 -0
  29. {plain-0.61.0 → plain-0.62.0}/plain/assets/finders.py +0 -0
  30. {plain-0.61.0 → plain-0.62.0}/plain/assets/fingerprints.py +0 -0
  31. {plain-0.61.0 → plain-0.62.0}/plain/assets/urls.py +0 -0
  32. {plain-0.61.0 → plain-0.62.0}/plain/assets/views.py +0 -0
  33. {plain-0.61.0 → plain-0.62.0}/plain/chores/README.md +0 -0
  34. {plain-0.61.0 → plain-0.62.0}/plain/chores/__init__.py +0 -0
  35. {plain-0.61.0 → plain-0.62.0}/plain/chores/registry.py +0 -0
  36. {plain-0.61.0 → plain-0.62.0}/plain/cli/README.md +0 -0
  37. {plain-0.61.0 → plain-0.62.0}/plain/cli/__init__.py +0 -0
  38. {plain-0.61.0 → plain-0.62.0}/plain/cli/agent/__init__.py +0 -0
  39. {plain-0.61.0 → plain-0.62.0}/plain/cli/agent/docs.py +0 -0
  40. {plain-0.61.0 → plain-0.62.0}/plain/cli/agent/llmdocs.py +0 -0
  41. {plain-0.61.0 → plain-0.62.0}/plain/cli/agent/md.py +0 -0
  42. {plain-0.61.0 → plain-0.62.0}/plain/cli/agent/prompt.py +0 -0
  43. {plain-0.61.0 → plain-0.62.0}/plain/cli/agent/request.py +0 -0
  44. {plain-0.61.0 → plain-0.62.0}/plain/cli/build.py +0 -0
  45. {plain-0.61.0 → plain-0.62.0}/plain/cli/changelog.py +0 -0
  46. {plain-0.61.0 → plain-0.62.0}/plain/cli/chores.py +0 -0
  47. {plain-0.61.0 → plain-0.62.0}/plain/cli/core.py +0 -0
  48. {plain-0.61.0 → plain-0.62.0}/plain/cli/docs.py +0 -0
  49. {plain-0.61.0 → plain-0.62.0}/plain/cli/formatting.py +0 -0
  50. {plain-0.61.0 → plain-0.62.0}/plain/cli/install.py +0 -0
  51. {plain-0.61.0 → plain-0.62.0}/plain/cli/output.py +0 -0
  52. {plain-0.61.0 → plain-0.62.0}/plain/cli/preflight.py +0 -0
  53. {plain-0.61.0 → plain-0.62.0}/plain/cli/print.py +0 -0
  54. {plain-0.61.0 → plain-0.62.0}/plain/cli/registry.py +0 -0
  55. {plain-0.61.0 → plain-0.62.0}/plain/cli/scaffold.py +0 -0
  56. {plain-0.61.0 → plain-0.62.0}/plain/cli/settings.py +0 -0
  57. {plain-0.61.0 → plain-0.62.0}/plain/cli/shell.py +0 -0
  58. {plain-0.61.0 → plain-0.62.0}/plain/cli/startup.py +0 -0
  59. {plain-0.61.0 → plain-0.62.0}/plain/cli/upgrade.py +0 -0
  60. {plain-0.61.0 → plain-0.62.0}/plain/cli/urls.py +0 -0
  61. {plain-0.61.0 → plain-0.62.0}/plain/cli/utils.py +0 -0
  62. {plain-0.61.0 → plain-0.62.0}/plain/csrf/README.md +0 -0
  63. {plain-0.61.0 → plain-0.62.0}/plain/csrf/views.py +0 -0
  64. {plain-0.61.0 → plain-0.62.0}/plain/debug.py +0 -0
  65. {plain-0.61.0 → plain-0.62.0}/plain/exceptions.py +0 -0
  66. {plain-0.61.0 → plain-0.62.0}/plain/forms/README.md +0 -0
  67. {plain-0.61.0 → plain-0.62.0}/plain/forms/__init__.py +0 -0
  68. {plain-0.61.0 → plain-0.62.0}/plain/forms/boundfield.py +0 -0
  69. {plain-0.61.0 → plain-0.62.0}/plain/forms/exceptions.py +0 -0
  70. {plain-0.61.0 → plain-0.62.0}/plain/forms/fields.py +0 -0
  71. {plain-0.61.0 → plain-0.62.0}/plain/forms/forms.py +0 -0
  72. {plain-0.61.0 → plain-0.62.0}/plain/http/README.md +0 -0
  73. {plain-0.61.0 → plain-0.62.0}/plain/http/__init__.py +0 -0
  74. {plain-0.61.0 → plain-0.62.0}/plain/http/cookie.py +0 -0
  75. {plain-0.61.0 → plain-0.62.0}/plain/http/multipartparser.py +0 -0
  76. {plain-0.61.0 → plain-0.62.0}/plain/http/request.py +0 -0
  77. {plain-0.61.0 → plain-0.62.0}/plain/http/response.py +0 -0
  78. {plain-0.61.0 → plain-0.62.0}/plain/internal/__init__.py +0 -0
  79. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/__init__.py +0 -0
  80. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/base.py +0 -0
  81. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/locks.py +0 -0
  82. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/move.py +0 -0
  83. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/temp.py +0 -0
  84. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/uploadedfile.py +0 -0
  85. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/uploadhandler.py +0 -0
  86. {plain-0.61.0 → plain-0.62.0}/plain/internal/files/utils.py +0 -0
  87. {plain-0.61.0 → plain-0.62.0}/plain/internal/handlers/__init__.py +0 -0
  88. {plain-0.61.0 → plain-0.62.0}/plain/internal/handlers/wsgi.py +0 -0
  89. {plain-0.61.0 → plain-0.62.0}/plain/internal/middleware/__init__.py +0 -0
  90. {plain-0.61.0 → plain-0.62.0}/plain/internal/middleware/headers.py +0 -0
  91. {plain-0.61.0 → plain-0.62.0}/plain/internal/middleware/https.py +0 -0
  92. {plain-0.61.0 → plain-0.62.0}/plain/internal/middleware/slash.py +0 -0
  93. {plain-0.61.0 → plain-0.62.0}/plain/json.py +0 -0
  94. {plain-0.61.0 → plain-0.62.0}/plain/logs/utils.py +0 -0
  95. {plain-0.61.0 → plain-0.62.0}/plain/packages/README.md +0 -0
  96. {plain-0.61.0 → plain-0.62.0}/plain/packages/__init__.py +0 -0
  97. {plain-0.61.0 → plain-0.62.0}/plain/packages/config.py +0 -0
  98. {plain-0.61.0 → plain-0.62.0}/plain/packages/registry.py +0 -0
  99. {plain-0.61.0 → plain-0.62.0}/plain/paginator.py +0 -0
  100. {plain-0.61.0 → plain-0.62.0}/plain/preflight/README.md +0 -0
  101. {plain-0.61.0 → plain-0.62.0}/plain/preflight/__init__.py +0 -0
  102. {plain-0.61.0 → plain-0.62.0}/plain/preflight/files.py +0 -0
  103. {plain-0.61.0 → plain-0.62.0}/plain/preflight/messages.py +0 -0
  104. {plain-0.61.0 → plain-0.62.0}/plain/preflight/registry.py +0 -0
  105. {plain-0.61.0 → plain-0.62.0}/plain/preflight/security.py +0 -0
  106. {plain-0.61.0 → plain-0.62.0}/plain/preflight/urls.py +0 -0
  107. {plain-0.61.0 → plain-0.62.0}/plain/runtime/README.md +0 -0
  108. {plain-0.61.0 → plain-0.62.0}/plain/runtime/user_settings.py +0 -0
  109. {plain-0.61.0 → plain-0.62.0}/plain/runtime/utils.py +0 -0
  110. {plain-0.61.0 → plain-0.62.0}/plain/signals/README.md +0 -0
  111. {plain-0.61.0 → plain-0.62.0}/plain/signals/__init__.py +0 -0
  112. {plain-0.61.0 → plain-0.62.0}/plain/signals/dispatch/__init__.py +0 -0
  113. {plain-0.61.0 → plain-0.62.0}/plain/signals/dispatch/dispatcher.py +0 -0
  114. {plain-0.61.0 → plain-0.62.0}/plain/signals/dispatch/license.txt +0 -0
  115. {plain-0.61.0 → plain-0.62.0}/plain/signing.py +0 -0
  116. {plain-0.61.0 → plain-0.62.0}/plain/templates/AGENTS.md +0 -0
  117. {plain-0.61.0 → plain-0.62.0}/plain/templates/README.md +0 -0
  118. {plain-0.61.0 → plain-0.62.0}/plain/templates/__init__.py +0 -0
  119. {plain-0.61.0 → plain-0.62.0}/plain/templates/core.py +0 -0
  120. {plain-0.61.0 → plain-0.62.0}/plain/templates/jinja/__init__.py +0 -0
  121. {plain-0.61.0 → plain-0.62.0}/plain/templates/jinja/environments.py +0 -0
  122. {plain-0.61.0 → plain-0.62.0}/plain/templates/jinja/extensions.py +0 -0
  123. {plain-0.61.0 → plain-0.62.0}/plain/templates/jinja/filters.py +0 -0
  124. {plain-0.61.0 → plain-0.62.0}/plain/templates/jinja/globals.py +0 -0
  125. {plain-0.61.0 → plain-0.62.0}/plain/test/README.md +0 -0
  126. {plain-0.61.0 → plain-0.62.0}/plain/test/__init__.py +0 -0
  127. {plain-0.61.0 → plain-0.62.0}/plain/test/client.py +0 -0
  128. {plain-0.61.0 → plain-0.62.0}/plain/test/encoding.py +0 -0
  129. {plain-0.61.0 → plain-0.62.0}/plain/test/exceptions.py +0 -0
  130. {plain-0.61.0 → plain-0.62.0}/plain/urls/README.md +0 -0
  131. {plain-0.61.0 → plain-0.62.0}/plain/urls/__init__.py +0 -0
  132. {plain-0.61.0 → plain-0.62.0}/plain/urls/converters.py +0 -0
  133. {plain-0.61.0 → plain-0.62.0}/plain/urls/exceptions.py +0 -0
  134. {plain-0.61.0 → plain-0.62.0}/plain/urls/patterns.py +0 -0
  135. {plain-0.61.0 → plain-0.62.0}/plain/urls/resolvers.py +0 -0
  136. {plain-0.61.0 → plain-0.62.0}/plain/urls/routers.py +0 -0
  137. {plain-0.61.0 → plain-0.62.0}/plain/urls/utils.py +0 -0
  138. {plain-0.61.0 → plain-0.62.0}/plain/utils/README.md +0 -0
  139. {plain-0.61.0 → plain-0.62.0}/plain/utils/__init__.py +0 -0
  140. {plain-0.61.0 → plain-0.62.0}/plain/utils/cache.py +0 -0
  141. {plain-0.61.0 → plain-0.62.0}/plain/utils/crypto.py +0 -0
  142. {plain-0.61.0 → plain-0.62.0}/plain/utils/datastructures.py +0 -0
  143. {plain-0.61.0 → plain-0.62.0}/plain/utils/dateparse.py +0 -0
  144. {plain-0.61.0 → plain-0.62.0}/plain/utils/deconstruct.py +0 -0
  145. {plain-0.61.0 → plain-0.62.0}/plain/utils/decorators.py +0 -0
  146. {plain-0.61.0 → plain-0.62.0}/plain/utils/duration.py +0 -0
  147. {plain-0.61.0 → plain-0.62.0}/plain/utils/encoding.py +0 -0
  148. {plain-0.61.0 → plain-0.62.0}/plain/utils/functional.py +0 -0
  149. {plain-0.61.0 → plain-0.62.0}/plain/utils/hashable.py +0 -0
  150. {plain-0.61.0 → plain-0.62.0}/plain/utils/html.py +0 -0
  151. {plain-0.61.0 → plain-0.62.0}/plain/utils/http.py +0 -0
  152. {plain-0.61.0 → plain-0.62.0}/plain/utils/inspect.py +0 -0
  153. {plain-0.61.0 → plain-0.62.0}/plain/utils/ipv6.py +0 -0
  154. {plain-0.61.0 → plain-0.62.0}/plain/utils/itercompat.py +0 -0
  155. {plain-0.61.0 → plain-0.62.0}/plain/utils/module_loading.py +0 -0
  156. {plain-0.61.0 → plain-0.62.0}/plain/utils/regex_helper.py +0 -0
  157. {plain-0.61.0 → plain-0.62.0}/plain/utils/safestring.py +0 -0
  158. {plain-0.61.0 → plain-0.62.0}/plain/utils/text.py +0 -0
  159. {plain-0.61.0 → plain-0.62.0}/plain/utils/timesince.py +0 -0
  160. {plain-0.61.0 → plain-0.62.0}/plain/utils/timezone.py +0 -0
  161. {plain-0.61.0 → plain-0.62.0}/plain/utils/tree.py +0 -0
  162. {plain-0.61.0 → plain-0.62.0}/plain/validators.py +0 -0
  163. {plain-0.61.0 → plain-0.62.0}/plain/views/README.md +0 -0
  164. {plain-0.61.0 → plain-0.62.0}/plain/views/__init__.py +0 -0
  165. {plain-0.61.0 → plain-0.62.0}/plain/views/base.py +0 -0
  166. {plain-0.61.0 → plain-0.62.0}/plain/views/errors.py +0 -0
  167. {plain-0.61.0 → plain-0.62.0}/plain/views/exceptions.py +0 -0
  168. {plain-0.61.0 → plain-0.62.0}/plain/views/forms.py +0 -0
  169. {plain-0.61.0 → plain-0.62.0}/plain/views/objects.py +0 -0
  170. {plain-0.61.0 → plain-0.62.0}/plain/views/redirect.py +0 -0
  171. {plain-0.61.0 → plain-0.62.0}/plain/views/templates.py +0 -0
  172. {plain-0.61.0 → plain-0.62.0}/plain/wsgi.py +0 -0
  173. {plain-0.61.0 → plain-0.62.0}/tests/.gitignore +0 -0
  174. {plain-0.61.0 → plain-0.62.0}/tests/app/.gitignore +0 -0
  175. {plain-0.61.0 → plain-0.62.0}/tests/app/settings.py +0 -0
  176. {plain-0.61.0 → plain-0.62.0}/tests/app/test/__init__.py +0 -0
  177. {plain-0.61.0 → plain-0.62.0}/tests/app/test/default_settings.py +0 -0
  178. {plain-0.61.0 → plain-0.62.0}/tests/app/urls.py +0 -0
  179. {plain-0.61.0 → plain-0.62.0}/tests/conftest.py +0 -0
  180. {plain-0.61.0 → plain-0.62.0}/tests/test_cli.py +0 -0
  181. {plain-0.61.0 → plain-0.62.0}/tests/test_csrf.py +0 -0
  182. {plain-0.61.0 → plain-0.62.0}/tests/test_runtime.py +0 -0
  183. {plain-0.61.0 → plain-0.62.0}/tests/test_wsgi.py +0 -0
@@ -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
@@ -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).
@@ -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
@@ -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
@@ -0,0 +1,139 @@
1
+ # Logs
2
+
3
+ **Logging configuration and utilities.**
4
+
5
+ - [Overview](#overview)
6
+ - [`app_logger`](#app_logger)
7
+ - [Output formats](#output-formats)
8
+ - [Context management](#context-management)
9
+ - [Debug mode](#debug-mode)
10
+ - [Advanced usage](#advanced-usage)
11
+ - [Logging settings](#logging-settings)
12
+
13
+ ## Overview
14
+
15
+ In Python, configuring logging can be surprisingly complex. For most use cases, Plain provides a [default configuration](./configure.py) that "just works".
16
+
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.
18
+
19
+ The `app_logger` supports multiple output formats and provides a friendly kwargs-based API for structured logging.
20
+
21
+ ## `app_logger`
22
+
23
+ The `app_logger` is an enhanced logger that supports kwargs-style logging and multiple output formats.
24
+
25
+ ```python
26
+ from plain.logs import app_logger
27
+
28
+
29
+ def example_function():
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
+ )
52
+ ```
53
+
54
+ ## Output formats
55
+
56
+ The `app_logger` supports three output formats controlled by the `APP_LOG_FORMAT` environment variable:
57
+
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:
100
+
101
+ ```python
102
+ # Set persistent context
103
+ app_logger.context["user_id"] = 123
104
+ app_logger.context["request_id"] = "abc456"
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
109
+
110
+ # Clear context
111
+ app_logger.context.clear()
112
+ ```
113
+
114
+ ### Temporary context
115
+
116
+ Use `include_context()` for temporary context that only applies within a block:
117
+
118
+ ```python
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")
139
+ ```
@@ -0,0 +1,3 @@
1
+ from .loggers import app_logger
2
+
3
+ __all__ = ["app_logger"]
@@ -0,0 +1,36 @@
1
+ import logging
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
@@ -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)
@@ -0,0 +1,183 @@
1
+ import logging
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")
@@ -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,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.61.0"
3
+ version = "0.62.0"
4
4
  description = "A web framework for building products with Python."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -10,7 +10,7 @@ dependencies = [
10
10
  "opentelemetry-api>=1.34.1",
11
11
  "opentelemetry-semantic-conventions>=0.55b1",
12
12
  ]
13
- requires-python = ">=3.11"
13
+ requires-python = ">=3.13"
14
14
 
15
15
  [project.scripts]
16
16
  plain = "plain.cli.core:cli"