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.
- plain/README.md +33 -0
- plain/__main__.py +5 -0
- plain/assets/README.md +56 -0
- plain/assets/__init__.py +6 -0
- plain/assets/finders.py +233 -0
- plain/assets/preflight.py +14 -0
- plain/assets/storage.py +916 -0
- plain/assets/utils.py +52 -0
- plain/assets/whitenoise/__init__.py +5 -0
- plain/assets/whitenoise/base.py +259 -0
- plain/assets/whitenoise/compress.py +189 -0
- plain/assets/whitenoise/media_types.py +137 -0
- plain/assets/whitenoise/middleware.py +197 -0
- plain/assets/whitenoise/responders.py +286 -0
- plain/assets/whitenoise/storage.py +178 -0
- plain/assets/whitenoise/string_utils.py +13 -0
- plain/cli/README.md +123 -0
- plain/cli/__init__.py +3 -0
- plain/cli/cli.py +439 -0
- plain/cli/formatting.py +61 -0
- plain/cli/packages.py +73 -0
- plain/cli/print.py +9 -0
- plain/cli/startup.py +33 -0
- plain/csrf/README.md +3 -0
- plain/csrf/middleware.py +466 -0
- plain/csrf/views.py +10 -0
- plain/debug.py +23 -0
- plain/exceptions.py +242 -0
- plain/forms/README.md +14 -0
- plain/forms/__init__.py +8 -0
- plain/forms/boundfield.py +58 -0
- plain/forms/exceptions.py +11 -0
- plain/forms/fields.py +1030 -0
- plain/forms/forms.py +297 -0
- plain/http/README.md +1 -0
- plain/http/__init__.py +51 -0
- plain/http/cookie.py +20 -0
- plain/http/multipartparser.py +743 -0
- plain/http/request.py +754 -0
- plain/http/response.py +719 -0
- plain/internal/__init__.py +0 -0
- plain/internal/files/README.md +3 -0
- plain/internal/files/__init__.py +3 -0
- plain/internal/files/base.py +161 -0
- plain/internal/files/locks.py +127 -0
- plain/internal/files/move.py +102 -0
- plain/internal/files/temp.py +79 -0
- plain/internal/files/uploadedfile.py +150 -0
- plain/internal/files/uploadhandler.py +254 -0
- plain/internal/files/utils.py +78 -0
- plain/internal/handlers/__init__.py +0 -0
- plain/internal/handlers/base.py +133 -0
- plain/internal/handlers/exception.py +145 -0
- plain/internal/handlers/wsgi.py +216 -0
- plain/internal/legacy/__init__.py +0 -0
- plain/internal/legacy/__main__.py +12 -0
- plain/internal/legacy/management/__init__.py +414 -0
- plain/internal/legacy/management/base.py +692 -0
- plain/internal/legacy/management/color.py +113 -0
- plain/internal/legacy/management/commands/__init__.py +0 -0
- plain/internal/legacy/management/commands/collectstatic.py +297 -0
- plain/internal/legacy/management/sql.py +67 -0
- plain/internal/legacy/management/utils.py +175 -0
- plain/json.py +40 -0
- plain/logs/README.md +24 -0
- plain/logs/__init__.py +5 -0
- plain/logs/configure.py +39 -0
- plain/logs/loggers.py +74 -0
- plain/logs/utils.py +46 -0
- plain/middleware/README.md +3 -0
- plain/middleware/__init__.py +0 -0
- plain/middleware/clickjacking.py +52 -0
- plain/middleware/common.py +87 -0
- plain/middleware/gzip.py +64 -0
- plain/middleware/security.py +64 -0
- plain/packages/README.md +41 -0
- plain/packages/__init__.py +4 -0
- plain/packages/config.py +259 -0
- plain/packages/registry.py +438 -0
- plain/paginator.py +187 -0
- plain/preflight/README.md +3 -0
- plain/preflight/__init__.py +38 -0
- plain/preflight/compatibility/__init__.py +0 -0
- plain/preflight/compatibility/django_4_0.py +20 -0
- plain/preflight/files.py +19 -0
- plain/preflight/messages.py +88 -0
- plain/preflight/registry.py +72 -0
- plain/preflight/security/__init__.py +0 -0
- plain/preflight/security/base.py +268 -0
- plain/preflight/security/csrf.py +40 -0
- plain/preflight/urls.py +117 -0
- plain/runtime/README.md +75 -0
- plain/runtime/__init__.py +61 -0
- plain/runtime/global_settings.py +199 -0
- plain/runtime/user_settings.py +353 -0
- plain/signals/README.md +14 -0
- plain/signals/__init__.py +5 -0
- plain/signals/dispatch/__init__.py +9 -0
- plain/signals/dispatch/dispatcher.py +320 -0
- plain/signals/dispatch/license.txt +35 -0
- plain/signing.py +299 -0
- plain/templates/README.md +20 -0
- plain/templates/__init__.py +6 -0
- plain/templates/core.py +24 -0
- plain/templates/jinja/README.md +227 -0
- plain/templates/jinja/__init__.py +22 -0
- plain/templates/jinja/defaults.py +119 -0
- plain/templates/jinja/extensions.py +39 -0
- plain/templates/jinja/filters.py +28 -0
- plain/templates/jinja/globals.py +19 -0
- plain/test/README.md +3 -0
- plain/test/__init__.py +16 -0
- plain/test/client.py +985 -0
- plain/test/utils.py +255 -0
- plain/urls/README.md +3 -0
- plain/urls/__init__.py +40 -0
- plain/urls/base.py +118 -0
- plain/urls/conf.py +94 -0
- plain/urls/converters.py +66 -0
- plain/urls/exceptions.py +9 -0
- plain/urls/resolvers.py +731 -0
- plain/utils/README.md +3 -0
- plain/utils/__init__.py +0 -0
- plain/utils/_os.py +52 -0
- plain/utils/cache.py +327 -0
- plain/utils/connection.py +84 -0
- plain/utils/crypto.py +76 -0
- plain/utils/datastructures.py +345 -0
- plain/utils/dateformat.py +329 -0
- plain/utils/dateparse.py +154 -0
- plain/utils/dates.py +76 -0
- plain/utils/deconstruct.py +54 -0
- plain/utils/decorators.py +90 -0
- plain/utils/deprecation.py +6 -0
- plain/utils/duration.py +44 -0
- plain/utils/email.py +12 -0
- plain/utils/encoding.py +235 -0
- plain/utils/functional.py +456 -0
- plain/utils/hashable.py +26 -0
- plain/utils/html.py +401 -0
- plain/utils/http.py +374 -0
- plain/utils/inspect.py +73 -0
- plain/utils/ipv6.py +46 -0
- plain/utils/itercompat.py +8 -0
- plain/utils/module_loading.py +69 -0
- plain/utils/regex_helper.py +353 -0
- plain/utils/safestring.py +72 -0
- plain/utils/termcolors.py +221 -0
- plain/utils/text.py +518 -0
- plain/utils/timesince.py +138 -0
- plain/utils/timezone.py +244 -0
- plain/utils/tree.py +126 -0
- plain/validators.py +603 -0
- plain/views/README.md +268 -0
- plain/views/__init__.py +18 -0
- plain/views/base.py +107 -0
- plain/views/csrf.py +24 -0
- plain/views/errors.py +25 -0
- plain/views/exceptions.py +4 -0
- plain/views/forms.py +76 -0
- plain/views/objects.py +229 -0
- plain/views/redirect.py +72 -0
- plain/views/templates.py +66 -0
- plain/wsgi.py +11 -0
- plain-0.1.0.dist-info/LICENSE +85 -0
- plain-0.1.0.dist-info/METADATA +51 -0
- plain-0.1.0.dist-info/RECORD +169 -0
- plain-0.1.0.dist-info/WHEEL +4 -0
- 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
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()
|
plain/cli/formatting.py
ADDED
|
@@ -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()
|