plain 0.66.0__py3-none-any.whl → 0.101.2__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 (197) hide show
  1. plain/CHANGELOG.md +684 -0
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -53
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +112 -28
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -13
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +14 -27
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +56 -40
  145. plain/urls/resolvers.py +38 -28
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/cli/request.py ADDED
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import click
7
+
8
+ from plain.runtime import settings
9
+ from plain.test import Client
10
+
11
+
12
+ @click.command()
13
+ @click.argument("path")
14
+ @click.option(
15
+ "--method",
16
+ default="GET",
17
+ help="HTTP method (GET, POST, PUT, PATCH, DELETE, etc.)",
18
+ )
19
+ @click.option(
20
+ "--data",
21
+ help="Request data (JSON string for POST/PUT/PATCH)",
22
+ )
23
+ @click.option(
24
+ "--user",
25
+ "user_id",
26
+ help="User ID to authenticate as (skips normal authentication)",
27
+ )
28
+ @click.option(
29
+ "--follow/--no-follow",
30
+ default=True,
31
+ help="Follow redirects (default: True)",
32
+ )
33
+ @click.option(
34
+ "--content-type",
35
+ help="Content-Type header for request data",
36
+ )
37
+ @click.option(
38
+ "--header",
39
+ "headers",
40
+ multiple=True,
41
+ help="Additional headers (format: 'Name: Value')",
42
+ )
43
+ @click.option(
44
+ "--no-headers",
45
+ is_flag=True,
46
+ help="Hide response headers from output",
47
+ )
48
+ @click.option(
49
+ "--no-body",
50
+ is_flag=True,
51
+ help="Hide response body from output",
52
+ )
53
+ def request(
54
+ path: str,
55
+ method: str,
56
+ data: str | None,
57
+ user_id: str | None,
58
+ follow: bool,
59
+ content_type: str | None,
60
+ headers: tuple[str, ...],
61
+ no_headers: bool,
62
+ no_body: bool,
63
+ ) -> None:
64
+ """Make HTTP requests against the dev database"""
65
+
66
+ try:
67
+ # Only allow in DEBUG mode for security
68
+ if not settings.DEBUG:
69
+ click.secho("This command only works when DEBUG=True", fg="red", err=True)
70
+ return
71
+
72
+ # Create test client
73
+ client = Client()
74
+
75
+ # If user_id provided, force login
76
+ if user_id:
77
+ try:
78
+ # Get the User model using plain.auth utility
79
+ from plain.auth import get_user_model
80
+
81
+ User = get_user_model()
82
+
83
+ # Get the user
84
+ try:
85
+ user = User.query.get(id=user_id)
86
+ client.force_login(user)
87
+ except User.DoesNotExist:
88
+ click.secho(f"User {user_id} not found", fg="red", err=True)
89
+ return
90
+
91
+ except Exception as e:
92
+ click.secho(f"Authentication error: {e}", fg="red", err=True)
93
+ return
94
+
95
+ # Parse additional headers
96
+ header_dict = {}
97
+ for header in headers:
98
+ if ":" in header:
99
+ key, value = header.split(":", 1)
100
+ header_dict[key.strip()] = value.strip()
101
+
102
+ # Prepare request data
103
+ if data and content_type and "json" in content_type.lower():
104
+ try:
105
+ # Validate JSON
106
+ json.loads(data)
107
+ except json.JSONDecodeError as e:
108
+ click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
109
+ return
110
+
111
+ # Make the request
112
+ method = method.upper()
113
+ kwargs: dict[str, Any] = {
114
+ "follow": follow,
115
+ }
116
+ if header_dict:
117
+ kwargs["headers"] = header_dict
118
+
119
+ if method in ("POST", "PUT", "PATCH") and data:
120
+ kwargs["data"] = data
121
+ if content_type:
122
+ kwargs["content_type"] = content_type
123
+
124
+ # Call the appropriate client method
125
+ if method == "GET":
126
+ response = client.get(path, **kwargs)
127
+ elif method == "POST":
128
+ response = client.post(path, **kwargs)
129
+ elif method == "PUT":
130
+ response = client.put(path, **kwargs)
131
+ elif method == "PATCH":
132
+ response = client.patch(path, **kwargs)
133
+ elif method == "DELETE":
134
+ response = client.delete(path, **kwargs)
135
+ elif method == "HEAD":
136
+ response = client.head(path, **kwargs)
137
+ elif method == "OPTIONS":
138
+ response = client.options(path, **kwargs)
139
+ elif method == "TRACE":
140
+ response = client.trace(path, **kwargs)
141
+ else:
142
+ click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
143
+ return
144
+
145
+ # Display response information
146
+ click.secho("Response:", fg="yellow", bold=True)
147
+
148
+ # Status code
149
+ click.echo(f" Status: {response.status_code}")
150
+
151
+ # Request ID
152
+ click.echo(f" Request ID: {response.wsgi_request.unique_id}")
153
+
154
+ # User
155
+ if response.user:
156
+ click.echo(f" Authenticated user: {response.user}")
157
+
158
+ # URL pattern
159
+ if response.resolver_match:
160
+ match = response.resolver_match
161
+ namespaced_url_name = getattr(match, "namespaced_url_name", None)
162
+ url_name_attr = getattr(match, "url_name", None)
163
+ url_name = namespaced_url_name or url_name_attr
164
+ if url_name:
165
+ click.echo(f" URL pattern: {url_name}")
166
+
167
+ click.echo()
168
+
169
+ # Show headers
170
+ if response.headers and not no_headers:
171
+ click.secho("Response Headers:", fg="yellow", bold=True)
172
+ for key, value in response.headers.items():
173
+ click.echo(f" {key}: {value}")
174
+ click.echo()
175
+
176
+ # Show response content last
177
+ if response.content and not no_body:
178
+ content_type = response.headers.get("Content-Type", "")
179
+
180
+ if "json" in content_type.lower():
181
+ try:
182
+ # The test client adds a json() method to the response
183
+ json_method = getattr(response, "json", None)
184
+ if json_method and callable(json_method):
185
+ json_data: Any = json_method()
186
+ click.secho("Response Body (JSON):", fg="yellow", bold=True)
187
+ click.echo(json.dumps(json_data, indent=2))
188
+ else:
189
+ click.secho("Response Body:", fg="yellow", bold=True)
190
+ click.echo(response.content.decode("utf-8", errors="replace"))
191
+ except Exception:
192
+ click.secho("Response Body:", fg="yellow", bold=True)
193
+ click.echo(response.content.decode("utf-8", errors="replace"))
194
+ elif "html" in content_type.lower():
195
+ click.secho("Response Body (HTML):", fg="yellow", bold=True)
196
+ content = response.content.decode("utf-8", errors="replace")
197
+ click.echo(content)
198
+ else:
199
+ click.secho("Response Body:", fg="yellow", bold=True)
200
+ content = response.content.decode("utf-8", errors="replace")
201
+ click.echo(content)
202
+ elif not no_body:
203
+ click.secho("(No response body)", fg="yellow", dim=True)
204
+
205
+ except Exception as e:
206
+ click.secho(f"Request failed: {e}", fg="red", err=True)
plain/cli/runtime.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ CLI runtime utilities.
3
+
4
+ This module provides decorators and utilities for CLI commands.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from typing import TypeVar
9
+
10
+ F = TypeVar("F", bound=Callable)
11
+
12
+
13
+ def without_runtime_setup(f: F) -> F:
14
+ """
15
+ Decorator to mark commands that don't need plain.runtime.setup().
16
+
17
+ Use this for commands that don't access settings or app code,
18
+ particularly for commands that fork (like server) where setup()
19
+ should happen in the worker process, not the parent.
20
+
21
+ Example:
22
+ @without_runtime_setup
23
+ @click.command()
24
+ def server(**options):
25
+ ...
26
+ """
27
+ f.without_runtime_setup = True # dynamic attribute for decorator
28
+ return f
29
+
30
+
31
+ def common_command(f: F) -> F:
32
+ """
33
+ Decorator to mark commands as commonly used.
34
+
35
+ Common commands are shown in a separate "Common Commands" section
36
+ in the help output, making them easier to discover.
37
+
38
+ Example:
39
+ @common_command
40
+ @click.command()
41
+ def dev(**options):
42
+ ...
43
+ """
44
+ f.is_common_command = True # dynamic attribute for decorator
45
+ return f
plain/cli/scaffold.py CHANGED
@@ -7,13 +7,8 @@ import plain.runtime
7
7
 
