plain 0.1.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 (169) hide show
  1. plain/README.md +33 -0
  2. plain/__main__.py +5 -0
  3. plain/assets/README.md +56 -0
  4. plain/assets/__init__.py +6 -0
  5. plain/assets/finders.py +233 -0
  6. plain/assets/preflight.py +14 -0
  7. plain/assets/storage.py +916 -0
  8. plain/assets/utils.py +52 -0
  9. plain/assets/whitenoise/__init__.py +5 -0
  10. plain/assets/whitenoise/base.py +259 -0
  11. plain/assets/whitenoise/compress.py +189 -0
  12. plain/assets/whitenoise/media_types.py +137 -0
  13. plain/assets/whitenoise/middleware.py +197 -0
  14. plain/assets/whitenoise/responders.py +286 -0
  15. plain/assets/whitenoise/storage.py +178 -0
  16. plain/assets/whitenoise/string_utils.py +13 -0
  17. plain/cli/README.md +123 -0
  18. plain/cli/__init__.py +3 -0
  19. plain/cli/cli.py +439 -0
  20. plain/cli/formatting.py +61 -0
  21. plain/cli/packages.py +73 -0
  22. plain/cli/print.py +9 -0
  23. plain/cli/startup.py +33 -0
  24. plain/csrf/README.md +3 -0
  25. plain/csrf/middleware.py +466 -0
  26. plain/csrf/views.py +10 -0
  27. plain/debug.py +23 -0
  28. plain/exceptions.py +242 -0
  29. plain/forms/README.md +14 -0
  30. plain/forms/__init__.py +8 -0
  31. plain/forms/boundfield.py +58 -0
  32. plain/forms/exceptions.py +11 -0
  33. plain/forms/fields.py +1030 -0
  34. plain/forms/forms.py +297 -0
  35. plain/http/README.md +1 -0
  36. plain/http/__init__.py +51 -0
  37. plain/http/cookie.py +20 -0
  38. plain/http/multipartparser.py +743 -0
  39. plain/http/request.py +754 -0
  40. plain/http/response.py +719 -0
  41. plain/internal/__init__.py +0 -0
  42. plain/internal/files/README.md +3 -0
  43. plain/internal/files/__init__.py +3 -0
  44. plain/internal/files/base.py +161 -0
  45. plain/internal/files/locks.py +127 -0
  46. plain/internal/files/move.py +102 -0
  47. plain/internal/files/temp.py +79 -0
  48. plain/internal/files/uploadedfile.py +150 -0
  49. plain/internal/files/uploadhandler.py +254 -0
  50. plain/internal/files/utils.py +78 -0
  51. plain/internal/handlers/__init__.py +0 -0
  52. plain/internal/handlers/base.py +133 -0
  53. plain/internal/handlers/exception.py +145 -0
  54. plain/internal/handlers/wsgi.py +216 -0
  55. plain/internal/legacy/__init__.py +0 -0
  56. plain/internal/legacy/__main__.py +12 -0
  57. plain/internal/legacy/management/__init__.py +414 -0
  58. plain/internal/legacy/management/base.py +692 -0
  59. plain/internal/legacy/management/color.py +113 -0
  60. plain/internal/legacy/management/commands/__init__.py +0 -0
  61. plain/internal/legacy/management/commands/collectstatic.py +297 -0
  62. plain/internal/legacy/management/sql.py +67 -0
  63. plain/internal/legacy/management/utils.py +175 -0
  64. plain/json.py +40 -0
  65. plain/logs/README.md +24 -0
  66. plain/logs/__init__.py +5 -0
  67. plain/logs/configure.py +39 -0
  68. plain/logs/loggers.py +74 -0
  69. plain/logs/utils.py +46 -0
  70. plain/middleware/README.md +3 -0
  71. plain/middleware/__init__.py +0 -0
  72. plain/middleware/clickjacking.py +52 -0
  73. plain/middleware/common.py +87 -0
  74. plain/middleware/gzip.py +64 -0
  75. plain/middleware/security.py +64 -0
  76. plain/packages/README.md +41 -0
  77. plain/packages/__init__.py +4 -0
  78. plain/packages/config.py +259 -0
  79. plain/packages/registry.py +438 -0
  80. plain/paginator.py +187 -0
  81. plain/preflight/README.md +3 -0
  82. plain/preflight/__init__.py +38 -0
  83. plain/preflight/compatibility/__init__.py +0 -0
  84. plain/preflight/compatibility/django_4_0.py +20 -0
  85. plain/preflight/files.py +19 -0
  86. plain/preflight/messages.py +88 -0
  87. plain/preflight/registry.py +72 -0
  88. plain/preflight/security/__init__.py +0 -0
  89. plain/preflight/security/base.py +268 -0
  90. plain/preflight/security/csrf.py +40 -0
  91. plain/preflight/urls.py +117 -0
  92. plain/runtime/README.md +75 -0
  93. plain/runtime/__init__.py +61 -0
  94. plain/runtime/global_settings.py +199 -0
  95. plain/runtime/user_settings.py +353 -0
  96. plain/signals/README.md +14 -0
  97. plain/signals/__init__.py +5 -0
  98. plain/signals/dispatch/__init__.py +9 -0
  99. plain/signals/dispatch/dispatcher.py +320 -0
  100. plain/signals/dispatch/license.txt +35 -0
  101. plain/signing.py +299 -0
  102. plain/templates/README.md +20 -0
  103. plain/templates/__init__.py +6 -0
  104. plain/templates/core.py +24 -0
  105. plain/templates/jinja/README.md +227 -0
  106. plain/templates/jinja/__init__.py +22 -0
  107. plain/templates/jinja/defaults.py +119 -0
  108. plain/templates/jinja/extensions.py +39 -0
  109. plain/templates/jinja/filters.py +28 -0
  110. plain/templates/jinja/globals.py +19 -0
  111. plain/test/README.md +3 -0
  112. plain/test/__init__.py +16 -0
  113. plain/test/client.py +985 -0
  114. plain/test/utils.py +255 -0
  115. plain/urls/README.md +3 -0
  116. plain/urls/__init__.py +40 -0
  117. plain/urls/base.py +118 -0
  118. plain/urls/conf.py +94 -0
  119. plain/urls/converters.py +66 -0
  120. plain/urls/exceptions.py +9 -0
  121. plain/urls/resolvers.py +731 -0
  122. plain/utils/README.md +3 -0
  123. plain/utils/__init__.py +0 -0
  124. plain/utils/_os.py +52 -0
  125. plain/utils/cache.py +327 -0
  126. plain/utils/connection.py +84 -0
  127. plain/utils/crypto.py +76 -0
  128. plain/utils/datastructures.py +345 -0
  129. plain/utils/dateformat.py +329 -0
  130. plain/utils/dateparse.py +154 -0
  131. plain/utils/dates.py +76 -0
  132. plain/utils/deconstruct.py +54 -0
  133. plain/utils/decorators.py +90 -0
  134. plain/utils/deprecation.py +6 -0
  135. plain/utils/duration.py +44 -0
  136. plain/utils/email.py +12 -0
  137. plain/utils/encoding.py +235 -0
  138. plain/utils/functional.py +456 -0
  139. plain/utils/hashable.py +26 -0
  140. plain/utils/html.py +401 -0
  141. plain/utils/http.py +374 -0
  142. plain/utils/inspect.py +73 -0
  143. plain/utils/ipv6.py +46 -0
  144. plain/utils/itercompat.py +8 -0
  145. plain/utils/module_loading.py +69 -0
  146. plain/utils/regex_helper.py +353 -0
  147. plain/utils/safestring.py +72 -0
  148. plain/utils/termcolors.py +221 -0
  149. plain/utils/text.py +518 -0
  150. plain/utils/timesince.py +138 -0
  151. plain/utils/timezone.py +244 -0
  152. plain/utils/tree.py +126 -0
  153. plain/validators.py +603 -0
  154. plain/views/README.md +268 -0
  155. plain/views/__init__.py +18 -0
  156. plain/views/base.py +107 -0
  157. plain/views/csrf.py +24 -0
  158. plain/views/errors.py +25 -0
  159. plain/views/exceptions.py +4 -0
  160. plain/views/forms.py +76 -0
  161. plain/views/objects.py +229 -0
  162. plain/views/redirect.py +72 -0
  163. plain/views/templates.py +66 -0
  164. plain/wsgi.py +11 -0
  165. plain-0.1.0.dist-info/LICENSE +85 -0
  166. plain-0.1.0.dist-info/METADATA +51 -0
  167. plain-0.1.0.dist-info/RECORD +169 -0
  168. plain-0.1.0.dist-info/WHEEL +4 -0
  169. plain-0.1.0.dist-info/entry_points.txt +3 -0
