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
plain/cli/install.py CHANGED
@@ -3,28 +3,11 @@ import sys
3
3
 
4
4
  import click
5
5
 
6
- from .agent.prompt import prompt_agent
7
-
8
6
 
9
7
  @click.command()
10
8
  @click.argument("packages", nargs=-1, required=True)
11
- @click.option(
12
- "--agent-command",
13
- envvar="PLAIN_AGENT_COMMAND",
14
- help="Run command with generated prompt",
15
- )
16
- @click.option(
17
- "--print",
18
- "print_only",
19
- is_flag=True,
20
- help="Print the prompt without running the agent",
21
- )
22
- def install(
23
- packages: tuple[str, ...],
24
- agent_command: str | None = None,
25
- print_only: bool = False,
26
- ) -> None:
27
- """Install Plain packages with the help of an agent."""
9
+ def install(packages: tuple[str, ...]) -> None:
10
+ """Install Plain packages"""
28
11
  # Validate all package names
29
12
  invalid_packages = [pkg for pkg in packages if not pkg.startswith("plain")]
30
13
  if invalid_packages:
@@ -33,14 +16,14 @@ def install(
33
16
  "This command is only for Plain framework packages."
34
17
  )
35
18
 
36
- # Install all packages first
19
+ # Install all packages
37
20
  if len(packages) == 1:
38
- click.secho(f"Installing {packages[0]}...", bold=True, err=True)
21
+ click.secho(f"Installing {packages[0]}...", bold=True)
39
22
  else:
40
- click.secho(f"Installing {len(packages)} packages...", bold=True, err=True)
23
+ click.secho(f"Installing {len(packages)} packages...", bold=True)
41
24
  for pkg in packages:
42
- click.secho(f" - {pkg}", err=True)
43
- click.echo(err=True)
25
+ click.secho(f" - {pkg}")
26
+ click.echo()
44
27
 
45
28
  install_cmd = ["uv", "add"] + list(packages)
46
29
  result = subprocess.run(install_cmd, check=False, stderr=sys.stderr)
@@ -48,35 +31,8 @@ def install(
48
31
  if result.returncode != 0:
49
32
  raise click.ClickException("Failed to install packages")
50
33
 
51
- click.echo(err=True)
34
+ click.echo()
52
35
  if len(packages) == 1:
53
- click.secho(f"{packages[0]} installed successfully", fg="green", err=True)
36
+ click.secho(f"{packages[0]} installed successfully", fg="green")
54
37
  else:
55
- click.secho(
56
- f"✓ {len(packages)} packages installed successfully", fg="green", err=True
57
- )
58
- click.echo(err=True)
59
-
60
- # Build the prompt for the agent to complete setup
61
- lines = [
62
- f"Complete the setup for the following Plain packages that were just installed: {', '.join(packages)}",
63
- "",
64
- "## Instructions",
65
- "",
66
- "For each package:",
67
- "1. Run `uv run plain docs <package>` and read the installation instructions",
68
- "2. If the docs point out that it is a --dev tool, move it to the dev dependencies in pyproject.toml: `uv remove <package> && uv add <package> --dev`",
69
- "3. Go through the installation instructions and complete any code modifications that are needed",
70
- "",
71
- "DO NOT commit any changes",
72
- "",
73
- "Report back with:",
74
- "- Whether the setup completed successfully",
75
- "- Any manual steps that the user will need to complete",
76
- "- Any issues or errors encountered",
77
- ]
78
-
79
- prompt = "\n".join(lines)
80
- success = prompt_agent(prompt, agent_command, print_only)
81
- if not success:
82
- raise click.Abort()
38
+ click.secho(f"{len(packages)} packages installed successfully", fg="green")
@@ -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 agents 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 "/agents/" 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 "/agents/" 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,30 +62,37 @@ 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):
62
- for doc in self.docs:
63
- if relative_to:
64
- display_path = doc.relative_to(relative_to)
65
- else:
66
- display_path = self.display_path(doc)
67
- click.secho(f"<Docs: {display_path}>", fg="yellow")
68
- click.echo(doc.read_text())
69
- click.secho(f"</Docs: {display_path}>", fg="yellow")
70
- click.echo()
71
-
72
- for source in self.sources:
73
- if symbolicated := self.symbolicate(source):
65
+ def print(
66
+ self,
67
+ relative_to: Path | None = None,
68
+ include_docs: bool = True,
69
+ include_symbols: bool = True,
70
+ ) -> None:
71
+ if include_docs:
72
+ for doc in self.docs:
74
73
  if relative_to:
75
- display_path = source.relative_to(relative_to)
74
+ display_path = doc.relative_to(relative_to)
76
75
  else:
77
- display_path = self.display_path(source)
78
- click.secho(f"<Source: {display_path}>", fg="yellow")
79
- click.echo(symbolicated)
80
- click.secho(f"</Source: {display_path}>", fg="yellow")
76
+ display_path = self.display_path(doc)
77
+ click.secho(f"<Docs: {display_path}>", fg="yellow")
78
+ click.echo(doc.read_text())
79
+ click.secho(f"</Docs: {display_path}>", fg="yellow")
81
80
  click.echo()
82
81
 
82
+ if include_symbols:
83
+ for source in self.sources:
84
+ if symbolicated := self.symbolicate(source):
85
+ if relative_to:
86
+ display_path = source.relative_to(relative_to)
87
+ else:
88
+ display_path = self.display_path(source)
89
+ click.secho(f"<Source: {display_path}>", fg="yellow")
90
+ click.echo(symbolicated)
91
+ click.secho(f"</Source: {display_path}>", fg="yellow")
92
+ click.echo()
93
+
83
94
  @staticmethod
84
- def symbolicate(file_path: Path):
95
+ def symbolicate(file_path: Path) -> str:
85
96
  if "internal" in str(file_path).split("/"):
86
97
  return ""
87
98
 
@@ -89,8 +100,16 @@ class LLMDocs:
89
100
 
90
101
  parsed = ast.parse(source)
91
102
 
92
- def should_skip(node):
93
- if isinstance(node, ast.ClassDef | ast.FunctionDef):
103
+ def should_skip(node: ast.AST) -> bool:
104
+ if isinstance(node, ast.ClassDef):
105
+ if any(
106
+ isinstance(d, ast.Name) and d.id == "internalcode"
107
+ for d in node.decorator_list
108
+ ):
109
+ return True
110
+ if node.name.startswith("_"):
111
+ return True
112
+ elif isinstance(node, ast.FunctionDef):
94
113
  if any(
95
114
  isinstance(d, ast.Name) and d.id == "internalcode"
96
115
  for d in node.decorator_list
@@ -104,7 +123,7 @@ class LLMDocs:
104
123
  return True
105
124
  return False
106
125
 
107
- def process_node(node, indent=0):
126
+ def process_node(node: ast.AST, indent: int = 0) -> list[str]:
108
127
  lines = []
109
128
  prefix = " " * indent
110
129
 
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