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/build.py CHANGED
@@ -9,6 +9,7 @@ import click
9
9
 
10
10
  import plain.runtime
11
11
  from plain.assets.compile import compile_assets, get_compiled_path
12
+ from plain.cli.print import print_event
12
13
 
13
14
 
14
15
  @click.command()
@@ -33,8 +34,8 @@ from plain.assets.compile import compile_assets, get_compiled_path
33
34
  default=True,
34
35
  help="Compress the assets",
35
36
  )
36
- def build(keep_original, fingerprint, compress):
37
- """Pre-deployment build step (compile assets, css, js, etc.)"""
37
+ def build(keep_original: bool, fingerprint: bool, compress: bool) -> None:
38
+ """Pre-deployment build step for assets and static files"""
38
39
 
39
40
  if not keep_original and not fingerprint:
40
41
  raise click.UsageError(
@@ -54,18 +55,16 @@ def build(keep_original, fingerprint, compress):
54
55
  .get("run", {})
55
56
  .items()
56
57
  ):
57
- click.secho(f"Running {name} from pyproject.toml", bold=True)
58
+ print_event(f"{name}...")
58
59
  result = subprocess.run(data["cmd"], shell=True)
59
- print()
60
60
  if result.returncode:
61
61
  click.secho(f"Error in {name} (exit {result.returncode})", fg="red")
62
62
  sys.exit(result.returncode)
63
63
 
64
64
  # Then run installed package build steps (like tailwind, typically should run last...)
65
65
  for entry_point in entry_points(group="plain.build"):
66
- click.secho(f"Running {entry_point.name}", bold=True)
67
- result = entry_point.load()()
68
- print()
66
+ print_event(f"{entry_point.name}...")
67
+ entry_point.load()()
69
68
 
70
69
  # Compile our assets
71
70
  target_dir = get_compiled_path()
@@ -79,7 +78,7 @@ def build(keep_original, fingerprint, compress):
79
78
  total_compiled = 0
80
79
 