8
8
  @click.command()
9
9
  @click.argument("package_name")
10
- def create(package_name):
11
- """
12
- Create a new local package.
13
-
14
- The PACKAGE_NAME is typically a plural noun, like "users" or "posts",
15
- where you might create a "User" or "Post" model inside of the package.
16
- """
10
+ def create(package_name: str) -> None:
11
+ """Create a new local package"""
17
12
  package_dir = plain.runtime.APP_PATH / package_name
18
13
  package_dir.mkdir(exist_ok=True)
19
14
 
plain/cli/server.py ADDED
@@ -0,0 +1,153 @@
1
+ import os
2
+
3
+ import click
4
+
5
+ from plain.cli.runtime import without_runtime_setup
6
+
7
+
8
+ def parse_workers(ctx: click.Context, param: click.Parameter, value: str) -> int:
9
+ """Parse workers value - accepts int or 'auto' for CPU count."""
10
+ if value == "auto":
11
+ return os.cpu_count() or 1
12
+ return int(value)
13
+
14
+
15
+ @without_runtime_setup
16
+ @click.command()
17
+ @click.option(
18
+ "--bind",
19
+ "-b",
20
+ multiple=True,
21
+ default=["127.0.0.1:8000"],
22
+ help="Address to bind to (HOST:PORT, can be used multiple times)",
23
+ )
24
+ @click.option(
25
+ "--threads",
26
+ type=int,
27
+ default=1,
28
+ help="Number of threads per worker",
29
+ show_default=True,
30
+ )
31
+ @click.option(
32
+ "--workers",
33
+ "-w",
34
+ type=str,
35
+ default="1",
36
+ envvar="WEB_CONCURRENCY",
37
+ callback=parse_workers,
38
+ help="Number of worker processes (or 'auto' for CPU count)",
39
+ show_default=True,
40
+ )
41
+ @click.option(
42
+ "--timeout",
43
+ "-t",
44
+ type=int,
45
+ default=30,
46
+ help="Worker timeout in seconds",
47
+ show_default=True,
48
+ )
49
+ @click.option(
50
+ "--certfile",
51
+ type=click.Path(exists=True),
52
+ help="SSL certificate file",
53
+ )
54
+ @click.option(
55
+ "--keyfile",
56
+ type=click.Path(exists=True),
57
+ help="SSL key file",
58
+ )
59
+ @click.option(
60
+ "--log-level",
61
+ default="info",
62
+ type=click.Choice(["debug", "info", "warning", "error", "critical"]),
63
+ help="Logging level",
64
+ show_default=True,
65
+ )
66
+ @click.option(
67
+ "--reload",
68
+ is_flag=True,
69
+ help="Restart workers when code changes (dev only)",
70
+ )
71
+ @click.option(
72
+ "--access-log",
73
+ default="-",
74
+ help="Access log file (use '-' for stdout)",
75
+ show_default=True,
76
+ )
77
+ @click.option(
78
+ "--error-log",
79
+ default="-",
80
+ help="Error log file (use '-' for stderr)",
81
+ show_default=True,
82
+ )
83
+ @click.option(
84
+ "--log-format",
85
+ default="%(asctime)s [%(process)d] [%(levelname)s] %(message)s",
86
+ help="Log format string (applies to both error and access logs)",
87
+ show_default=True,
88
+ )
89
+ @click.option(
90
+ "--access-log-format",
91
+ help="Access log format string (HTTP request details)",
92
+ default='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"',
93
+ show_default=True,
94
+ )
95
+ @click.option(
96
+ "--max-requests",
97
+ type=int,
98
+ default=0,
99
+ help="Max requests before worker restart (0=disabled)",
100
+ show_default=True,
101
+ )
102
+ @click.option(
103
+ "--pidfile",
104
+ type=click.Path(),
105
+ help="PID file path",
106
+ )
107
+ def server(
108
+ bind: tuple[str, ...],
109
+ threads: int,
110
+ workers: int,
111
+ timeout: int,
112
+ certfile: str | None,
113
+ keyfile: str | None,
114
+ log_level: str,
115
+ reload: bool,
116
+ access_log: str,
117
+ error_log: str,
118
+ log_format: str,
119
+ access_log_format: str,
120
+ max_requests: int,
121
+ pidfile: str | None,
122
+ ) -> None:
123
+ """Production-ready WSGI server"""
124
+ from plain.runtime import settings
125
+
126
+ # Show settings loaded from environment
127
+ if env_settings := settings.get_env_settings():
128
+ click.secho("Settings from env:", dim=True)
129
+ for name, defn in env_settings:
130
+ click.secho(
131
+ f" {defn.env_var_name} -> {name}={defn.display_value()}", dim=True
132
+ )
133
+
134
+ from plain.server import ServerApplication
135
+ from plain.server.config import Config
136
+
137
+ cfg = Config(
138
+ bind=list(bind),
139
+ threads=threads,
140
+ workers=workers,
141
+ timeout=timeout,
142
+ max_requests=max_requests,
143
+ reload=reload,
144
+ pidfile=pidfile,
145
+ certfile=certfile,
146
+ keyfile=keyfile,
147
+ loglevel=log_level,
148
+ accesslog=access_log,
149
+ errorlog=error_log,
150
+ log_format=log_format,
151
+ access_log_format=access_log_format,
152
+ )
153
+ ServerApplication(cfg=cfg).run()
plain/cli/settings.py CHANGED
@@ -3,58 +3,62 @@ import click
3
3
  import plain.runtime