plain/cli/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # CLI
2
+
3
+ The `plain` CLI loads commands from Plain itself, and any `INSTALLED_PACKAGES`.
4
+
5
+ Commands are written using [Click]((https://click.palletsprojects.com/en/8.1.x/))
6
+ (one of Plain's few dependencies),
7
+ which has been one of those most popular CLI frameworks in Python for a long time now.
8
+
9
+ ## Built-in commands
10
+
11
+ ### `plain shell`
12
+
13
+ Open a Python shell with the Plain loaded.
14
+
15
+ To auto-load models or run other code at shell launch,
16
+ create an `app/shell.py` and it will be imported automatically.
17
+
18
+ ```python
19
+ # app/shell.py
20
+ from organizations.models import Organization
21
+
22
+ __all__ = [
23
+ "Organization",
24
+ ]
25
+ ```
26
+
27
+ ### `plain compile`
28
+
29
+ Compile static assets (used in the deploy/production process).
30
+
31
+ Automatically runs `plain tailwind compile` if [plain-tailwind](https://plainframework.com/docs/plain-tailwind/) is installed.
32
+
33
+ Automatically runs `npm run compile` if you have a `package.json` with `scripts.compile`.
34
+
35
+ ### `plain run`
36
+
37
+ Run a Python script in the context of your app.
38
+
39
+ ### `plain legacy`
40
+
41
+ A temporary holdover for running the old `manage.py` commands that haven't been converted yet.
42
+
43
+ ### `plain preflight`
44
+
45
+ Run preflight checks to ensure your app is ready to run.
46
+
47
+ ### `plain create`
48
+
49
+ Create a new local package.
50
+
51
+ ### `plain setting`
52
+
53
+ View the runtime value of a named setting.
54
+
55
+ ## Adding commands
56
+
57
+ ### Add an `app/cli.py`
58
+
59
+ You can add "root" commands to your app by defining a `cli` function in `app/cli.py`.
60
+
61
+ ```python
62
+ import click
63
+
64
+
65
+ @click.group()
66
+ def cli():
67
+ pass
68
+
69
+
70
+ @cli.command()
71
+ def custom_command():
72
+ click.echo("An app command!")
73
+ ```
74
+
75
+ Then you can run the command with `plain`.
76
+
77
+ ```bash
78
+ $ plain custom-command
79
+ An app command!
80
+ ```
81
+
82
+ ### Add CLI commands to your local packages
83
+
84
+ Any package in `INSTALLED_PACKAGES` can define CLI commands by creating a `cli.py` in the root of the package.
85
+ In `cli.py`, create a command or group of commands named `cli`.
86
+
87
+ ```python
88
+ import click
89
+
90
+
91
+ @click.group()
92
+ def cli():
93
+ pass
94
+
95
+
96
+ @cli.command()
97
+ def hello():
98
+ click.echo("Hello, world!")
99
+ ```
100
+
101
+ Plain will use the name of the package in the CLI,
102
+ then any commands you defined.
103
+
104
+ ```bash
105
+ $ plain <pkg> hello
106
+ Hello, world!
107
+ ```
108
+
109
+ ### Add CLI commands to published packages
110
+
111
+ Some packages, like [plain-dev](https://plainframework.com/docs/plain-dev/),
112
+ never show up in `INSTALLED_PACKAGES` but still have CLI commands.
113
+ These are detected via Python entry points.
114
+
115
+ An example with `pyproject.toml` and Poetry:
116
+
117
+ ```toml
118
+ # pyproject.toml
119
+ [tool.poetry.plugins."plain.cli"]
120
+ "dev" = "plain.dev:cli"
121
+ "pre-commit" = "plain.dev.precommit:cli"
122
+ "contrib" = "plain.dev.contribute:cli"
123
+ ```
plain/cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ __all__ = ["cli"]
plain/cli/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ import importlib
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from importlib.util import find_spec
7
+ from pathlib import Path
8
+
9
+ import click
10
+ from click.core import Command, Context
11
+
12
+ import plain.runtime
13
+ from plain import preflight
14
+ from plain.packages import packages
15
+
16
+ from .formatting import PlainContext
17
+ from .packages import EntryPointGroup, InstalledPackagesGroup
18
+
19
+
20
+ @click.group()
21
+ def plain_cli():
22
+ pass
23
+
24
+
25
+ @plain_cli.command(
26
+ "legacy",
27
+ context_settings={
28
+ "ignore_unknown_options": True,
29
+ },
30
+ )
31
+ @click.argument("legacy_args", nargs=-1, type=click.UNPROCESSED)
32
+ def legacy_alias(legacy_args):
33
+ result = subprocess.run(
34
+ [
35
+ "python",
36
+ "-m",
37
+ "plain.internal.legacy",
38
+ *legacy_args,
39
+ ],
40
+ )
41
+ if result.returncode:
42
+ sys.exit(result.returncode)
43
+
44
+
45
+ # @plain_cli.command
46
+ # def docs():
47
+ # """Open the Forge documentation in your browser"""
48
+ # subprocess.run(["open", "https://www.forgepackages.com/docs/?ref=cli"])
49
+
50
+
51
+ @plain_cli.command()
52
+ @click.option(
53
+ "-i",
54
+ "--interface",
55
+ type=click.Choice(["ipython", "bpython", "python"]),
56
+ help="Specify an interactive interpreter interface.",
57
+ )
58
+ def shell(interface):
59
+ """
60
+ Runs a Python interactive interpreter. Tries to use IPython or
61
+ bpython, if one of them is available.
62
+ """
63
+
64
+ if interface:
65
+ interface = [interface]
66
+ else:
67
+
68
+ def get_default_interface():
69
+ try:
70
+ import IPython # noqa
71
+
72
+ return ["python", "-m", "IPython"]
73
+ except ImportError:
74
+ pass
75
+
76
+ return ["python"]
77
+
78
+ interface = get_default_interface()
79
+
80
+ result = subprocess.run(
81
+ interface,
82
+ env={
83
+ "PYTHONSTARTUP": os.path.join(os.path.dirname(__file__), "startup.py"),
84
+ **os.environ,
85
+ },
86
+ )
87
+ if result.returncode:
88
+ sys.exit(result.returncode)
89
+
90
+
91
+ @plain_cli.command()
92
+ @click.argument("script", nargs=1, type=click.Path(exists=True))
93
+ def run(script):
94
+ """Run a Python script in the context of your app"""
95
+ before_script = "import plain.runtime; plain.runtime.setup()"
96
+ command = f"{before_script}; exec(open('{script}').read())"
97
+ result = subprocess.run(["python", "-c", command])
98
+ if result.returncode:
99
+ sys.exit(result.returncode)
100
+
101
+
102
+ # @plain_cli.command()
103
+ # @click.option("--filter", "-f", "name_filter", help="Filter settings by name")
104
+ # @click.option("--overridden", is_flag=True, help="Only show overridden settings")
105
+ # def settings(name_filter, overridden):
106
+ # """Print Plain settings"""
107
+ # table = Table(box=box.MINIMAL)
108
+ # table.add_column("Setting")
109
+ # table.add_column("Default value")
110
+ # table.add_column("App value")
111
+ # table.add_column("Type")
112
+ # table.add_column("Module")
113
+
114
+ # for setting in dir(settings):
115
+ # if setting.isupper():
116
+ # if name_filter and name_filter.upper() not in setting:
117
+ # continue
118
+
119
+ # is_overridden = settings.is_overridden(setting)
120
+
121
+ # if overridden and not is_overridden:
122
+ # continue
123
+
124
+ # default_setting = settings._default_settings.get(setting)
125
+ # if default_setting:
126
+ # default_value = default_setting.value
127
+ # annotation = default_setting.annotation
128
+ # module = default_setting.module
129
+ # else:
130
+ # default_value = ""
131
+ # annotation = ""
132
+ # module = ""
133
+
134
+ # table.add_row(
135
+ # setting,
136
+ # Pretty(default_value) if default_value else "",
137
+ # Pretty(getattr(settings, setting))
138
+ # if is_overridden
139
+ # else Text("<Default>", style="italic dim"),
140
+ # Pretty(annotation) if annotation else "",
141
+ # str(module.__name__) if module else "",
142
+ # )
143
+
144
+ # console = Console()
145
+ # console.print(table)
146
+
147
+
148
+ @plain_cli.command("preflight")
149
+ @click.argument("package_label", nargs=-1)
150
+ @click.option(
151
+ "--deploy",
152
+ is_flag=True,
153
+ help="Check deployment settings.",
154
+ )
155
+ @click.option(
156
+ "--fail-level",
157
+ default="ERROR",
158
+ type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]),
159
+ help="Message level that will cause the command to exit with a non-zero status. Default is ERROR.",
160
+ )
161
+ @click.option(
162
+ "--database",
163
+ "databases",
164
+ multiple=True,
165
+ help="Run database related checks against these aliases.",
166
+ )
167
+ def preflight_checks(package_label, deploy, fail_level, databases):
168
+ """
169
+ Use the system check framework to validate entire Plain project.
170
+ Raise CommandError for any serious message (error or critical errors).
171
+ If there are only light messages (like warnings), print them to stderr
172
+ and don't raise an exception.
173
+ """
174
+ include_deployment_checks = deploy
175
+
176
+ if package_label:
177
+ package_configs = [
178
+ packages.get_package_config(label) for label in package_label
179
+ ]
180
+ else:
181
+ package_configs = None
182
+
183
+ all_issues = preflight.run_checks(
184
+ package_configs=package_configs,
185
+ include_deployment_checks=include_deployment_checks,
186
+ databases=databases,
187
+ )
188
+
189
+ header, body, footer = "", "", ""
190
+ visible_issue_count = 0 # excludes silenced warnings
191
+
192
+ if all_issues:
193
+ debugs = [
194
+ e for e in all_issues if e.level < preflight.INFO and not e.is_silenced()
195
+ ]
196
+ infos = [
197
+ e
198
+ for e in all_issues
199
+ if preflight.INFO <= e.level < preflight.WARNING and not e.is_silenced()
200
+ ]
201
+ warnings = [
202
+ e
203
+ for e in all_issues
204
+ if preflight.WARNING <= e.level < preflight.ERROR and not e.is_silenced()
205
+ ]
206
+ errors = [
207
+ e
208
+ for e in all_issues
209
+ if preflight.ERROR <= e.level < preflight.CRITICAL and not e.is_silenced()
210
+ ]
211
+ criticals = [
212
+ e
213
+ for e in all_issues
214
+ if preflight.CRITICAL <= e.level and not e.is_silenced()
215
+ ]
216
+ sorted_issues = [
217
+ (criticals, "CRITICALS"),
218
+ (errors, "ERRORS"),
219
+ (warnings, "WARNINGS"),
220
+ (infos, "INFOS"),
221
+ (debugs, "DEBUGS"),
222
+ ]
223
+
224
+ for issues, group_name in sorted_issues:
225
+ if issues:
226
+ visible_issue_count += len(issues)
227
+ formatted = (
228
+ click.style(str(e), fg="red")
229
+ if e.is_serious()
230
+ else click.style(str(e), fg="yellow")
231
+ for e in issues
232
+ )
233
+ formatted = "\n".join(sorted(formatted))
234
+ body += f"\n{group_name}:\n{formatted}\n"
235
+
236
+ if visible_issue_count:
237
+ header = "Preflight check identified some issues:\n"
238
+
239
+ if any(
240
+ e.is_serious(getattr(preflight, fail_level)) and not e.is_silenced()
241
+ for e in all_issues
242
+ ):
243
+ footer += "\n"
244
+ footer += "Preflight check identified {} ({} silenced).".format(
245
+ "no issues"
246
+ if visible_issue_count == 0
247
+ else "1 issue"
248
+ if visible_issue_count == 1
249
+ else "%s issues" % visible_issue_count,
250
+ len(all_issues) - visible_issue_count,
251
+ )
252
+ msg = click.style("SystemCheckError: %s" % header, fg="red") + body + footer
253
+ raise click.ClickException(msg)
254
+ else:
255
+ if visible_issue_count:
256
+ footer += "\n"
257
+ footer += "Preflight check identified {} ({} silenced).".format(
258
+ "no issues"
259
+ if visible_issue_count == 0
260
+ else "1 issue"
261
+ if visible_issue_count == 1
262
+ else "%s issues" % visible_issue_count,
263
+ len(all_issues) - visible_issue_count,
264
+ )
265
+ msg = header + body + footer
266
+ click.echo(msg, err=True)
267
+ else:
268
+ click.echo("Preflight check identified no issues.", err=True)
269
+
270
+
271
+ @plain_cli.command()
272
+ @click.pass_context
273
+ def compile(ctx):
274
+ """Compile static assets"""
275
+
276
+ # TODO preflight for assets only?
277
+
278
+ # TODO make this an entrypoint instead
279
+ # Compile our Tailwind CSS (including templates in plain itself)
280
+ if find_spec("plain.tailwind") is not None:
281
+ result = subprocess.run(["plain", "tailwind", "compile", "--minify"])
282
+ if result.returncode:
283
+ click.secho(
284
+ f"Error compiling Tailwind CSS (exit {result.returncode})", fg="red"
285
+ )
286
+ sys.exit(result.returncode)
287
+
288
+ # TODO also look in [tool.plain.compile.run]
289
+
290
+ # Run a "compile" script from package.json automatically
291
+ package_json = Path("package.json")
292
+ if package_json.exists():
293
+ with package_json.open() as f:
294
+ package = json.load(f)
295
+
296
+ if package.get("scripts", {}).get("compile"):
297
+ result = subprocess.run(["npm", "run", "compile"])
298
+ if result.returncode:
299
+ click.secho(
300
+ f"Error in `npm run compile` (exit {result.returncode})", fg="red"
301
+ )
302
+ sys.exit(result.returncode)
303
+
304
+ # Run the regular collectstatic
305
+ ctx.invoke(legacy_alias, legacy_args=["collectstatic", "--noinput"])
306
+
307
+
308
+ @plain_cli.command()
309
+ @click.argument("package_name")
310
+ def create(package_name):
311
+ """Create a new local package"""
312
+ package_dir = plain.runtime.APP_PATH / package_name
313
+ package_dir.mkdir(exist_ok=True)
314
+
315
+ empty_dirs = (
316
+ f"templates/{package_name}",
317
+ "migrations",
318
+ )
319
+ for d in empty_dirs:
320
+ (package_dir / d).mkdir(parents=True, exist_ok=True)
321
+
322
+ empty_files = (
323
+ "__init__.py",
324
+ "migrations/__init__.py",
325
+ "models.py",
326
+ "views.py",
327
+ )
328
+ for f in empty_files:
329
+ (package_dir / f).touch(exist_ok=True)
330
+
331
+ # Create a urls.py file with a default namespace
332
+ if not (package_dir / "urls.py").exists():
333
+ (package_dir / "urls.py").write_text(
334
+ f"""from plain.urls import path
335
+
336
+ default_namespace = f"{package_name}"
337
+
338
+ urlpatterns = [
339
+ # path("", views.IndexView, name="index"),
340
+ ]
341
+ """
342
+ )
343
+
344
+ click.secho(
345
+ f'Created {package_dir.relative_to(Path.cwd())}. Make sure to add "{package_name}" to INSTALLED_PACKAGES!',
346
+ fg="green",
347
+ )
348
+
349
+
350
+ @plain_cli.command()
351
+ @click.argument("setting_name")
352
+ def setting(setting_name):
353
+ """Print the value of a setting at runtime"""
354
+ try:
355
+ setting = getattr(plain.runtime.settings, setting_name)
356
+ click.echo(setting)
357
+ except AttributeError:
358
+ click.secho(f'Setting "{setting_name}" not found', fg="red")
359
+
360
+
361
+ class AppCLIGroup(click.Group):
362
+ """
363
+ Loads app.cli if it exists as `plain app`
364
+ """
365
+
366
+ MODULE_NAME = "app.cli"
367
+
368
+ def list_commands(self, ctx):
369
+ try:
370
+ find_spec(self.MODULE_NAME)
371
+ return ["app"]
372
+ except ModuleNotFoundError:
373
+ return []
374
+
375
+ def get_command(self, ctx, name):
376
+ if name != "app":
377
+ return
378
+
379
+ try:
380
+ cli = importlib.import_module(self.MODULE_NAME)
381
+ return cli.cli
382
+ except ModuleNotFoundError:
383
+ return
384
+
385
+
386
+ class PlainCommandCollection(click.CommandCollection):
387
+ context_class = PlainContext
388
+
389
+ def __init__(self, *args, **kwargs):
390
+ sources = []
391
+
392
+ try:
393
+ # Setup has to run before the installed packages CLI work
394
+ # and it also does the .env file loading right now...
395
+ plain.runtime.setup()
396
+
397
+ sources = [
398
+ InstalledPackagesGroup(),
399
+ EntryPointGroup(),
400
+ AppCLIGroup(),
401
+ plain_cli,
402
+ ]
403
+ except plain.runtime.AppPathNotFound:
404
+ click.secho(
405
+ "Plain `app` directory not found. Some commands may be missing.",
406
+ fg="yellow",
407
+ err=True,
408
+ )
409
+
410
+ sources = [
411
+ EntryPointGroup(),
412
+ plain_cli,
413
+ ]
414
+ except Exception as e:
415
+ click.secho(
416
+ f"Error setting up Plain CLI\n{e}",
417
+ fg="red",
418
+ err=True,
419
+ )
420
+
421
+ sources = [
422
+ EntryPointGroup(),
423
+ AppCLIGroup(),
424
+ plain_cli,
425
+ ]
426
+
427
+ super().__init__(*args, **kwargs)
428
+
429
+ self.sources = sources
430
+
431
+ def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
432
+ cmd = super().get_command(ctx, cmd_name)
433
+ if cmd:
434
+ # Pass the formatting down to subcommands automatically
435
+ cmd.context_class = self.context_class
436
+ return cmd
437
+
438
+
439
+ cli = PlainCommandCollection()
@@ -0,0 +1,61 @@
1
+ import click
2
+ from click.formatting import iter_rows, measure_table, term_len, wrap_text
3
+
4
+
5
+ class PlainHelpFormatter(click.HelpFormatter):
6
+ def write_heading(self, heading):
7
+ styled_heading = click.style(heading, underline=True)
8
+ self.write(f"{'':>{self.current_indent}}{styled_heading}\n")
9
+
10
+ def write_usage(self, prog, args, prefix="Usage: "):
11
+ prefix_styled = click.style(prefix, italic=True)
12
+ super().write_usage(prog, args, prefix=prefix_styled)
13
+
14
+ def write_dl(
15
+ self,
16
+ rows,
17
+ col_max=30,
18
+ col_spacing=2,
19
+ ):
20
+ """Writes a definition list into the buffer. This is how options
21
+ and commands are usually formatted.
22
+
23
+ :param rows: a list of two item tuples for the terms and values.
24
+ :param col_max: the maximum width of the first column.
25
+ :param col_spacing: the number of spaces between the first and
26
+ second column.
27
+ """
28
+ rows = list(rows)
29
+ widths = measure_table(rows)
30
+ if len(widths) != 2:
31
+ raise TypeError("Expected two columns for definition list")
32
+
33
+ first_col = min(widths[0], col_max) + col_spacing
34
+
35
+ for first, second in iter_rows(rows, len(widths)):
36
+ first_styled = click.style(first, bold=True)
37
+ self.write(f"{'':>{self.current_indent}}{first_styled}")
38
+ if not second:
39
+ self.write("\n")
40
+ continue
41
+ if term_len(first) <= first_col - col_spacing:
42
+ self.write(" " * (first_col - term_len(first)))
43
+ else:
44
+ self.write("\n")
45
+ self.write(" " * (first_col + self.current_indent))
46
+
47
+ text_width = max(self.width - first_col - 2, 10)
48
+ wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True)
49
+ lines = wrapped_text.splitlines()
50
+
51
+ if lines:
52
+ self.write(f"{lines[0]}\n")
53
+
54
+ for line in lines[1:]:
55
+ self.write(f"{'':>{first_col + self.current_indent}}{line}\n")
56
+ else:
57
+ self.write("\n")
58
+
59
+
60
+ class PlainContext(click.Context):
61
+ formatter_class = PlainHelpFormatter
plain/cli/packages.py ADDED
@@ -0,0 +1,73 @@
1
+ import importlib
2
+ from importlib.metadata import entry_points
3
+ from importlib.util import find_spec
4
+
5
+ import click
6
+
7
+ from plain.packages import packages
8
+
9
+
10
+ class InstalledPackagesGroup(click.Group):
11
+ """
12
+ Packages in INSTALLED_PACKAGES with a cli.py module
13
+ will be discovered automatically.
14
+ """
15
+
16
+ PLAIN_APPS_PREFIX = "plain."
17
+ MODULE_NAME = "cli"
18
+
19
+ def list_commands(self, ctx):
20
+ packages_with_commands = []
21
+
22
+ # Get installed packages with a cli.py module
23
+ for app in packages.get_package_configs():
24
+ if not find_spec(f"{app.name}.{self.MODULE_NAME}"):
25
+ continue
26
+
27
+ cli_name = app.name
28
+
29
+ if cli_name.startswith(self.PLAIN_APPS_PREFIX):
30
+ cli_name = cli_name[len(self.PLAIN_APPS_PREFIX) :]
31
+
32
+ packages_with_commands.append(cli_name)
33
+
34
+ return packages_with_commands
35
+
36
+ def get_command(self, ctx, name):
37
+ # Try it as plain.x and just x (we don't know ahead of time which it is, but prefer plain.x)
38
+ for n in [self.PLAIN_APPS_PREFIX + name, name]:
39
+ try:
40
+ cli = importlib.import_module(f"{n}.{self.MODULE_NAME}")
41
+ except ModuleNotFoundError:
42
+ continue
43
+
44
+ # Get the app's cli.py group
45
+ try:
46
+ return cli.cli
47
+ except AttributeError:
48
+ continue
49
+
50
+
51
+ class EntryPointGroup(click.Group):
52
+ """
53
+ Python packages can be added to the Plain CLI
54
+ via the plain_cli entrypoint in their setup.py.
55
+
56
+ This is intended for packages that don't go in INSTALLED_PACKAGES.
57
+ """
58
+
59
+ ENTRYPOINT_NAME = "plain.cli"
60
+
61
+ def list_commands(self, ctx):
62
+ rv = []
63
+
64
+ for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
65
+ rv.append(entry_point.name)
66
+
67
+ rv.sort()
68
+ return rv
69
+
70
+ def get_command(self, ctx, name):
71
+ for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
72
+ if entry_point.name == name:
73
+ return entry_point.load()
plain/cli/print.py ADDED
@@ -0,0 +1,9 @@
1
+ import click
2
+
3
+
4
+ def print_event(msg, newline=True):
5
+ arrow = click.style("-->", fg=214, bold=True)
6
+ message = str(msg)
7
+ if not newline:
8
+ message += " "
9
+ click.secho(f"{arrow} {message}", nl=newline)