plain 0.68.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 (195) hide show
  1. plain/CHANGELOG.md +656 -1
  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 -36
  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 +110 -26
  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 +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  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 -8
  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 +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  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 +13 -5
  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 +38 -22
  145. plain/urls/resolvers.py +35 -25
  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.68.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.68.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/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import ast
2
4
  from pathlib import Path
3
5
 
@@ -7,10 +9,10 @@ import click
7
9
  class LLMDocs:
8
10
  """Generates LLM-friendly documentation."""
9
11
 
10
- def __init__(self, paths):
12
+ def __init__(self, paths: list[Path]):
11
13
  self.paths = paths
12
14
 
13
- def load(self):
15
+ def load(self) -> None:
14
16
  self.docs = set()
15
17
  self.sources = set()
16
18
 
@@ -24,7 +26,7 @@ class LLMDocs:
24
26
  self.docs.add(path)
25
27
 
26
28
  # Exclude "migrations" code from plain apps, except for plain/models/migrations
27
- # Also exclude CHANGELOG.md and AGENTS.md
29
+ # Also exclude CHANGELOG.md, AGENTS.md, and skills directory
28
30
  self.docs = {
29
31
  doc
30
32
  for doc in self.docs
@@ -33,6 +35,7 @@ class LLMDocs:
33
35
  and "/plain/models/migrations/" not in str(doc)
34
36
  )
35
37
  and doc.name not in ("CHANGELOG.md", "AGENTS.md")
38
+ and "/skills/" not in str(doc)
36
39
  }
37
40
  self.sources = {
38
41
  source
@@ -42,12 +45,13 @@ class LLMDocs:
42
45
  and "/plain/models/migrations/" not in str(source)
43
46
  )
44
47
  and source.name != "cli.py"
48
+ and "/skills/" not in str(source)
45
49
  }
46
50
 
47
51
  self.docs = sorted(self.docs)
48
52
  self.sources = sorted(self.sources)
49
53
 
50
- def display_path(self, path):
54
+ def display_path(self, path: Path) -> Path:
51
55
  if "plain" in path.parts:
52
56
  root_index = path.parts.index("plain")
53
57
  elif "plainx" in path.parts:
@@ -58,7 +62,7 @@ class LLMDocs:
58
62
  plain_root = Path(*path.parts[: root_index + 1])
59
63
  return path.relative_to(plain_root.parent)
60
64
 
61
- def print(self, relative_to=None):
65
+ def print(self, relative_to: Path | None = None) -> None:
62
66
  for doc in self.docs:
63
67
  if relative_to:
64
68
  display_path = doc.relative_to(relative_to)
@@ -81,7 +85,7 @@ class LLMDocs:
81
85
  click.echo()
82
86
 
83
87
  @staticmethod
84
- def symbolicate(file_path: Path):
88
+ def symbolicate(file_path: Path) -> str:
85
89
  if "internal" in str(file_path).split("/"):
86
90
  return ""
87
91
 
@@ -89,8 +93,16 @@ class LLMDocs:
89
93
 
90
94
  parsed = ast.parse(source)
91
95
 
