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/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, llm, open):
17
- if not module and not llm:
18
- raise click.UsageError("You must specify a module or use --llm")
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
- return
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
- elif isinstance(node, ast.Assign):
208
- for target in node.targets:
209
- if isinstance(target, ast.Name):
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
- return lines
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
- symbolicated_lines = []
215
- for node in parsed.body:
216
- symbolicated_lines.extend(process_node(node))
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
- return "\n".join(symbolicated_lines)
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
@@ -3,7 +3,7 @@ import sys
3
3
 
4
4
  import click
5
5
 
6
- from .agent import prompt_agent
6
+ from .agent.prompt import prompt_agent
7
7
 
8
8
 
9
9
  @click.command()
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
- def shell(interface):
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
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  import click
7
7
 
8
- from .agent import prompt_agent
8
+ from .agent.prompt import prompt_agent
9
9
 
10
10
  LOCK_FILE = Path("uv.lock")
11
11
 
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)