4
4
 
5
5
 
6
- @click.command()
6
+ @click.group()
7
+ def settings() -> None:
8
+ """View and inspect settings"""
9
+ pass
10
+
11
+
12
+ @settings.command()
7
13
  @click.argument("setting_name")
8
- def setting(setting_name):
9
- """Print the value of a setting at runtime"""
14
+ def get(setting_name: str) -> None:
15
+ """Get the value of a specific setting"""
10
16
  try:
11
- setting = getattr(plain.runtime.settings, setting_name)
12
- click.echo(setting)
17
+ value = getattr(plain.runtime.settings, setting_name)
18
+ click.echo(value)
13
19
  except AttributeError:
14
20
  click.secho(f'Setting "{setting_name}" not found', fg="red")
15
21
 
16
22
 
17
- # @plain_cli.command()
18
- # @click.option("--filter", "-f", "name_filter", help="Filter settings by name")
19
- # @click.option("--overridden", is_flag=True, help="Only show overridden settings")
20
- # def settings(name_filter, overridden):
21
- # """Print Plain settings"""
22
- # table = Table(box=box.MINIMAL)
23
- # table.add_column("Setting")
24
- # table.add_column("Default value")
25
- # table.add_column("App value")
26
- # table.add_column("Type")
27
- # table.add_column("Module")
28
-
29
- # for setting in dir(settings):
30
- # if setting.isupper():
31
- # if name_filter and name_filter.upper() not in setting:
32
- # continue
33
-
34
- # is_overridden = settings.is_overridden(setting)
35
-
36
- # if overridden and not is_overridden:
37
- # continue
38
-
39
- # default_setting = settings._default_settings.get(setting)
40
- # if default_setting:
41
- # default_value = default_setting.value
42
- # annotation = default_setting.annotation
43
- # module = default_setting.module
44
- # else:
45
- # default_value = ""
46
- # annotation = ""
47
- # module = ""
48
-
49
- # table.add_row(
50
- # setting,
51
- # Pretty(default_value) if default_value else "",
52
- # Pretty(getattr(settings, setting))
53
- # if is_overridden
54
- # else Text("<Default>", style="italic dim"),
55
- # Pretty(annotation) if annotation else "",
56
- # str(module.__name__) if module else "",
57
- # )
58
-
59
- # console = Console()
60
- # console.print(table)
23
+ @settings.command(name="list")
24
+ def list_settings() -> None:
25
+ """List all settings with their sources"""
26
+ if not (items := plain.runtime.settings.get_settings()):
27
+ click.echo("No settings configured.")
28
+ return
29
+
30
+ # Calculate column widths
31
+ max_name = max(len(name) for name, _ in items)
32
+ max_source = max(len(defn.env_var_name or defn.source) for _, defn in items)
33
+
34
+ # Print header
35
+ header = (
36
+ click.style(f"{'Setting':<{max_name}}", bold=True)
37
+ + " "
38
+ + click.style(f"{'Source':<{max_source}}", bold=True)
39
+ + " "
40
+ + click.style("Value", bold=True)
41
+ )
42
+ click.echo(header)
43
+ click.secho("-" * (max_name + max_source + 10), dim=True)
44
+
45
+ # Print each setting
46
+ for name, defn in items:
47
+ source_info = defn.env_var_name or defn.source
48
+ value = defn.display_value()
49
+
50
+ # Style based on source
51
+ if defn.source == "env":
52
+ source_styled = click.style(f"{source_info:<{max_source}}", fg="green")
53
+ elif defn.source == "explicit":
54
+ source_styled = click.style(f"{source_info:<{max_source}}", fg="cyan")
55
+ else:
56
+ source_styled = click.style(f"{source_info:<{max_source}}", dim=True)
57
+
58
+ # Style secret values
59
+ if defn.is_secret:
60
+ value_styled = click.style(value, dim=True)
61
+ else:
62
+ value_styled = value
63
+
64
+ click.echo(f"{name:<{max_name}} {source_styled} {value_styled}")
plain/cli/shell.py CHANGED
@@ -1,10 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
2
4
  import subprocess