81
80
  for url_path, resolved_url_path, compiled_paths in compile_assets(
82
- target_dir=target_dir,
81
+ target_dir=str(target_dir),
83
82
  keep_original=keep_original,
84
83
  fingerprint=fingerprint,
85
84
  compress=compress,
plain/cli/changelog.py CHANGED
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import re
2
4
  from importlib.util import find_spec
3
5
  from pathlib import Path
@@ -5,9 +7,10 @@ from pathlib import Path
5
7
  import click
6
8
 
7
9
  from .output import style_markdown
10
+ from .runtime import without_runtime_setup
8
11
 
9
12
 
10
- def parse_version(version_str):
13
+ def parse_version(version_str: str) -> tuple[int, ...]:
11
14
  """Parse a version string into a tuple of integers for comparison."""
12
15
  # Remove 'v' prefix if present and split by dots
13
16
  clean_version = version_str.lstrip("v")
@@ -22,7 +25,7 @@ def parse_version(version_str):
22
25
  return tuple(parts)
23
26
 
24
27
 
25
- def compare_versions(v1, v2):
28
+ def compare_versions(v1: str, v2: str) -> int:
26
29
  """Compare two version strings. Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2."""
27
30
  parsed_v1 = parse_version(v1)
28
31
  parsed_v2 = parse_version(v2)
@@ -40,12 +43,15 @@ def compare_versions(v1, v2):
40
43
  return 0
41
44
 
42
45
 
46
+ @without_runtime_setup
43
47
  @click.command("changelog")
44
48
  @click.argument("package_label")
45
49
  @click.option("--from", "from_version", help="Show entries from this version onwards")
46
50
  @click.option("--to", "to_version", help="Show entries up to this version")
47
- def changelog(package_label, from_version, to_version):
48
- """Show changelog entries for a package."""
51
+ def changelog(
52
+ package_label: str, from_version: str | None, to_version: str | None
53
+ ) -> None:
54
+ """Show changelog for a package"""
49
55
  module_name = package_label.replace("-", ".")
50
56
  spec = find_spec(module_name)
51
57
  if not spec:
@@ -85,7 +91,7 @@ def changelog(package_label, from_version, to_version):
85
91
  if current_version is not None:
86
92
  entries.append((current_version, current_lines))
87
93
 
88
- def version_found(version):
94
+ def version_found(version: str) -> bool:
89
95
  return any(compare_versions(v, version) == 0 for v, _ in entries)
90
96
 
91
97
  if from_version and not version_found(from_version):
plain/cli/chores.py CHANGED
@@ -7,79 +7,77 @@ logger = logging.getLogger("plain.chores")
7
7
 
8
8
 
9
9
  @click.group()
10
- def chores():
10
+ def chores() -> None:
11
11
  """Routine maintenance tasks"""
12
12
  pass
13
13
 
14
14
 
15
15
  @chores.command("list")
16
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
17
16
  @click.option(
18
17
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
19
18
  )
20
- def list_chores(group, name):
21
- """
22
- List all registered chores.
23
- """
19
+ def list_chores(name: tuple[str, ...]) -> None:
20
+ """List all registered chores"""
24
21
  from plain.chores.registry import chores_registry
25
22
 
26
23
  chores_registry.import_modules()
27
24
 
28
- if group or name:
29
- chores = [
30
- chore
31
- for chore in chores_registry.get_chores()
32
- if (chore.group in group or not group) and (chore.name in name or not name)
25
+ chore_classes = chores_registry.get_chores()
26
+
27
+ if name:
28
+ chore_classes = [
29
+ chore_class
30
+ for chore_class in chore_classes
31
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
33
32
  ]
34
- else:
35
- chores = chores_registry.get_chores()
36
33
 
37
- for chore in chores:
38
- click.secho(f"{chore}", bold=True, nl=False)
39
- if chore.description:
40
- click.echo(f": {chore.description}")
34
+ for chore_class in chore_classes:
35
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
36
+ click.secho(f"{chore_name}", bold=True, nl=False)
37
+ description = chore_class.__doc__.strip() if chore_class.__doc__ else ""
38
+ if description:
39
+ click.secho(f": {description}", dim=True)
41
40
  else:
42
41
  click.echo("")
43
42
 
44
43
 
45
44
  @chores.command("run")
46
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
47
45
  @click.option(
48
46
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
49
47
  )
50
48
  @click.option(
51
49
  "--dry-run", is_flag=True, help="Show what would be done without executing"
52
50
  )
53
- def run_chores(group, name, dry_run):
54
- """
55
- Run the specified chores.
56
- """
51
+ def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
52
+ """Run specified chores"""
57
53
  from plain.chores.registry import chores_registry
58
54
 
59
55
  chores_registry.import_modules()
60
56
 
61
- if group or name:
62
- chores = [
63
- chore
64
- for chore in chores_registry.get_chores()
65
- if (chore.group in group or not group) and (chore.name in name or not name)
57
+ chore_classes = chores_registry.get_chores()
58
+
59
+ if name:
60
+ chore_classes = [
61
+ chore_class
62
+ for chore_class in chore_classes
63
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
66
64
  ]
67
- else:
68
- chores = chores_registry.get_chores()
69
65
 
70
66
  chores_failed = []
71
67
 
72
- for chore in chores:
73
- click.echo(f"{chore.name}:", nl=False)
68
+ for chore_class in chore_classes:
69
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
70
+ click.echo(f"{chore_name}:", nl=False)
74
71
  if dry_run:
75
- click.echo(" (dry run)", fg="yellow")
72
+ click.secho(" (dry run)", fg="yellow", nl=False)
76
73
  else:
77
74
  try:
75
+ chore = chore_class()
78
76
  result = chore.run()
79
77
  except Exception:
80
78
  click.secho(" Failed", fg="red")
81
- chores_failed.append(chore)
82
- logger.exception(f"Error running chore {chore.name}")
79
+ chores_failed.append(chore_class)
80
+ logger.exception(f"Error running chore {chore_name}")
83
81
  continue
84
82
 
85
83
  if result is None:
plain/cli/core.py CHANGED
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import traceback
4
+ from typing import Any
2
5
 
3
6
  import click
4
7
  from click.core import Command, Context
@@ -15,8 +18,10 @@ from .formatting import PlainContext
15
18
  from .install import install
16
19
  from .preflight import preflight_cli
17
20
  from .registry import cli_registry
21
+ from .request import request
18
22
  from .scaffold import create
19
- from .settings import setting
23
+ from .server import server
24
+ from .settings import settings
20
25
  from .shell import run, shell
21
26
  from .upgrade import upgrade
22
27
  from .urls import urls
@@ -24,12 +29,13 @@ from .utils import utils
24
29
 
25
30
 
26
31
  @click.group()
27
- def plain_cli():
32
+ def plain_cli() -> None:
28
33
  pass
29
34
 
30
35
 
31
- plain_cli.add_command(agent)
32
36
  plain_cli.add_command(docs)
37
+ plain_cli.add_command(request)
38
+ plain_cli.add_command(agent)
33
39
  plain_cli.add_command(preflight_cli)
34
40
  plain_cli.add_command(create)
35
41
  plain_cli.add_command(chores)
@@ -37,11 +43,12 @@ plain_cli.add_command(build)
37
43
  plain_cli.add_command(utils)
38
44
  plain_cli.add_command(urls)
39
45
  plain_cli.add_command(changelog)
40
- plain_cli.add_command(setting)
46
+ plain_cli.add_command(settings)
41
47
  plain_cli.add_command(shell)
42
48
  plain_cli.add_command(run)
43
49
  plain_cli.add_command(install)
44
50
  plain_cli.add_command(upgrade)
51
+ plain_cli.add_command(server)
45
52
 
46
53
 
47
54
  class CLIRegistryGroup(click.Group):
@@ -49,42 +56,49 @@ class CLIRegistryGroup(click.Group):
49
56
  Click Group that exposes commands from the CLI registry.
50
57
  """
51
58
 
52
- def __init__(self, *args, **kwargs):
59
+ def __init__(self, *args: Any, **kwargs: Any):
53
60
  super().__init__(*args, **kwargs)
54
61
  cli_registry.import_modules()
55
62
 
56
- def list_commands(self, ctx):
63
+ def list_commands(self, ctx: Context) -> list[str]:
57
64
  return sorted(cli_registry.get_commands().keys())
58
65
 
59
- def get_command(self, ctx, name):
66
+ def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
60
67
  commands = cli_registry.get_commands()
61
- return commands.get(name)
68
+ return commands.get(cmd_name)
62
69
 
63
70
 
64
71
  class PlainCommandCollection(click.CommandCollection):
65
72
  context_class = PlainContext
66
73
 
67
- def __init__(self, *args, **kwargs):
68
- sources = []
74
+ def __init__(self, *args: Any, **kwargs: Any):
75
+ # Start with only built-in commands (no setup needed)
76
+ sources = [plain_cli]
77
+
78
+ super().__init__(*args, **kwargs)
79
+ self.sources = sources
80
+ self._registry_group = None
81
+ self._setup_attempted = False
82
+
83
+ def _ensure_registry_loaded(self) -> None:
84
+ """Lazy load the registry group (requires setup)."""
85
+ if self._registry_group is not None or self._setup_attempted:
86
+ return
87
+
88
+ self._setup_attempted = True
69
89
 
70
90
  try:
71
91
  plain.runtime.setup()
72
-
73
- sources = [
74
- CLIRegistryGroup(),
75
- plain_cli,
76
- ]
92
+ self._registry_group = CLIRegistryGroup()
93
+ # Add registry group to sources
94
+ self.sources.insert(0, self._registry_group)
77
95
  except plain.runtime.AppPathNotFound:
78
- # Allow some commands to work regardless of being in a valid app
96
+ # Allow built-in commands to work regardless of being in a valid app
79
97
  click.secho(
80
98
  "Plain `app` directory not found. Some commands may be missing.",
81
99
  fg="yellow",
82
100
  err=True,
83
101
  )
84
-
85
- sources = [
86
- plain_cli,
87
- ]
88
102
  except ImproperlyConfigured as e:
89
103
  # Show what was configured incorrectly and exit
90
104
  click.secho(
@@ -92,7 +106,6 @@ class PlainCommandCollection(click.CommandCollection):
92
106
  fg="red",
93
107
  err=True,
94
108
  )
95
-
96
109
  exit(1)
97
110
  except Exception as e:
98
111
  # Show the exception and exit
@@ -105,19 +118,90 @@ class PlainCommandCollection(click.CommandCollection):
105
118
  fg="red",
106
119
  err=True,
107
120
  )
108
-
109
121
  exit(1)
110
122
 
111
- super().__init__(*args, **kwargs)
112
-
113
- self.sources = sources
114
-
115
123
  def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
124
+ # Try built-in commands first
116
125
  cmd = super().get_command(ctx, cmd_name)
126
+
127
+ if cmd is None:
128
+ # Command not found in built-ins, try registry (requires setup)
129
+ self._ensure_registry_loaded()
130
+ cmd = super().get_command(ctx, cmd_name)
131
+ elif not getattr(cmd, "without_runtime_setup", False):
132
+ # Command found but needs setup - ensure registry is loaded
133
+ self._ensure_registry_loaded()
134
+
117
135
  if cmd:
118
136
  # Pass the formatting down to subcommands automatically
119
137
  cmd.context_class = self.context_class
120
138
  return cmd
121
139
 
140
+ def list_commands(self, ctx: Context) -> list[str]:
141
+ # For help listing, we need to show registry commands too
142
+ self._ensure_registry_loaded()
143
+ return super().list_commands(ctx)
144
+
145
+ def format_commands(self, ctx: Context, formatter: Any) -> None:
146
+ """Format commands with separate sections for common, core, and package commands."""
147
+ self._ensure_registry_loaded()
148
+
149
+ # Get all commands from both sources, tracking their source
150
+ commands = []
151
+ for source_index, source in enumerate(self.sources):
152
+ for name in source.list_commands(ctx):
153
+ cmd = source.get_command(ctx, name)
154
+ if cmd is not None:
155
+ # source_index 0 = plain_cli (core), 1+ = registry (packages)
156
+ commands.append((name, cmd, source_index))
157
+
158
+ if not commands:
159
+ return
160
+
161
+ # Get metadata from the registry (for shortcuts)
162
+ shortcuts_metadata = cli_registry.get_shortcuts()
163
+
164
+ # Separate commands into common, core, and package
165
+ common_commands = []
166
+ core_commands = []
167
+ package_commands = []
168
+
169
+ for name, cmd, source_index in commands:
170
+ help_text = cmd.get_short_help_str(limit=200)
171
+
172
+ # Check if command is marked as common via decorator
173
+ is_common = getattr(cmd, "is_common_command", False)
174
+
175
+ if is_common:
176
+ # This is a common command
177
+ # Add arrow notation if it's also a shortcut
178
+ if name in shortcuts_metadata:
179
+ shortcut_for = shortcuts_metadata[name].shortcut_for
180
+ if shortcut_for:
181
+ alias_info = click.style(f"(→ {shortcut_for})", italic=True)
182
+ help_text = f"{help_text} {alias_info}"
183
+ common_commands.append((name, help_text))
184
+ elif source_index == 0:
185
+ # Package command (from registry, inserted at index 0)
186
+ package_commands.append((name, help_text))
187
+ else:
188
+ # Core command (from plain_cli, at index 1)
189
+ core_commands.append((name, help_text))
190
+
191
+ # Write common commands section if any exist
192
+ if common_commands:
193
+ with formatter.section("Common Commands"):
194
+ formatter.write_dl(sorted(common_commands))
195
+
196
+ # Write core commands section if any exist
197
+ if core_commands:
198
+ with formatter.section("Core Commands"):
199
+ formatter.write_dl(sorted(core_commands))
200
+
201
+ # Write package commands section if any exist
202
+ if package_commands:
203
+ with formatter.section("Package Commands"):
204
+ formatter.write_dl(sorted(package_commands))
205
+
122
206
 
123
207
  cli = PlainCommandCollection()
plain/cli/docs.py CHANGED
@@ -1,38 +1,115 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib.util
2
4
  from pathlib import Path
3
5
 
4
6
  import click
5
7
 
6
- from .output import iterate_markdown
8
+ from .llmdocs import LLMDocs
9
+
10
+ # All known official Plain packages: pip name -> short description
11
+ KNOWN_PACKAGES = {
12
+ "plain": "Web framework core",
13
+ "plain-admin": "Backend admin interface",
14
+ "plain-api": "Class-based API views",
15
+ "plain-auth": "User authentication and authorization",
16
+ "plain-cache": "Database-backed cache with optional expiration",
17
+ "plain-code": "Preconfigured code formatting and linting",
18
+ "plain-dev": "Local development server with auto-reload",
19
+ "plain-elements": "HTML template components",
20
+ "plain-email": "Send email",
21
+ "plain-esbuild": "Build JavaScript with esbuild",
22
+ "plain-flags": "Feature flags via database models",
23
+ "plain-htmx": "HTMX integration for templates and views",
24
+ "plain-jobs": "Background jobs with a database-driven queue",
25
+ "plain-loginlink": "Link-based authentication",
26
+ "plain-models": "Model data and store it in a database",
27
+ "plain-oauth": "OAuth provider login",
28
+ "plain-observer": "On-page telemetry and observability",
29
+ "plain-pages": "Serve static pages, markdown, and assets",
30
+ "plain-pageviews": "Client-side pageview tracking",
31
+ "plain-passwords": "Password authentication",
32
+ "plain-pytest": "Test with pytest",
33
+ "plain-redirection": "URL redirection with admin and logging",
34
+ "plain-scan": "Test for production best practices",
35
+ "plain-sessions": "Database-backed sessions",
36
+ "plain-start": "Bootstrap a new project from templates",
37
+ "plain-support": "Support forms for your application",
38
+ "plain-tailwind": "Tailwind CSS without JavaScript or npm",
39
+ "plain-toolbar": "Debug toolbar",
40
+ "plain-tunnel": "Remote access to local dev server",
41
+ "plain-vendor": "Vendor CDN scripts and styles",
42
+ }
43
+
44
+
45
+ def _normalize_module(module: str) -> str:
46
+ """Normalize a module string to dotted form (e.g. plain-models -> plain.models)."""
47
+ module = module.replace("-", ".")
48
+ if not module.startswith("plain"):
49
+ module = f"plain.{module}"
50
+ return module
51
+
52
+
53
+ def _pip_package_name(module: str) -> str:
54
+ """Convert a dotted module name to a pip package name (e.g. plain.models -> plain-models)."""
55
+ return module.replace(".", "-")
56
+
57
+
58
+ def _is_installed(module: str) -> bool:
59
+ """Check if a dotted module name is installed."""
60
+ try:
61
+ return importlib.util.find_spec(module) is not None
62
+ except (ModuleNotFoundError, ValueError):
63
+ return False
64
+
65
+
66
+ def _online_docs_url(pip_name: str) -> str:
67
+ """Return the online documentation URL for a package."""
68
+ module = pip_name.replace("-", ".")
69
+ return f"https://plainframework.com/docs/{pip_name}/{module.replace('.', '/')}/"
7
70
 
8
71
 
9
72
  @click.command()
10
- @click.option("--open")
73
+ @click.option("--symbols", is_flag=True, help="Show symbolicated API surface only")
74
+ @click.option("--list", "show_list", is_flag=True, help="List available packages")
11
75
  @click.argument("module", default="")
12
- def docs(module, open):
76
+ def docs(module: str, symbols: bool, show_list: bool) -> None:
77
+ """Show documentation for a package"""
78
+ if show_list:
79
+ for pip_name in sorted(KNOWN_PACKAGES):
80
+ description = KNOWN_PACKAGES[pip_name]
81
+ dotted = pip_name.replace("-", ".")
82
+ installed = _is_installed(dotted)
83
+ status = " (installed)" if installed else ""
84
+ click.echo(f" {pip_name}{status} — {description}")
85
+ return
86
+
13
87
  if not module:
14
88
  raise click.UsageError(
15
- "You must specify a module. For LLM-friendly docs, use `plain agent docs`."
89
+ "You must specify a module. Use --list to see available packages."
16
90
  )
17
91
 
18
- # Convert hyphens to dots (e.g., plain-models -> plain.models)
19
- module = module.replace("-", ".")
92
+ module = _normalize_module(module)
20
93
 
21
- # Automatically prefix if we need to
22
- if not module.startswith("plain"):
23
- module = f"plain.{module}"
24
-
25
- # Get the README.md file for the module
94
+ # Get the module path
26
95
  spec = importlib.util.find_spec(module)
27
- if not spec:
28
- raise click.UsageError(f"Module {module} not found")
96
+ if not spec or not spec.origin:
97
+ pip_name = _pip_package_name(module)
98
+ if pip_name in KNOWN_PACKAGES:
99
+ msg = (
100
+ f"{module} is not installed.\n\n"
101
+ f" Online docs: {_online_docs_url(pip_name)}"
102
+ )
103
+ else:
104
+ msg = f"Module {module} not found. Use --list to see available packages."
105
+ raise click.UsageError(msg)
29
106
 
30
107
  module_path = Path(spec.origin).parent
31
- readme_path = module_path / "README.md"
32
- if not readme_path.exists():
33
- raise click.UsageError(f"README.md not found for {module}")
34
-
35
- if open:
36
- click.launch(str(readme_path))
37
- else:
38
- click.echo_via_pager(iterate_markdown(readme_path.read_text()))
108
+
109
+ llm_docs = LLMDocs([module_path])
110
+ llm_docs.load()
111
+ llm_docs.print(
112
+ relative_to=module_path.parent,
113
+ include_docs=not symbols,
114
+ include_symbols=symbols,
115
+ )
plain/cli/formatting.py CHANGED
@@ -1,24 +1,29 @@
1
+ from __future__ import annotations
2
+
1
3
  import os
4
+ from typing import Any
2
5
 
3
6
  import click
4
7
  from click.formatting import iter_rows, measure_table, term_len, wrap_text
5
8
 
6
9
 
7
10
  class PlainHelpFormatter(click.HelpFormatter):
8
- def write_heading(self, heading):
9
- styled_heading = click.style(heading, underline=True)
11
+ def write_heading(self, heading: str) -> None:
12
+ styled_heading = click.style(heading, dim=True)
10
13
  self.write(f"{'':>{self.current_indent}}{styled_heading}\n")
11
14
 
12
- def write_usage(self, prog, args, prefix="Usage: "):
13
- prefix_styled = click.style(prefix, italic=True)
15
+ def write_usage( # type: ignore[override]
16
+ self, prog: str, args: str = "", prefix: str = "Usage: "
17
+ ) -> None:
18
+ prefix_styled = click.style(prefix, dim=True)
14
19
  super().write_usage(prog, args, prefix=prefix_styled)
15
20
 
16
- def write_dl(
21
+ def write_dl( # type: ignore[override]
17
22
  self,
18
- rows,
19
- col_max=30,
20
- col_spacing=2,
21
- ):
23
+ rows: list[tuple[str, str]],
24
+ col_max: int = 20,
25
+ col_spacing: int = 2,
26
+ ) -> None:
22
27
  """Writes a definition list into the buffer. This is how options
23
28
  and commands are usually formatted.
24
29
 
@@ -51,10 +56,15 @@ class PlainHelpFormatter(click.HelpFormatter):
51
56
  lines = wrapped_text.splitlines()
52
57
 
53
58
  if lines:
54
- self.write(f"{lines[0]}\n")
59
+ # Dim the description text
60
+ first_line_styled = click.style(lines[0], dim=True)
61
+ self.write(f"{first_line_styled}\n")
55
62
 
56
63
  for line in lines[1:]:
57
- self.write(f"{'':>{first_col + self.current_indent}}{line}\n")
64
+ line_styled = click.style(line, dim=True)
65
+ self.write(
66
+ f"{'':>{first_col + self.current_indent}}{line_styled}\n"
67
+ )
58
68
  else:
59
69
  self.write("\n")
60
70
 
@@ -62,12 +72,25 @@ class PlainHelpFormatter(click.HelpFormatter):
62
72
  class PlainContext(click.Context):
63
73
  formatter_class = PlainHelpFormatter
64
74
 
65
- def __init__(self, *args, **kwargs):
75
+ def __init__(self, *args: Any, **kwargs: Any):
76
+ # Set a wider max_content_width for help text (default is 80)
77
+ # This allows descriptions to fit more comfortably on one line
78
+ if "max_content_width" not in kwargs:
79
+ kwargs["max_content_width"] = 140
80
+
66
81
  super().__init__(*args, **kwargs)
67
82
 
68
- # Force colors in CI environments
69
- if any(
70
- os.getenv(var)
71
- for var in ["CI", "FORCE_COLOR", "GITHUB_ACTIONS", "GITLAB_CI"]
72
- ) and not any(os.getenv(var) for var in ["NO_COLOR", "PYTEST_CURRENT_TEST"]):
83
+ # Follow CLICOLOR standard (http://bixense.com/clicolors/)
84
+ # Priority: NO_COLOR > CLICOLOR_FORCE/FORCE_COLOR > CI detection > CLICOLOR > isatty
85
+ if os.getenv("NO_COLOR") or os.getenv("PYTEST_CURRENT_TEST"):
86
+ self.color = False
87
+ elif os.getenv("CLICOLOR_FORCE") or os.getenv("FORCE_COLOR"):
88
+ self.color = True
89
+ elif os.getenv("CI"):
90
+ # Enable colors in CI/deployment environments even without TTY
91
+ # This matches behavior of modern tools like uv (via Rust's anstyle)
73
92
  self.color = True
93
+ elif os.getenv("CLICOLOR"):
94
+ # CLICOLOR=1 means use colors only if TTY (Click's default behavior)
95
+ pass # Let Click handle it with isatty check
96
+ # Otherwise use Click's default behavior (isatty check)