plain 0.68.0__py3-none-any.whl → 0.103.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. plain/CHANGELOG.md +684 -1
  2. plain/README.md +1 -1
  3. plain/agents/.claude/rules/plain.md +88 -0
  4. plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
  5. plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
  6. plain/assets/compile.py +25 -12
  7. plain/assets/finders.py +24 -17
  8. plain/assets/fingerprints.py +10 -7
  9. plain/assets/urls.py +1 -1
  10. plain/assets/views.py +47 -33
  11. plain/chores/README.md +25 -23
  12. plain/chores/__init__.py +2 -1
  13. plain/chores/core.py +27 -0
  14. plain/chores/registry.py +23 -36
  15. plain/cli/README.md +185 -16
  16. plain/cli/__init__.py +2 -1
  17. plain/cli/agent.py +234 -0
  18. plain/cli/build.py +7 -8
  19. plain/cli/changelog.py +11 -5
  20. plain/cli/chores.py +32 -34
  21. plain/cli/core.py +110 -26
  22. plain/cli/docs.py +98 -21
  23. plain/cli/formatting.py +40 -17
  24. plain/cli/install.py +10 -54
  25. plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
  26. plain/cli/output.py +6 -2
  27. plain/cli/preflight.py +27 -75
  28. plain/cli/print.py +4 -4
  29. plain/cli/registry.py +96 -10
  30. plain/cli/{agent/request.py → request.py} +67 -33
  31. plain/cli/runtime.py +45 -0
  32. plain/cli/scaffold.py +2 -7
  33. plain/cli/server.py +153 -0
  34. plain/cli/settings.py +53 -49
  35. plain/cli/shell.py +15 -12
  36. plain/cli/startup.py +9 -8
  37. plain/cli/upgrade.py +17 -104
  38. plain/cli/urls.py +12 -7
  39. plain/cli/utils.py +3 -3
  40. plain/csrf/README.md +65 -40
  41. plain/csrf/middleware.py +53 -43
  42. plain/debug.py +5 -2
  43. plain/exceptions.py +22 -114
  44. plain/forms/README.md +453 -24
  45. plain/forms/__init__.py +55 -4
  46. plain/forms/boundfield.py +15 -8
  47. plain/forms/exceptions.py +1 -1
  48. plain/forms/fields.py +346 -143
  49. plain/forms/forms.py +75 -45
  50. plain/http/README.md +356 -9
  51. plain/http/__init__.py +41 -26
  52. plain/http/cookie.py +15 -7
  53. plain/http/exceptions.py +65 -0
  54. plain/http/middleware.py +32 -0
  55. plain/http/multipartparser.py +99 -88
  56. plain/http/request.py +362 -250
  57. plain/http/response.py +99 -197
  58. plain/internal/__init__.py +8 -1
  59. plain/internal/files/base.py +35 -19
  60. plain/internal/files/locks.py +19 -11
  61. plain/internal/files/move.py +8 -3
  62. plain/internal/files/temp.py +25 -6
  63. plain/internal/files/uploadedfile.py +47 -28
  64. plain/internal/files/uploadhandler.py +64 -58
  65. plain/internal/files/utils.py +24 -10
  66. plain/internal/handlers/base.py +34 -23
  67. plain/internal/handlers/exception.py +68 -65
  68. plain/internal/handlers/wsgi.py +65 -54
  69. plain/internal/middleware/headers.py +37 -11
  70. plain/internal/middleware/hosts.py +11 -8
  71. plain/internal/middleware/https.py +17 -7
  72. plain/internal/middleware/slash.py +14 -9
  73. plain/internal/reloader.py +77 -0
  74. plain/json.py +2 -1
  75. plain/logs/README.md +161 -62
  76. plain/logs/__init__.py +1 -1
  77. plain/logs/{loggers.py → app.py} +71 -67
  78. plain/logs/configure.py +63 -14
  79. plain/logs/debug.py +17 -6
  80. plain/logs/filters.py +15 -0
  81. plain/logs/formatters.py +7 -4
  82. plain/packages/README.md +105 -23
  83. plain/packages/config.py +15 -7
  84. plain/packages/registry.py +27 -16
  85. plain/paginator.py +31 -21
  86. plain/preflight/README.md +209 -24
  87. plain/preflight/__init__.py +1 -0
  88. plain/preflight/checks.py +3 -1
  89. plain/preflight/files.py +3 -1
  90. plain/preflight/registry.py +26 -11
  91. plain/preflight/results.py +15 -7
  92. plain/preflight/security.py +15 -13
  93. plain/preflight/settings.py +54 -0
  94. plain/preflight/urls.py +4 -1
  95. plain/runtime/README.md +115 -47
  96. plain/runtime/__init__.py +10 -6
  97. plain/runtime/global_settings.py +34 -25
  98. plain/runtime/secret.py +20 -0
  99. plain/runtime/user_settings.py +110 -38
  100. plain/runtime/utils.py +1 -1
  101. plain/server/LICENSE +35 -0
  102. plain/server/README.md +155 -0
  103. plain/server/__init__.py +9 -0
  104. plain/server/app.py +52 -0
  105. plain/server/arbiter.py +555 -0
  106. plain/server/config.py +118 -0
  107. plain/server/errors.py +31 -0
  108. plain/server/glogging.py +292 -0
  109. plain/server/http/__init__.py +12 -0
  110. plain/server/http/body.py +283 -0
  111. plain/server/http/errors.py +155 -0
  112. plain/server/http/message.py +400 -0
  113. plain/server/http/parser.py +70 -0
  114. plain/server/http/unreader.py +88 -0
  115. plain/server/http/wsgi.py +421 -0
  116. plain/server/pidfile.py +92 -0
  117. plain/server/sock.py +240 -0
  118. plain/server/util.py +317 -0
  119. plain/server/workers/__init__.py +6 -0
  120. plain/server/workers/base.py +304 -0
  121. plain/server/workers/sync.py +212 -0
  122. plain/server/workers/thread.py +399 -0
  123. plain/server/workers/workertmp.py +50 -0
  124. plain/signals/README.md +170 -1
  125. plain/signals/__init__.py +0 -1
  126. plain/signals/dispatch/dispatcher.py +49 -27
  127. plain/signing.py +131 -35
  128. plain/templates/README.md +211 -20
  129. plain/templates/jinja/__init__.py +13 -5
  130. plain/templates/jinja/environments.py +5 -4
  131. plain/templates/jinja/extensions.py +12 -5
  132. plain/templates/jinja/filters.py +7 -2
  133. plain/templates/jinja/globals.py +2 -2
  134. plain/test/README.md +184 -22
  135. plain/test/client.py +340 -222
  136. plain/test/encoding.py +9 -6
  137. plain/test/exceptions.py +7 -2
  138. plain/urls/README.md +157 -73
  139. plain/urls/converters.py +18 -15
  140. plain/urls/exceptions.py +2 -2
  141. plain/urls/patterns.py +38 -22
  142. plain/urls/resolvers.py +35 -25
  143. plain/urls/utils.py +5 -1
  144. plain/utils/README.md +250 -3
  145. plain/utils/cache.py +17 -11
  146. plain/utils/crypto.py +21 -5
  147. plain/utils/datastructures.py +89 -56
  148. plain/utils/dateparse.py +9 -6
  149. plain/utils/deconstruct.py +15 -7
  150. plain/utils/decorators.py +5 -1
  151. plain/utils/dotenv.py +373 -0
  152. plain/utils/duration.py +8 -4
  153. plain/utils/encoding.py +14 -7
  154. plain/utils/functional.py +66 -49
  155. plain/utils/hashable.py +5 -1
  156. plain/utils/html.py +36 -22
  157. plain/utils/http.py +16 -9
  158. plain/utils/inspect.py +14 -6
  159. plain/utils/ipv6.py +7 -3
  160. plain/utils/itercompat.py +6 -1
  161. plain/utils/module_loading.py +7 -3
  162. plain/utils/regex_helper.py +37 -23
  163. plain/utils/safestring.py +14 -6
  164. plain/utils/text.py +41 -23
  165. plain/utils/timezone.py +33 -22
  166. plain/utils/tree.py +35 -19
  167. plain/validators.py +94 -52
  168. plain/views/README.md +156 -79
  169. plain/views/__init__.py +0 -1
  170. plain/views/base.py +25 -18
  171. plain/views/errors.py +13 -5
  172. plain/views/exceptions.py +4 -1
  173. plain/views/forms.py +6 -6
  174. plain/views/objects.py +52 -49
  175. plain/views/redirect.py +18 -15
  176. plain/views/templates.py +5 -3
  177. plain/wsgi.py +3 -1
  178. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
  179. plain-0.103.0.dist-info/RECORD +198 -0
  180. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
  181. plain-0.103.0.dist-info/entry_points.txt +2 -0
  182. plain/AGENTS.md +0 -18
  183. plain/cli/agent/__init__.py +0 -20
  184. plain/cli/agent/docs.py +0 -80
  185. plain/cli/agent/md.py +0 -87
  186. plain/cli/agent/prompt.py +0 -45
  187. plain/csrf/views.py +0 -31
  188. plain/logs/utils.py +0 -46
  189. plain/templates/AGENTS.md +0 -3
  190. plain-0.68.0.dist-info/RECORD +0 -169
  191. plain-0.68.0.dist-info/entry_points.txt +0 -5
  192. {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
4
+ from typing import Any
2
5
 
3
6
  import click
4
7
 
@@ -37,8 +40,28 @@ from plain.test import Client
37
40
  multiple=True,
38
41
  help="Additional headers (format: 'Name: Value')",
39
42
  )