3
5
  import sys
4
6
 
5
7
  import click
6
8
 
9
+ from plain.cli.runtime import common_command
10
+
7
11
 
12
+ @common_command
8
13
  @click.command()
9
14
  @click.option(
10
15
  "-i",
@@ -17,11 +22,8 @@ import click
17
22
  "--command",
18
23
  help="Execute the given command and exit.",
19
24
  )
20
- def shell(interface, command):
21
- """
22
- Runs a Python interactive interpreter. Tries to use IPython or
23
- bpython, if one of them is available.
24
- """
25
+ def shell(interface: str | None, command: str | None) -> None:
26
+ """Interactive Python shell"""
25
27
 
26
28
  if command:
27
29
  # Execute the command and exit
@@ -32,13 +34,14 @@ def shell(interface, command):
32
34
  sys.exit(result.returncode)
33
35
  return
34
36
 
37
+ interface_list: list[str]
35
38
  if interface:
36
- interface = [interface]
39
+ interface_list = [interface]
37
40
  else:
38
41
 
39
- def get_default_interface():
42
+ def get_default_interface() -> list[str]:
40
43
  try:
41
- import IPython # noqa
44
+ import IPython # noqa: F401 # type: ignore[import-not-found]
42
45
 
43
46
  return ["python", "-m", "IPython"]
