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
@@ -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,126 +1,199 @@
1
+ import json
2
+ import sys
3
+ from typing import Any
4
+
1
5
  import click
2
6
 
3
7
  from plain import preflight
8
+ from plain.cli.runtime import common_command
4
9
  from plain.packages import packages_registry
5
10
 
6
11
 
12
+ @common_command
7
13
  @click.command("preflight")
8
- @click.argument("package_label", nargs=-1)
9
14
  @click.option(
10
15
  "--deploy",
11
16
  is_flag=True,
12
- help="Check deployment settings.",
17
+ help="Include deployment checks.",
13
18
  )
14
19
  @click.option(
15
- "--fail-level",
16
- default="ERROR",
17
- type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]),
18
- help="Message level that will cause the command to exit with a non-zero status. Default is ERROR.",
20
+ "--format",
21
+ default="text",
22
+ type=click.Choice(["text", "json"]),
23
+ help="Output format (default: text)",
19
24
  )
20
25
  @click.option(
21
- "--database",
26
+ "--quiet",
22
27
  is_flag=True,
23
- help="Run database related checks as part of preflight.",
28
+ help="Hide progress output and warnings, only show errors.",
24
29
  )
25
- def preflight_checks(package_label, deploy, fail_level, database):
26
- """
27
- Use the system check framework to validate entire Plain project.
28
- Raise CommandError for any serious message (error or critical errors).
29
- If there are only light messages (like warnings), print them to stderr
30
- and don't raise an exception.
31
- """
32
- include_deployment_checks = deploy
33
-
34
- if package_label:
35
- package_configs = [
36
- packages_registry.get_package_config(label) for label in package_label
37
- ]
38
- else:
39
- package_configs = None
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"
40
35
 
41
- all_issues = preflight.run_checks(
42
- package_configs=package_configs,
43
- include_deployment_checks=include_deployment_checks,
44
- database=database,
45
- )
36
+ # Auto-discover and load preflight checks
37
+ packages_registry.autodiscover_modules("preflight", include_app=True)
38
+ if not quiet:
39
+ click.secho(
40
+ "Running preflight checks...", dim=True, italic=True, err=use_stderr
41
+ )
42
+
43
+ total_checks = 0
44
+ passed_checks = 0
45
+ check_results = []
46
+
47
+ # Run checks and collect results
48
+ for check_class, check_name, issues in preflight.run_checks(
49
+ include_deploy_checks=deploy,
50
+ ):
51
+ total_checks += 1
52
+
53
+ # Filter out silenced issues
54
+ visible_issues = [issue for issue in issues if not issue.is_silenced()]
55
+
56
+ # For text format, show real-time progress
57
+ if format == "text":
58
+ if not quiet:
59
+ # Print check name without newline
60
+ click.echo("Check:", nl=False, err=use_stderr)
61
+ click.secho(f"{check_name} ", bold=True, nl=False, err=use_stderr)
46
62
 