40
- def request(path, method, data, user_id, follow, content_type, headers):
41
- """Make an HTTP request using the test client against the development database."""
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"""
42
65
 
43
66
  try:
44
67
  # Only allow in DEBUG mode for security
@@ -61,9 +84,6 @@ def request(path, method, data, user_id, follow, content_type, headers):
61
84
  try:
62
85
  user = User.query.get(id=user_id)
63
86
  client.force_login(user)
64
- click.secho(
65
- f"Authenticated as user {user_id}", fg="green", dim=True
66
- )
67
87
  except User.DoesNotExist:
68
88
  click.secho(f"User {user_id} not found", fg="red", err=True)
69
89
  return
@@ -90,11 +110,11 @@ def request(path, method, data, user_id, follow, content_type, headers):
90
110
 
91
111
  # Make the request
92
112
  method = method.upper()
93
- kwargs = {
94
- "path": path,
113
+ kwargs: dict[str, Any] = {
95
114
  "follow": follow,
96
- "headers": header_dict or None,
97
115
  }
116
+ if header_dict:
117
+ kwargs["headers"] = header_dict
98
118
 
99
119
  if method in ("POST", "PUT", "PATCH") and data:
100
120
  kwargs["data"] = data
@@ -103,57 +123,71 @@ def request(path, method, data, user_id, follow, content_type, headers):
103
123
 
104
124
  # Call the appropriate client method
105
125
  if method == "GET":
106
- response = client.get(**kwargs)
126
+ response = client.get(path, **kwargs)
107
127
  elif method == "POST":
108
- response = client.post(**kwargs)
128
+ response = client.post(path, **kwargs)
109
129
  elif method == "PUT":
110
- response = client.put(**kwargs)
130
+ response = client.put(path, **kwargs)
111
131
  elif method == "PATCH":
112
- response = client.patch(**kwargs)
132
+ response = client.patch(path, **kwargs)
113
133
  elif method == "DELETE":
114
- response = client.delete(**kwargs)
134
+ response = client.delete(path, **kwargs)
115
135
  elif method == "HEAD":
116
- response = client.head(**kwargs)
136
+ response = client.head(path, **kwargs)
117
137
  elif method == "OPTIONS":
118
- response = client.options(**kwargs)
138
+ response = client.options(path, **kwargs)
119
139
  elif method == "TRACE":
120
- response = client.trace(**kwargs)
140
+ response = client.trace(path, **kwargs)
121
141
  else:
122
142
  click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
123
143
  return
124
144
 
125
145
  # Display response information
126
- click.secho(
127
- f"HTTP {response.status_code}",
128
- fg="green" if response.status_code < 400 else "red",
129
- bold=True,
130
- )
146
+ click.secho("Response:", fg="yellow", bold=True)
131
147
 
132
- # Show additional response info first
133
- if hasattr(response, "user"):
134
- click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
148
+ # Status code
149
+ click.echo(f" Status: {response.status_code}")
135
150
 
136
- if hasattr(response, "resolver_match") and response.resolver_match:
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:
137
160
  match = response.resolver_match
138
- url_name = match.namespaced_url_name or match.url_name or "unnamed"
139
- click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
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()
140
168
 
141
169
  # Show headers
142
- if response.headers:
170
+ if response.headers and not no_headers:
143
171
  click.secho("Response Headers:", fg="yellow", bold=True)
144
172
  for key, value in response.headers.items():
145
173
  click.echo(f" {key}: {value}")
146
174
  click.echo()
147
175
 
148
176
  # Show response content last
149
- if response.content:
177
+ if response.content and not no_body:
150
178
  content_type = response.headers.get("Content-Type", "")
151
179
 
152
180
  if "json" in content_type.lower():
153
181
  try:
154
- json_data = response.json()
155
- click.secho("Response Body (JSON):", fg="yellow", bold=True)
156
- click.echo(json.dumps(json_data, indent=2))
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"))
157
191
  except Exception:
158
192
  click.secho("Response Body:", fg="yellow", bold=True)
159
193
  click.echo(response.content.decode("utf-8", errors="replace"))
@@ -165,7 +199,7 @@ def request(path, method, data, user_id, follow, content_type, headers):
165
199
  click.secho("Response Body:", fg="yellow", bold=True)
166
200
  content = response.content.decode("utf-8", errors="replace")
167
201
  click.echo(content)
168
- else:
202
+ elif not no_body:
169
203
  click.secho("(No response body)", fg="yellow", dim=True)
170
204
 
171
205
  except Exception as e:
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(