44
47
  except ImportError:
@@ -46,10 +49,10 @@ def shell(interface, command):
46
49
 
47
50
  return ["python"]
48
51
 
49
- interface = get_default_interface()
52
+ interface_list = get_default_interface()
50
53
 
51
54
  result = subprocess.run(
52
- interface,
55
+ interface_list,
53
56
  env={
54
57
  "PYTHONSTARTUP": os.path.join(os.path.dirname(__file__), "startup.py"),
55
58
  **os.environ,
@@ -61,8 +64,8 @@ def shell(interface, command):
61
64
 
62
65
  @click.command()
63
66
  @click.argument("script", nargs=1, type=click.Path(exists=True))
64
- def run(script):
65
- """Run a Python script in the context of your app"""
67
+ def run(script: str) -> None:
68
+ """Execute Python scripts with app context"""
66
69
  before_script = "import plain.runtime; plain.runtime.setup()"
67
70
  command = f"{before_script}; exec(open('{script}').read())"
68
71
  result = subprocess.run(["python", "-c", command])
plain/cli/startup.py CHANGED
@@ -3,19 +3,19 @@ import plain.runtime
3
3
  plain.runtime.setup()
4
4
 
5
5
 
6
- def print_bold(s):
6
+ def print_bold(s: str) -> None:
7
7
  print("\033[1m", end="")
8
8
  print(s)
9
9
  print("\033[0m", end="")
10
10
 
11
11
 
12
- def print_italic(s):
12
+ def print_italic(s: str) -> None:
13
13
  print("\x1b[3m", end="")
14
14
  print(s)
15
15
  print("\x1b[0m", end="")
16
16
 
17
17
 
18
- def print_dim(s):
18
+ def print_dim(s: str) -> None:
19
19
  print("\x1b[2m", end="")
20
20
  print(s)
21
21
  print("\x1b[0m", end="")
@@ -29,12 +29,13 @@ if shell_import := plain.runtime.settings.SHELL_IMPORT:
29
29
  print_bold(f"Importing {shell_import}")
30
30
  module = import_module(shell_import)
31
31
 
32
- with open(module.__file__) as f:
33
- contents = f.read()
34
- for line in contents.splitlines():
35
- print_dim(f"{line}")
32
+ if module.__file__:
33
+ with open(module.__file__) as f:
34
+ contents = f.read()
35
+ for line in contents.splitlines():
36
+ print_dim(f"{line}")
36
37
 
37
- print()
38
+ print()
38
39
 
39
40
  # Emulate `from module import *`
40
41
  names = getattr(