47
- header, body, footer = "", "", ""
48
- visible_issue_count = 0 # excludes silenced warnings
49
-
50
- if all_issues:
51
- debugs = [
52
- e for e in all_issues if e.level < preflight.INFO and not e.is_silenced()
53
- ]
54
- infos = [
55
- e
56
- for e in all_issues
57
- if preflight.INFO <= e.level < preflight.WARNING and not e.is_silenced()
58
- ]
59
- warnings = [
60
- e
61
- for e in all_issues
62
- if preflight.WARNING <= e.level < preflight.ERROR and not e.is_silenced()
63
- ]
64
- errors = [
65
- e
66
- for e in all_issues
67
- if preflight.ERROR <= e.level < preflight.CRITICAL and not e.is_silenced()
68
- ]
69
- criticals = [
70
- e
71
- for e in all_issues
72
- if preflight.CRITICAL <= e.level and not e.is_silenced()
73
- ]
74
- sorted_issues = [
75
- (criticals, "CRITICALS"),
76
- (errors, "ERRORS"),
77
- (warnings, "WARNINGS"),
78
- (infos, "INFOS"),
79
- (debugs, "DEBUGS"),
80
- ]
81
-
82
- for issues, group_name in sorted_issues:
83
- if issues:
84
- visible_issue_count += len(issues)
85
- formatted = (
86
- click.style(str(e), fg="red")
87
- if e.is_serious()
88
- else click.style(str(e), fg="yellow")
89
- for e in issues
63
+ # Determine status icon based on issue severity
64
+ if not visible_issues:
65
+ # No issues - passed
66
+ if not quiet:
67
+ click.secho("✔", fg="green", err=use_stderr)
68
+ passed_checks += 1
69
+ else:
70
+ # Has issues - determine icon based on highest severity
71
+ has_errors = any(not issue.warning for issue in visible_issues)
72
+ if not quiet:
73
+ if has_errors:
74
+ click.secho("✗", fg="red", err=use_stderr)
75
+ else:
76
+ click.secho("⚠", fg="yellow", err=use_stderr)
77
+
78
+ # Print issues with simple indentation
79
+ issues_to_show = (
80
+ visible_issues
81
+ if not quiet
82
+ else [issue for issue in visible_issues if not issue.warning]
90
83
  )
91
- formatted = "\n".join(sorted(formatted))
92
- body += f"\n{group_name}:\n{formatted}\n"
84
+ for i, issue in enumerate(issues_to_show):
85
+ issue_color = "red" if not issue.warning else "yellow"
86
+ issue_type = "ERROR" if not issue.warning else "WARNING"
93
87
 
94
- if visible_issue_count:
95
- header = "Preflight check identified some issues:\n"
88
+ if quiet:
89
+ # In quiet mode, show check name once, then issues
90
+ if i == 0:
91
+ click.secho(f"{check_name}:", err=use_stderr)
92
+ # Show ID and fix on separate lines with same indentation
93
+ click.secho(
94
+ f" [{issue_type}] {issue.id}:",
95
+ fg=issue_color,
96
+ bold=True,
97
+ err=use_stderr,
98
+ nl=False,
99
+ )
100
+ click.secho(f" {issue.fix}", err=use_stderr, dim=True)
101
+ else:
102
+ # Show ID and fix on separate lines with same indentation
103
+ click.secho(
104
+ f" [{issue_type}] {issue.id}: ",
105
+ fg=issue_color,
106
+ bold=True,
107
+ err=use_stderr,
108
+ nl=False,
109
+ )
110
+ click.secho(f"{issue.fix}", err=use_stderr, dim=True)
111
+ else:
112
+ # For JSON format, just count passed checks
113
+ if not visible_issues:
114
+ passed_checks += 1
96
115
 
97
- if any(
98
- e.is_serious(getattr(preflight, fail_level)) and not e.is_silenced()
99
- for e in all_issues
100
- ):
101
- footer += "\n"
102
- footer += "Preflight check identified {} ({} silenced).".format(
103
- "no issues"
104
- if visible_issue_count == 0
105
- else "1 issue"
106
- if visible_issue_count == 1
107
- else f"{visible_issue_count} issues",
108
- len(all_issues) - visible_issue_count,
109
- )
110
- msg = click.style(f"SystemCheckError: {header}", fg="red") + body + footer
111
- raise click.ClickException(msg)
116
+ check_results.append((check_class, check_name, issues))
117
+
118
+ # Output results based on format
119
+
120
+ # Get all issues from check_results instead of maintaining separate list
121
+ all_issues = [issue for _, _, issues in check_results for issue in issues]
122
+ # Errors (non-warnings) cause preflight to fail
123
+ has_errors = any(
124
+ not issue.warning and not issue.is_silenced() for issue in all_issues
125
+ )
126
+
127
+ if format == "json":
128
+ # Build JSON output
129
+ results: dict[str, Any] = {"passed": not has_errors, "checks": []}
130
+
131
+ for check_class, check_name, issues in check_results:
132
+ visible_issues = [issue for issue in issues if not issue.is_silenced()]
133
+
134
+ check_result: dict[str, Any] = {
135
+ "name": check_name,
136
+ "passed": len(visible_issues) == 0,
137
+ "issues": [],
138
+ }
139
+
140
+ for issue in visible_issues:
141
+ issue_data = {
142
+ "id": issue.id,
143
+ "warning": issue.warning,
144
+ "fix": issue.fix,
145
+ "obj": str(issue.obj) if issue.obj is not None else None,
146
+ }
147
+ check_result["issues"].append(issue_data)
148
+
149
+ results["checks"].append(check_result)
150
+
151
+ click.echo(json.dumps(results, indent=2))
112
152
  else:
113
- if visible_issue_count:
114
- footer += "\n"
115
- footer += "Preflight check identified {} ({} silenced).".format(
116
- "no issues"
117
- if visible_issue_count == 0
118
- else "1 issue"
119
- if visible_issue_count == 1
120
- else f"{visible_issue_count} issues",
121
- len(all_issues) - visible_issue_count,
153
+ # Text format summary
154
+ if not quiet:
155
+ click.echo()
156
+
157
+ # Calculate warning and error counts
158
+ warning_count = sum(
159
+ 1
160
+ for _, _, issues in check_results
161
+ if issues
162
+ and not any(
163
+ not issue.warning for issue in issues if not issue.is_silenced()
122
164
  )
123
- msg = header + body + footer
124
- click.echo(msg, err=True)
165
+ )
166
+ error_count = sum(
167
+ 1
168
+ for _, _, issues in check_results
169
+ if issues
170
+ and any(not issue.warning for issue in issues if not issue.is_silenced())
171
+ )
172
+
173
+ # Build colored summary parts
174
+ summary_parts = []
175
+
176
+ if passed_checks > 0:
177
+ summary_parts.append(click.style(f"{passed_checks} passed", fg="green"))
178
+
179
+ if warning_count > 0:
180
+ summary_parts.append(click.style(f"{warning_count} warnings", fg="yellow"))
181
+
182
+ if error_count > 0:
183
+ summary_parts.append(click.style(f"{error_count} errors", fg="red"))
184
+
185
+ # Show checkmark if successful (no errors)
186
+ if not has_errors:
187
+ icon = click.style("✔ ", fg="green")
188
+ summary_color = "green"
125
189
  else:
126
- click.secho("✔ Checks passed", err=True, fg="green")
190
+ icon = ""
191
+ summary_color = None
192
+
193
+ summary_text = ", ".join(summary_parts) if summary_parts else "no issues"
194
+
195
+ click.secho(f"{icon}{summary_text}", fg=summary_color, err=use_stderr)
196
+
197
+ # Exit with error if there are any errors (not warnings)
198
+ if has_errors:
199
+ sys.exit(1)
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,62 +1,131 @@
1
- from importlib import import_module
2
- from importlib.util import find_spec
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, NamedTuple
3
4
 
4
5
  from plain.packages import packages_registry
5
6
 
6
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
+
7
16
  class CLIRegistry:
8
- def __init__(self):
9
- self._commands = {}
17
+ def __init__(self) -> None:
18
+ self._commands: dict[str, CommandMetadata] = {}
10
19
 
11
- 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:
12
27
  """
13
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
14
35
  """
15
- self._commands[name] = cmd
36
+ self._commands[name] = CommandMetadata(
37
+ cmd=cmd, shortcut_for=shortcut_for, is_common=is_common
38
+ )
16
39
 
17
- def import_modules(self):
40
+ def import_modules(self) -> None:
18
41
  """
19
42
  Import modules from installed packages and app to trigger registration.
20
43
  """
21
- # Import from installed packages
22
- for package_config in packages_registry.get_package_configs():
23
- import_name = f"{package_config.name}.cli"
24
- try:
25
- import_module(import_name)
26
- except ModuleNotFoundError:
27
- pass
44
+ packages_registry.autodiscover_modules("cli", include_app=True)
28
45
 
29
- # Import from app
30
- import_name = "app.cli"
31
- if find_spec(import_name):
32
- try:
33
- import_module(import_name)
34
- except ModuleNotFoundError:
35
- pass
46
+ def get_commands(self) -> dict[str, Any]:
47
+ """
48
+ Get all registered commands (just the command objects, not metadata).
49
+ """
50
+ return {name: metadata.cmd for name, metadata in self._commands.items()}
36
51
 
37
- def get_commands(self):
52
+ def get_commands_with_metadata(self) -> dict[str, CommandMetadata]:
38
53
  """
39
- Get all registered commands.
54
+ Get all registered commands with their metadata.
40
55
  """
41
56
  return self._commands
42
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
+
43
88
 
44
89
  cli_registry = CLIRegistry()
45
90
 
46
91
 
47
- def register_cli(name):
92
+ def register_cli(
93
+ name: str, shortcut_for: str | None = None, common: bool = False
94
+ ) -> Any:
48
95
  """
49
96
  Register a CLI command or group with the given name.
50
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
+
51
105
  Usage:
106
+ # Register a regular command group
52
107
  @register_cli("users")
53
108
  @click.group()
54
109
  def users_cli():
55
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
56
123
  """
57
124
 
58
- def wrapper(cmd):
59
- 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
+ )
60
129
  return cmd
61
130
 
62
131
  return wrapper