plain 0.60.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 +18 -0
- plain/CHANGELOG.md +26 -0
- plain/cli/agent/__init__.py +20 -0
- plain/cli/agent/docs.py +80 -0
- plain/cli/agent/llmdocs.py +145 -0
- plain/cli/agent/md.py +87 -0
- plain/cli/{agent.py → agent/prompt.py} +10 -15
- plain/cli/agent/request.py +181 -0
- plain/cli/core.py +2 -2
- plain/cli/docs.py +21 -201
- plain/cli/install.py +1 -1
- plain/cli/shell.py +15 -1
- plain/cli/upgrade.py +1 -1
- plain/csrf/middleware.py +1 -1
- plain/internal/handlers/base.py +1 -1
- plain/internal/handlers/exception.py +1 -1
- plain/logs/README.md +104 -26
- plain/logs/__init__.py +1 -3
- plain/logs/configure.py +35 -43
- plain/logs/debug.py +36 -0
- plain/logs/formatters.py +70 -0
- plain/logs/loggers.py +182 -73
- plain/runtime/__init__.py +8 -4
- plain/runtime/global_settings.py +6 -2
- plain/templates/AGENTS.md +3 -0
- plain/views/objects.py +4 -3
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/METADATA +2 -2
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/RECORD +31 -23
- plain/cli/help.py +0 -27
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/WHEEL +0 -0
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/entry_points.txt +0 -0
- {plain-0.60.0.dist-info → plain-0.62.0.dist-info}/licenses/LICENSE +0 -0
plain/cli/docs.py
CHANGED
@@ -1,218 +1,38 @@
|
|
1
|
-
import ast
|
2
1
|
import importlib.util
|
3
2
|
from pathlib import Path
|
4
3
|
|
5
4
|
import click
|
6
5
|
|
7
|
-
from plain.packages import packages_registry
|
8
|
-
|
9
6
|
from .output import iterate_markdown
|
10
7
|
|
11
8
|
|
12
9
|
@click.command()
|
13
|
-
@click.option("--llm", "llm", is_flag=True)
|
14
10
|
@click.option("--open")
|
15
11
|
@click.argument("module", default="")
|
16
|
-
def docs(module,
|
17
|
-
if not module
|
18
|
-
raise click.UsageError(
|
19
|
-
|
20
|
-
if llm:
|
21
|
-
paths = [Path(__file__).parent.parent]
|
22
|
-
|
23
|
-
for package_config in packages_registry.get_package_configs():
|
24
|
-
if package_config.name.startswith("app."):
|
25
|
-
# Ignore app packages for now
|
26
|
-
continue
|
27
|
-
|
28
|
-
paths.append(Path(package_config.path))
|
29
|
-
|
30
|
-
source_docs = LLMDocs(paths)
|
31
|
-
source_docs.load()
|
32
|
-
source_docs.print()
|
33
|
-
|
34
|
-
click.secho(
|
35
|
-
"That's everything! Copy this into your AI tool of choice.",
|
36
|
-
err=True,
|
37
|
-
fg="green",
|
12
|
+
def docs(module, open):
|
13
|
+
if not module:
|
14
|
+
raise click.UsageError(
|
15
|
+
"You must specify a module. For LLM-friendly docs, use `plain agent docs`."
|
38
16
|
)
|
39
17
|
|
40
|
-
|
41
|
-
|
42
|
-
if module:
|
43
|
-
# Convert hyphens to dots (e.g., plain-models -> plain.models)
|
44
|
-
module = module.replace("-", ".")
|
45
|
-
|
46
|
-
# Automatically prefix if we need to
|
47
|
-
if not module.startswith("plain"):
|
48
|
-
module = f"plain.{module}"
|
49
|
-
|
50
|
-
# Get the README.md file for the module
|
51
|
-
spec = importlib.util.find_spec(module)
|
52
|
-
if not spec:
|
53
|
-
raise click.UsageError(f"Module {module} not found")
|
54
|
-
|
55
|
-
module_path = Path(spec.origin).parent
|
56
|
-
readme_path = module_path / "README.md"
|
57
|
-
if not readme_path.exists():
|
58
|
-
raise click.UsageError(f"README.md not found for {module}")
|
59
|
-
|
60
|
-
if open:
|
61
|
-
click.launch(str(readme_path))
|
62
|
-
else:
|
63
|
-
click.echo_via_pager(iterate_markdown(readme_path.read_text()))
|
64
|
-
|
65
|
-
|
66
|
-
class LLMDocs:
|
67
|
-
preamble = (
|
68
|
-
"Below is all of the documentation and abbreviated source code for the Plain web framework. "
|
69
|
-
"Your job is to read and understand it, and then act as the Plain Framework Assistant and "
|
70
|
-
"help the developer accomplish whatever they want to do next."
|
71
|
-
"\n\n---\n\n"
|
72
|
-
)
|
73
|
-
|
74
|
-
def __init__(self, paths):
|
75
|
-
self.paths = paths
|
76
|
-
|
77
|
-
def load(self):
|
78
|
-
self.docs = set()
|
79
|
-
self.sources = set()
|
80
|
-
|
81
|
-
for path in self.paths:
|
82
|
-
if path.is_dir():
|
83
|
-
self.docs.update(path.glob("**/*.md"))
|
84
|
-
self.sources.update(path.glob("**/*.py"))
|
85
|
-
elif path.suffix == ".py":
|
86
|
-
self.sources.add(path)
|
87
|
-
elif path.suffix == ".md":
|
88
|
-
self.docs.add(path)
|
89
|
-
|
90
|
-
# Exclude "migrations" code from plain apps, except for plain/models/migrations
|
91
|
-
self.docs = {
|
92
|
-
doc
|
93
|
-
for doc in self.docs
|
94
|
-
if not (
|
95
|
-
"/migrations/" in str(doc)
|
96
|
-
and "/plain/models/migrations/" not in str(doc)
|
97
|
-
)
|
98
|
-
}
|
99
|
-
self.sources = {
|
100
|
-
source
|
101
|
-
for source in self.sources
|
102
|
-
if not (
|
103
|
-
"/migrations/" in str(source)
|
104
|
-
and "/plain/models/migrations/" not in str(source)
|
105
|
-
)
|
106
|
-
}
|
107
|
-
|
108
|
-
self.docs = sorted(self.docs)
|
109
|
-
self.sources = sorted(self.sources)
|
110
|
-
|
111
|
-
def display_path(self, path):
|
112
|
-
if "plain" in path.parts:
|
113
|
-
root_index = path.parts.index("plain")
|
114
|
-
elif "plainx" in path.parts:
|
115
|
-
root_index = path.parts.index("plainx")
|
116
|
-
else:
|
117
|
-
raise ValueError("Path does not contain 'plain' or 'plainx'")
|
118
|
-
|
119
|
-
plain_root = Path(*path.parts[: root_index + 1])
|
120
|
-
return path.relative_to(plain_root.parent)
|
121
|
-
|
122
|
-
def print(self, relative_to=None):
|
123
|
-
click.secho(self.preamble, fg="yellow")
|
124
|
-
|
125
|
-
for doc in self.docs:
|
126
|
-
if relative_to:
|
127
|
-
display_path = doc.relative_to(relative_to)
|
128
|
-
else:
|
129
|
-
display_path = self.display_path(doc)
|
130
|
-
click.secho(f"<Docs: {display_path}>", fg="yellow")
|
131
|
-
click.echo(doc.read_text())
|
132
|
-
click.secho(f"</Docs: {display_path}>", fg="yellow")
|
133
|
-
click.echo()
|
134
|
-
|
135
|
-
for source in self.sources:
|
136
|
-
if symbolicated := self.symbolicate(source):
|
137
|
-
if relative_to:
|
138
|
-
display_path = source.relative_to(relative_to)
|
139
|
-
else:
|
140
|
-
display_path = self.display_path(source)
|
141
|
-
click.secho(f"<Source: {display_path}>", fg="yellow")
|
142
|
-
click.echo(symbolicated)
|
143
|
-
click.secho(f"</Source: {display_path}>", fg="yellow")
|
144
|
-
click.echo()
|
145
|
-
|
146
|
-
@staticmethod
|
147
|
-
def symbolicate(file_path: Path):
|
148
|
-
if "internal" in str(file_path).split("/"):
|
149
|
-
return ""
|
150
|
-
|
151
|
-
source = file_path.read_text()
|
152
|
-
|
153
|
-
parsed = ast.parse(source)
|
154
|
-
|
155
|
-
def should_skip(node):
|
156
|
-
if isinstance(node, ast.ClassDef | ast.FunctionDef):
|
157
|
-
if any(
|
158
|
-
isinstance(d, ast.Name) and d.id == "internalcode"
|
159
|
-
for d in node.decorator_list
|
160
|
-
):
|
161
|
-
return True
|
162
|
-
if node.name.startswith("_"): # and not node.name.endswith("__"):
|
163
|
-
return True
|
164
|
-
elif isinstance(node, ast.Assign):
|
165
|
-
for target in node.targets:
|
166
|
-
if (
|
167
|
-
isinstance(target, ast.Name) and target.id.startswith("_")
|
168
|
-
# and not target.id.endswith("__")
|
169
|
-
):
|
170
|
-
return True
|
171
|
-
return False
|
172
|
-
|
173
|
-
def process_node(node, indent=0):
|
174
|
-
lines = []
|
175
|
-
prefix = " " * indent
|
176
|
-
|
177
|
-
if should_skip(node):
|
178
|
-
return []
|
179
|
-
|
180
|
-
if isinstance(node, ast.ClassDef):
|
181
|
-
decorators = [
|
182
|
-
f"{prefix}@{ast.unparse(d)}"
|
183
|
-
for d in node.decorator_list
|
184
|
-
if not (isinstance(d, ast.Name) and d.id == "internalcode")
|
185
|
-
]
|
186
|
-
lines.extend(decorators)
|
187
|
-
bases = [ast.unparse(base) for base in node.bases]
|
188
|
-
lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
|
189
|
-
# if ast.get_docstring(node):
|
190
|
-
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
191
|
-
for child in node.body:
|
192
|
-
child_lines = process_node(child, indent + 1)
|
193
|
-
if child_lines:
|
194
|
-
lines.extend(child_lines)
|
195
|
-
# if not has_body:
|
196
|
-
# lines.append(f"{prefix} pass")
|
197
|
-
|
198
|
-
elif isinstance(node, ast.FunctionDef):
|
199
|
-
decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
|
200
|
-
lines.extend(decorators)
|
201
|
-
args = ast.unparse(node.args)
|
202
|
-
lines.append(f"{prefix}def {node.name}({args})")
|
203
|
-
# if ast.get_docstring(node):
|
204
|
-
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
205
|
-
# lines.append(f"{prefix} pass")
|
18
|
+
# Convert hyphens to dots (e.g., plain-models -> plain.models)
|
19
|
+
module = module.replace("-", ".")
|
206
20
|
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
lines.append(f"{prefix}{target.id} = {ast.unparse(node.value)}")
|
21
|
+
# Automatically prefix if we need to
|
22
|
+
if not module.startswith("plain"):
|
23
|
+
module = f"plain.{module}"
|
211
24
|
|
212
|
-
|
25
|
+
# Get the README.md file for the module
|
26
|
+
spec = importlib.util.find_spec(module)
|
27
|
+
if not spec:
|
28
|
+
raise click.UsageError(f"Module {module} not found")
|
213
29
|
|
214
|
-
|
215
|
-
|
216
|
-
|
30
|
+
module_path = Path(spec.origin).parent
|
31
|
+
readme_path = module_path / "README.md"
|
32
|
+
if not readme_path.exists():
|
33
|
+
raise click.UsageError(f"README.md not found for {module}")
|
217
34
|
|
218
|
-
|
35
|
+
if open:
|
36
|
+
click.launch(str(readme_path))
|
37
|
+
else:
|
38
|
+
click.echo_via_pager(iterate_markdown(readme_path.read_text()))
|
plain/cli/install.py
CHANGED
plain/cli/shell.py
CHANGED
@@ -12,12 +12,26 @@ import click
|
|
12
12
|
type=click.Choice(["ipython", "bpython", "python"]),
|
13
13
|
help="Specify an interactive interpreter interface.",
|
14
14
|
)
|
15
|
-
|
15
|
+
@click.option(
|
16
|
+
"-c",
|
17
|
+
"--command",
|
18
|
+
help="Execute the given command and exit.",
|
19
|
+
)
|
20
|
+
def shell(interface, command):
|
16
21
|
"""
|
17
22
|
Runs a Python interactive interpreter. Tries to use IPython or
|
18
23
|
bpython, if one of them is available.
|
19
24
|
"""
|
20
25
|
|
26
|
+
if command:
|
27
|
+
# Execute the command and exit
|
28
|
+
before_script = "import plain.runtime; plain.runtime.setup()"
|
29
|
+
full_command = f"{before_script}; {command}"
|
30
|
+
result = subprocess.run(["python", "-c", full_command])
|
31
|
+
if result.returncode:
|
32
|
+
sys.exit(result.returncode)
|
33
|
+
return
|
34
|
+
|
21
35
|
if interface:
|
22
36
|
interface = [interface]
|
23
37
|
else:
|
plain/cli/upgrade.py
CHANGED
plain/csrf/middleware.py
CHANGED
plain/internal/handlers/base.py
CHANGED
@@ -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
|
-
- [
|
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
|
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
|
-
|
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
|
-
##
|
54
|
+
## Output formats
|
55
|
+
|
56
|
+
The `app_logger` supports three output formats controlled by the `APP_LOG_FORMAT` environment variable:
|
29
57
|
|
30
|
-
|
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
|
-
|
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
|
-
|
37
|
-
|
110
|
+
# Clear context
|
111
|
+
app_logger.context.clear()
|
38
112
|
```
|
39
113
|
|
40
|
-
|
114
|
+
### Temporary context
|
41
115
|
|
42
|
-
|
116
|
+
Use `include_context()` for temporary context that only applies within a block:
|
43
117
|
|
44
118
|
```python
|
45
|
-
#
|
46
|
-
|
47
|
-
|
48
|
-
"
|
49
|
-
"
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
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
plain/logs/configure.py
CHANGED
@@ -1,44 +1,36 @@
|
|
1
1
|
import logging
|
2
|
-
|
3
|
-
from
|
4
|
-
|
5
|
-
|
6
|
-
def configure_logging(
|
7
|
-
#
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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)
|
plain/logs/formatters.py
ADDED
@@ -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)
|