92
- def should_skip(node):
93
- if isinstance(node, ast.ClassDef | ast.FunctionDef):
96
+ def should_skip(node: ast.AST) -> bool:
97
+ if isinstance(node, ast.ClassDef):
98
+ if any(
99
+ isinstance(d, ast.Name) and d.id == "internalcode"
100
+ for d in node.decorator_list
101
+ ):
102
+ return True
103
+ if node.name.startswith("_"):
104
+ return True
105
+ elif isinstance(node, ast.FunctionDef):
94
106
  if any(
95
107
  isinstance(d, ast.Name) and d.id == "internalcode"
96
108
  for d in node.decorator_list
@@ -104,7 +116,7 @@ class LLMDocs:
104
116
  return True
105
117
  return False
106
118
 
107
- def process_node(node, indent=0):
119
+ def process_node(node: ast.AST, indent: int = 0) -> list[str]:
108
120
  lines = []
109
121
  prefix = " " * indent
110
122
 
plain/cli/output.py CHANGED
@@ -1,11 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+
1
5
  import click
2
6
 
3
7
 
4
- def style_markdown(content):
8
+ def style_markdown(content: str) -> str:
5
9
  return "".join(iterate_markdown(content))
6
10
 
7
11
 
8
- def iterate_markdown(content):
12
+ def iterate_markdown(content: str) -> Iterator[str]:
9
13
  """
10
14
  Iterator that does basic markdown for a Click pager.
11
15
 
plain/cli/preflight.py CHANGED
@@ -1,25 +1,20 @@
1
1
  import json
2
2
  import sys
3
+ from typing import Any
3
4
 
4
5
  import click
5
6
 
6
7
  from plain import preflight
8
+ from plain.cli.runtime import common_command
7
9
  from plain.packages import packages_registry
8
- from plain.preflight.registry import checks_registry
9
- from plain.runtime import settings
10
10
 
11
11
 
12
- @click.group("preflight")
13
- def preflight_cli():
14
- """Run or manage preflight checks."""
15
- pass
16
-
17
-
18
- @preflight_cli.command("check")
12
+ @common_command
13
+ @click.command("preflight")
19
14
  @click.option(
20
15
  "--deploy",
21
16
  is_flag=True,
22
- help="Check deployment settings.",
17
+ help="Include deployment checks.",
23
18
  )
24
19
  @click.option(
25
20
  "--format",
@@ -32,16 +27,18 @@ def preflight_cli():
32
27
  is_flag=True,
33
28
  help="Hide progress output and warnings, only show errors.",
34
29
  )
35
- def check_command(deploy, format, quiet):
36
- """
37
- Use the system check framework to validate entire Plain project.
38
- Exit with error code if any errors are found. Warnings do not cause failure.
39
- """
30
+ def preflight_cli(deploy: bool, format: str, quiet: bool) -> None:
31
+ """Validation checks before deployment"""
32
+ # Use stderr for progress messages only in JSON mode (keeps stdout clean for parsing)
33
+ # In text mode, send all output to stdout (so success doesn't appear in error logs)
34
+ use_stderr = format == "json"
35
+
40
36
  # Auto-discover and load preflight checks
41
37
  packages_registry.autodiscover_modules("preflight", include_app=True)
42
-
43
38
  if not quiet:
44
- click.secho("Running preflight checks...", dim=True, italic=True, err=True)
39
+ click.secho(
40
+ "Running preflight checks...", dim=True, italic=True, err=use_stderr
41
+ )
45
42
 
46
43
  total_checks = 0
47
44
  passed_checks = 0
@@ -60,23 +57,23 @@ def check_command(deploy, format, quiet):
60
57
  if format == "text":
61
58
  if not quiet:
62
59
  # Print check name without newline
63
- click.echo("Check:", nl=False, err=True)
64
- click.secho(f"{check_name} ", bold=True, nl=False, err=True)
60
+ click.echo("Check:", nl=False, err=use_stderr)
61
+ click.secho(f"{check_name} ", bold=True, nl=False, err=use_stderr)
65
62
 
66
63
  # Determine status icon based on issue severity
67
64
  if not visible_issues:
68
65
  # No issues - passed
69
66
  if not quiet:
70
- click.secho("✔", fg="green", err=True)
67
+ click.secho("✔", fg="green", err=use_stderr)
71
68
  passed_checks += 1
72
69
  else:
73
70
  # Has issues - determine icon based on highest severity
74
71
  has_errors = any(not issue.warning for issue in visible_issues)
75
72
  if not quiet:
76
73
  if has_errors:
77
- click.secho("✗", fg="red", err=True)
74
+ click.secho("✗", fg="red", err=use_stderr)
78
75
  else:
79
- click.secho("⚠", fg="yellow", err=True)
76
+ click.secho("⚠", fg="yellow", err=use_stderr)
80
77
 
81
78
  # Print issues with simple indentation
82
79
  issues_to_show = (
@@ -91,26 +88,26 @@ def check_command(deploy, format, quiet):
91
88
  if quiet:
92
89
  # In quiet mode, show check name once, then issues
93
90
  if i == 0:
94
- click.secho(f"{check_name}:", err=True)
91
+ click.secho(f"{check_name}:", err=use_stderr)
95
92
  # Show ID and fix on separate lines with same indentation
96
93
  click.secho(
97
94
  f" [{issue_type}] {issue.id}:",
98
95
  fg=issue_color,
99
96
  bold=True,
100
- err=True,
97
+ err=use_stderr,
101
98
  nl=False,
102
99
  )
103
- click.secho(f" {issue.fix}", err=True, dim=True)
100
+ click.secho(f" {issue.fix}", err=use_stderr, dim=True)
104
101
  else:
105
102
  # Show ID and fix on separate lines with same indentation
106
103
  click.secho(
107
104
  f" [{issue_type}] {issue.id}: ",
108
105
  fg=issue_color,
109
106
  bold=True,
110
- err=True,
107
+ err=use_stderr,
111
108
  nl=False,
112
109
  )
113
- click.secho(f"{issue.fix}", err=True, dim=True)
110
+ click.secho(f"{issue.fix}", err=use_stderr, dim=True)
114
111
  else:
115
112
  # For JSON format, just count passed checks
116
113
  if not visible_issues:
@@ -129,12 +126,12 @@ def check_command(deploy, format, quiet):
129
126
 
130
127
  if format == "json":
131
128
  # Build JSON output
132
- results = {"passed": not has_errors, "checks": []}
129
+ results: dict[str, Any] = {"passed": not has_errors, "checks": []}
133
130
 
134
131
  for check_class, check_name, issues in check_results:
135
132
  visible_issues = [issue for issue in issues if not issue.is_silenced()]
136
133
 
137
- check_result = {
134
+ check_result: dict[str, Any] = {
138
135
  "name": check_name,
139
136
  "passed": len(visible_issues) == 0,
140
137
  "issues": [],
@@ -195,53 +192,8 @@ def check_command(deploy, format, quiet):
195
192
 
196
193
  summary_text = ", ".join(summary_parts) if summary_parts else "no issues"
197
194
 
198
- click.secho(f"{icon}{summary_text}", fg=summary_color, err=True)
195
+ click.secho(f"{icon}{summary_text}", fg=summary_color, err=use_stderr)
199
196
 
200
197
  # Exit with error if there are any errors (not warnings)
201
198
  if has_errors:
202
199
  sys.exit(1)
203
-
204
-
205
- @preflight_cli.command("list")
206
- def list_checks():
207
- """List all available preflight checks."""
208
- packages_registry.autodiscover_modules("preflight", include_app=True)
209
-
210
- regular = []
211
- deployment = []
212
- silenced_checks = settings.PREFLIGHT_SILENCED_CHECKS
213
-
214
- for name, (check_class, deploy) in sorted(checks_registry.checks.items()):
215
- # Use class docstring as description
216
- description = check_class.__doc__ or "No description"
217
- # Get first line of docstring
218
- description = description.strip().split("\n")[0]
219
-
220
- is_silenced = name in silenced_checks
221
- if deploy:
222
- deployment.append((name, description, is_silenced))
223
- else:
224
- regular.append((name, description, is_silenced))
225
-
226
- if regular:
227
- click.echo("Regular checks:")
228
- for name, description, is_silenced in regular:
229
- silenced_text = (
230
- click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
231
- )
232
- click.echo(
233
- f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
234
- )
235
-
236
- if deployment:
237
- click.echo("\nDeployment checks:")
238
- for name, description, is_silenced in deployment:
239
- silenced_text = (
240
- click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
241
- )
242
- click.echo(
243
- f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
244
- )
245
-
246
- if not regular and not deployment:
247
- click.echo("No preflight checks found.")
plain/cli/print.py CHANGED
@@ -1,9 +1,9 @@
1
1
  import click
2
2
 
3
3
 
4
- def print_event(msg, newline=True):
5
- arrow = click.style("-->", fg=214, bold=True)
6
- message = str(msg)
4
+ def print_event(msg: str, newline: bool = True) -> None:
5
+ arrow = click.style("-->", fg=214, bold=True, dim=True)
6
+ message = click.style(msg, dim=True)
7
7
  if not newline:
8
8
  message += " "
9
- click.secho(f"{arrow} {message}", nl=newline)
9
+ click.echo(f"{arrow} {message}", nl=newline)
plain/cli/registry.py CHANGED
@@ -1,45 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, NamedTuple
4
+
1
5
  from plain.packages import packages_registry
2
6
 
3
7
 
8
+ class CommandMetadata(NamedTuple):
9
+ """Metadata about a registered command."""
10
+
11
+ cmd: Any
12
+ shortcut_for: str | None = None
13
+ is_common: bool = False
14
+
15
+
4
16
  class CLIRegistry:
5
- def __init__(self):
6
- self._commands = {}
17
+ def __init__(self) -> None:
18
+ self._commands: dict[str, CommandMetadata] = {}
7
19
 
8
- def register_command(self, cmd, name):
20
+ def register_command(
21
+ self,
22
+ cmd: Any,
23
+ name: str,
24
+ shortcut_for: str | None = None,
25
+ is_common: bool = False,
26
+ ) -> None:
9
27
  """
10
28
  Register a CLI command or group with the specified name.
29
+
30
+ Args:
31
+ cmd: The click command or group to register
32
+ name: The name to register the command under
33
+ shortcut_for: Optional parent command this is a shortcut for (e.g., "models" for migrate)
34
+ is_common: Whether this is a commonly used command to show in the "Common Commands" section
11
35
  """
12
- self._commands[name] = cmd
36
+ self._commands[name] = CommandMetadata(
37
+ cmd=cmd, shortcut_for=shortcut_for, is_common=is_common
38
+ )
13
39
 
14
- def import_modules(self):
40
+ def import_modules(self) -> None:
15
41
  """
16
42
  Import modules from installed packages and app to trigger registration.
17
43
  """
18
44
  packages_registry.autodiscover_modules("cli", include_app=True)
19
45
 
20
- def get_commands(self):
46
+ def get_commands(self) -> dict[str, Any]:
21
47
  """
22
- Get all registered commands.
48
+ Get all registered commands (just the command objects, not metadata).
49
+ """
50
+ return {name: metadata.cmd for name, metadata in self._commands.items()}
51
+
52
+ def get_commands_with_metadata(self) -> dict[str, CommandMetadata]:
53
+ """
54
+ Get all registered commands with their metadata.
23
55
  """
24
56
  return self._commands
25
57
 
58
+ def get_shortcuts(self) -> dict[str, CommandMetadata]:
59
+ """
60
+ Get only commands that are shortcuts.
61
+ """
62
+ return {
63
+ name: metadata
64
+ for name, metadata in self._commands.items()
65
+ if metadata.shortcut_for
66
+ }
67
+
68
+ def get_common_commands(self) -> dict[str, CommandMetadata]:
69
+ """
70
+ Get only commands that are marked as common.
71
+ """
72
+ return {
73
+ name: metadata
74
+ for name, metadata in self._commands.items()
75
+ if metadata.is_common
76
+ }
77
+
78
+ def get_regular_commands(self) -> dict[str, CommandMetadata]:
79
+ """
80
+ Get only commands that are not common.
81
+ """
82
+ return {
83
+ name: metadata
84
+ for name, metadata in self._commands.items()
85
+ if not metadata.is_common
86
+ }
87
+
26
88
 
27
89
  cli_registry = CLIRegistry()
28
90
 
29
91
 
30
- def register_cli(name):
92
+ def register_cli(
93
+ name: str, shortcut_for: str | None = None, common: bool = False
94
+ ) -> Any:
31
95
  """
32
96
  Register a CLI command or group with the given name.
33
97
 
98
+ Args:
99
+ name: The name to register the command under
100
+ shortcut_for: Optional parent command this is a shortcut for.
101
+ For example, @register_cli("migrate", shortcut_for="models")
102
+ indicates that "plain migrate" is a shortcut for "plain models migrate"
103
+ common: Whether this is a commonly used command to show in the "Common Commands" section
104
+
34
105
  Usage:
106
+ # Register a regular command group
35
107
  @register_cli("users")
36
108
  @click.group()
37
109
  def users_cli():
38
110
  pass
111
+
112
+ # Register a shortcut command
113
+ @register_cli("migrate", shortcut_for="models", common=True)
114
+ @click.command()
115
+ def migrate():
116
+ pass
117
+
118
+ # Register a common command
119
+ @register_cli("dev", common=True)
120
+ @click.command()
121
+ def dev():
122
+ pass
39
123
  """
40
124
 
41
- def wrapper(cmd):
42
- cli_registry.register_command(cmd, name)
125
+ def wrapper(cmd: Any) -> Any:
126
+ cli_registry.register_command(
127
+ cmd, name, shortcut_for=shortcut_for, is_common=common
128
+ )
43
129
  return cmd
44
130
 
45
131
  return wrapper
@@ -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