plain 0.32.0__py3-none-any.whl → 0.33.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 +65 -0
- plain/assets/README.md +20 -20
- plain/assets/compile.py +8 -11
- plain/assets/finders.py +9 -0
- plain/assets/fingerprints.py +20 -0
- plain/assets/urls.py +9 -0
- plain/assets/views.py +7 -3
- plain/cli/README.md +34 -51
- plain/cli/core.py +203 -4
- plain/csrf/README.md +11 -8
- plain/forms/README.md +67 -9
- plain/http/README.md +24 -1
- plain/internal/__init__.py +7 -0
- plain/logs/README.md +38 -8
- plain/packages/README.md +50 -16
- plain/preflight/README.md +51 -3
- plain/preflight/__init__.py +2 -2
- plain/preflight/files.py +2 -2
- plain/preflight/messages.py +1 -1
- plain/preflight/registry.py +3 -3
- plain/preflight/security.py +9 -13
- plain/preflight/urls.py +2 -49
- plain/runtime/README.md +133 -45
- plain/runtime/global_settings.py +2 -2
- plain/signals/README.md +1 -1
- plain/templates/README.md +69 -9
- plain/test/README.md +37 -2
- plain/test/__init__.py +1 -3
- plain/test/client.py +104 -247
- plain/test/encoding.py +98 -0
- plain/test/exceptions.py +7 -0
- plain/urls/README.md +149 -2
- plain/urls/patterns.py +2 -0
- plain/utils/README.md +3 -1
- plain/views/README.md +2 -2
- plain/views/base.py +7 -9
- plain/views/forms.py +1 -1
- plain-0.33.0.dist-info/METADATA +77 -0
- {plain-0.32.0.dist-info → plain-0.33.0.dist-info}/RECORD +42 -42
- plain/internal/files/README.md +0 -3
- plain/templates/jinja/README.md +0 -213
- plain-0.32.0.dist-info/METADATA +0 -12
- {plain-0.32.0.dist-info → plain-0.33.0.dist-info}/WHEEL +0 -0
- {plain-0.32.0.dist-info → plain-0.33.0.dist-info}/entry_points.txt +0 -0
- {plain-0.32.0.dist-info → plain-0.33.0.dist-info}/licenses/LICENSE +0 -0
plain/README.md
CHANGED
@@ -1 +1,66 @@
|
|
1
1
|
# Plain
|
2
|
+
|
3
|
+
**Plain is a web framework for building products with Python.**
|
4
|
+
|
5
|
+
The core `plain` package provides the backbone of a Python web application (similar to [Flask](https://flask.palletsprojects.com/en/stable/)), while the additional first-party packages can power a more fully-featured database-backed app (similar to [Django](https://www.djangoproject.com/)).
|
6
|
+
|
7
|
+
All Plain packages are designed to work together and use [PEP 420](https://peps.python.org/pep-0420/) to share the `plain` namespace.
|
8
|
+
|
9
|
+
To quickly get started with Plain, visit [plainframework.com/start/](https://plainframework.com/start/).
|
10
|
+
|
11
|
+
## Core Modules
|
12
|
+
|
13
|
+
The `plain` package includes everything you need to start handling web requests with Python:
|
14
|
+
|
15
|
+
- [assets](assets/README.md) - Serve static files and assets.
|
16
|
+
- [cli](cli/README.md) - The `plain` CLI, powered by Click.
|
17
|
+
- [csrf](csrf/README.md) - Cross-Site Request Forgery protection.
|
18
|
+
- [forms](forms/README.md) - HTML forms and form validation.
|
19
|
+
- [http](http/README.md) - HTTP request and response handling.
|
20
|
+
- [logs](logs/README.md) - Logging configuration and utilities.
|
21
|
+
- [preflight](preflight/README.md) - Preflight checks for your app.
|
22
|
+
- [runtime](runtime/README.md) - Runtime settings and configuration.
|
23
|
+
- [templates](templates/README.md) - Jinja2 templates and rendering.
|
24
|
+
- [test](test/README.md) - Test utilities and fixtures.
|
25
|
+
- [urls](urls/README.md) - URL routing and request dispatching.
|
26
|
+
- [views](views/README.md) - Class-based views and request handlers.
|
27
|
+
|
28
|
+
## Foundational Packages
|
29
|
+
|
30
|
+
- [plain.models](/plain-models/README.md) - Define and interact with your database models.
|
31
|
+
- [plain.cache](/plain-cache/README.md) - A database-driven general purpose cache.
|
32
|
+
- [plain.mail](/plain-mail/README.md) - Send emails with SMTP or custom backends.
|
33
|
+
- [plain.sessions](/plain-sessions/README.md) - User sessions and cookies.
|
34
|
+
- [plain.worker](/plain-worker/README.md) - Backgrounb jobs stored in the database.
|
35
|
+
- [plain.api](/plain-api/README.md) - Build APIs with Plain views.
|
36
|
+
|
37
|
+
## Auth Packages
|
38
|
+
|
39
|
+
- [plain.auth](/plain-auth/README.md) - User authentication and authorization.
|
40
|
+
- [plain.oauth](/plain-oauth/README.md) - OAuth authentication and API access.
|
41
|
+
- [plain.passwords](/plain-passwords/README.md) - Password-based login and registration.
|
42
|
+
- [plain.loginlink](/plain-loginlink/README.md) - Login links for passwordless authentication.
|
43
|
+
|
44
|
+
## Admin Packages
|
45
|
+
|
46
|
+
- [plain.admin](/plain-admin/README.md) - An admin interface for back-office tasks.
|
47
|
+
- [plain.flags](/plain-flags/README.md) - Feature flags.
|
48
|
+
- [plain.support](/plain-support/README.md) - Customer support forms.
|
49
|
+
- [plain.redirection](/plain-redirection/README.md) - Redirects managed in the database.
|
50
|
+
- [plain.pageviews](/plain-pageviews/README.md) - Basic self-hosted page view tracking and reporting.
|
51
|
+
|
52
|
+
## Dev Packages
|
53
|
+
|
54
|
+
- [plain.dev](/plain-dev/README.md) - A single command for local development.
|
55
|
+
- [plain.pytest](/plain-pytest/README.md) - Pytest fixtures and helpers.
|
56
|
+
- [plain.code](/plain-code/README.md) - Code formatting and linting.
|
57
|
+
- [plain.tunnel](/plain-tunnel/README.md) - Expose your local server to the internet.
|
58
|
+
|
59
|
+
## Frontend Packages
|
60
|
+
|
61
|
+
- [plain.tailwind](/plain-tailwind/README.md) - Tailwind CSS integration without Node.js.
|
62
|
+
- [plain.htmx](/plain-htmx/README.md) - HTMX integrated into views and templates.
|
63
|
+
- [plain.elements](/plain-elements/README.md) - Server-side HTML components.
|
64
|
+
- [plain.pages](/plain-pages/README.md) - Static pages with Markdown and Jinja2.
|
65
|
+
- [plain.esbuild](/plain-esbuild/README.md) - Simple JavaScript bundling and minification.
|
66
|
+
- [plain.vendor](/plain-vendor/README.md) - Vendor JavaScript and CSS libraries.
|
plain/assets/README.md
CHANGED
@@ -1,38 +1,37 @@
|
|
1
1
|
# Assets
|
2
2
|
|
3
|
-
Serve static assets (CSS, JS, images, etc.) directly from
|
4
|
-
|
3
|
+
**Serve static assets (CSS, JS, images, etc.) directly or from a CDN.**
|
5
4
|
|
6
5
|
## Usage
|
7
6
|
|
8
7
|
To serve assets, put them in `app/assets` or `app/{package}/assets`.
|
9
8
|
|
10
|
-
Then include the `
|
9
|
+
Then include the `AssetsRouter` in your own router, typically under the `assets/` path.
|
11
10
|
|
12
11
|
```python
|
13
12
|
# app/urls.py
|
14
|
-
from plain.urls import
|
15
|
-
|
13
|
+
from plain.assets.urls import AssetsRouter
|
14
|
+
from plain.urls import include, Router
|
16
15
|
|
17
16
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
17
|
+
class AppRouter(Router):
|
18
|
+
namespace = ""
|
19
|
+
urls = [
|
20
|
+
include("assets/", AssetsRouter),
|
21
|
+
# your other routes here...
|
22
|
+
]
|
22
23
|
```
|
23
24
|
|
24
|
-
Now in your template you can use the `asset()` function to get the URL
|
25
|
+
Now in your template you can use the `asset()` function to get the URL, which will output the fully compiled and fingerprinted URL.
|
25
26
|
|
26
27
|
```html
|
27
28
|
<link rel="stylesheet" href="{{ asset('css/style.css') }}">
|
28
29
|
```
|
29
30
|
|
30
|
-
|
31
31
|
## Local development
|
32
32
|
|
33
33
|
When you're working with `settings.DEBUG = True`, the assets will be served directly from their original location. You don't need to run `plain build` or configure anything else.
|
34
34
|
|
35
|
-
|
36
35
|
## Production deployment
|
37
36
|
|
38
37
|
In production, one of your deployment steps should be to compile the assets.
|
@@ -41,11 +40,10 @@ In production, one of your deployment steps should be to compile the assets.
|
|
41
40
|
plain build
|
42
41
|
```
|
43
42
|
|
44
|
-
By default, this generates "fingerprinted" and compressed versions of the assets, which are then served by your app. This means that a file like `main.css` will result in two new files, like `main.d0db67b.css` and `main.d0db67b.css.gz`.
|
43
|
+
By default, this [generates "fingerprinted" and compressed versions of the assets](fingerprints.py#get_file_fingerprint), which are then served by your app. This means that a file like `main.css` will result in two new files, like `main.d0db67b.css` and `main.d0db67b.css.gz`.
|
45
44
|
|
46
45
|
The purpose of fingerprinting the assets is to allow the browser to cache them indefinitely. When the content of the file changes, the fingerprint will change, and the browser will use the newer file. This cuts down on the number of requests that your app has to handle related to assets.
|
47
46
|
|
48
|
-
|
49
47
|
## FAQs
|
50
48
|
|
51
49
|
### How do you reference assets in Python code?
|
@@ -67,10 +65,16 @@ mv .plain/assets/compiled /path/to/your/static
|
|
67
65
|
|
68
66
|
### How do I upload the assets to a CDN?
|
69
67
|
|
70
|
-
The steps for this will vary, but the general idea is to compile them, and then upload the compiled assets.
|
68
|
+
The steps for this will vary, but the general idea is to compile them, and then upload the compiled assets from their [compiled location](compile.py#get_compiled_path).
|
71
69
|
|
72
70
|
```bash
|
71
|
+
# Compile the assets
|
73
72
|
plain build
|
73
|
+
|
74
|
+
# List the newly compiled files
|
75
|
+
ls .plain/assets/compiled
|
76
|
+
|
77
|
+
# Upload the files to your CDN
|
74
78
|
./example-upload-to-cdn-script
|
75
79
|
```
|
76
80
|
|
@@ -81,14 +85,10 @@ Use the `ASSETS_BASE_URL` setting to tell the `{{ asset() }}` template function
|
|
81
85
|
ASSETS_BASE_URL = "https://cdn.example.com/"
|
82
86
|
```
|
83
87
|
|
84
|
-
|
85
88
|
### Why aren't the originals copied to the compiled directory?
|
86
89
|
|
87
90
|
The default behavior is to fingerprint assets, which is an exact copy of the original file but with a different filename. The originals aren't copied over because you should generally always use this fingerprinted path (that automatically uses longer-lived caching).
|
88
91
|
|
89
92
|
If you need the originals for any reason, you can use `plain build --keep-original`, though this will typically be combined with `--no-fingerprint` otherwise the fingerprinted files will still get priority in `{{ asset() }}` template calls.
|
90
93
|
|
91
|
-
|
92
|
-
### What about source maps or imported css files?
|
93
|
-
|
94
|
-
TODO
|
94
|
+
Note that by default, the `ASSETS_REDIRECT_ORIGINAL` setting is `True`, which will redirect requests for the original file to the fingerprinted file.
|
plain/assets/compile.py
CHANGED
@@ -1,14 +1,11 @@
|
|
1
1
|
import gzip
|
2
|
-
import hashlib
|
3
2
|
import os
|
4
3
|
import shutil
|
5
4
|
|
6
5
|
from plain.runtime import settings
|
7
6
|
|
8
7
|
from .finders import iter_assets
|
9
|
-
from .fingerprints import AssetsFingerprintsManifest
|
10
|
-
|
11
|
-
FINGERPRINT_LENGTH = 7
|
8
|
+
from .fingerprints import AssetsFingerprintsManifest, get_file_fingerprint
|
12
9
|
|
13
10
|
SKIP_COMPRESS_EXTENSIONS = (
|
14
11
|
# Images
|
@@ -46,12 +43,17 @@ SKIP_COMPRESS_EXTENSIONS = (
|
|
46
43
|
def get_compiled_path():
|
47
44
|
"""
|
48
45
|
Get the path at runtime to the compiled assets directory.
|
46
|
+
|
49
47
|
There's no reason currently for this to be a user-facing setting.
|
50
48
|
"""
|
51
49
|
return settings.PLAIN_TEMP_PATH / "assets" / "compiled"
|
52
50
|
|
53
51
|
|
54
52
|
def compile_assets(*, target_dir, keep_original, fingerprint, compress):
|
53
|
+
"""
|
54
|
+
Compile all assets to the target directory and save a JSON manifest
|
55
|
+
mapping the original filenames to the compiled filenames.
|
56
|
+
"""
|
55
57
|
manifest = AssetsFingerprintsManifest()
|
56
58
|
|
57
59
|
for asset in iter_assets():
|
@@ -68,8 +70,7 @@ def compile_assets(*, target_dir, keep_original, fingerprint, compress):
|
|
68
70
|
|
69
71
|
yield url_path, resolved_path, compiled_paths
|
70
72
|
|
71
|
-
|
72
|
-
manifest.save()
|
73
|
+
manifest.save()
|
73
74
|
|
74
75
|
|
75
76
|
def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
|
@@ -98,11 +99,7 @@ def compile_asset(*, asset, target_dir, keep_original, fingerprint, compress):
|
|
98
99
|
# Fingerprint it with an md5 hash
|
99
100
|
# (maybe need a setting with fnmatch patterns for files to NOT fingerprint?
|
100
101
|
# that would allow pre-fingerprinted files to be used as-is, and keep source maps etc in tact)
|
101
|
-
|
102
|
-
content = f.read()
|
103
|
-
fingerprint_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()[
|
104
|
-
:FINGERPRINT_LENGTH
|
105
|
-
]
|
102
|
+
fingerprint_hash = get_file_fingerprint(asset.absolute_path)
|
106
103
|
|
107
104
|
fingerprinted_basename = f"{base}.{fingerprint_hash}{extension}"
|
108
105
|
fingerprinted_path = os.path.join(target_dir, fingerprinted_basename)
|
plain/assets/finders.py
CHANGED
@@ -9,6 +9,11 @@ SKIP_ASSETS = (".DS_Store", ".gitignore")
|
|
9
9
|
|
10
10
|
|
11
11
|
def iter_assets():
|
12
|
+
"""
|
13
|
+
Iterate all valid asset files found in the installed
|
14
|
+
packages and the app itself.
|
15
|
+
"""
|
16
|
+
|
12
17
|
class Asset:
|
13
18
|
def __init__(self, *, url_path, absolute_path):
|
14
19
|
self.url_path = url_path
|
@@ -32,6 +37,10 @@ def iter_assets():
|
|
32
37
|
|
33
38
|
|
34
39
|
def iter_asset_dirs():
|
40
|
+
"""
|
41
|
+
Iterate all directories containing assets, from installed
|
42
|
+
packages and from app/assets.
|
43
|
+
"""
|
35
44
|
# Iterate the installed package assets, in order
|
36
45
|
for pkg in packages_registry.get_package_configs():
|
37
46
|
asset_dir = os.path.join(pkg.path, "assets")
|
plain/assets/fingerprints.py
CHANGED
@@ -1,10 +1,17 @@
|
|
1
|
+
import hashlib
|
1
2
|
import json
|
2
3
|
from functools import cache
|
3
4
|
|
4
5
|
from plain.runtime import settings
|
5
6
|
|
7
|
+
FINGERPRINT_LENGTH = 7
|
8
|
+
|
6
9
|
|
7
10
|
class AssetsFingerprintsManifest(dict):
|
11
|
+
"""
|
12
|
+
A manifest of original filenames to fingerprinted filenames.
|
13
|
+
"""
|
14
|
+
|
8
15
|
def __init__(self):
|
9
16
|
self.path = settings.PLAIN_TEMP_PATH / "assets" / "fingerprints.json"
|
10
17
|
|
@@ -36,3 +43,16 @@ def get_fingerprinted_url_path(url_path):
|
|
36
43
|
manifest = _get_manifest()
|
37
44
|
if url_path in manifest:
|
38
45
|
return manifest[url_path]
|
46
|
+
|
47
|
+
|
48
|
+
def get_file_fingerprint(file_path):
|
49
|
+
"""
|
50
|
+
Get the fingerprint hash for a file.
|
51
|
+
"""
|
52
|
+
with open(file_path, "rb") as f:
|
53
|
+
content = f.read()
|
54
|
+
fingerprint_hash = hashlib.md5(content, usedforsecurity=False).hexdigest()[
|
55
|
+
:FINGERPRINT_LENGTH
|
56
|
+
]
|
57
|
+
|
58
|
+
return fingerprint_hash
|
plain/assets/urls.py
CHANGED
@@ -6,6 +6,12 @@ from .views import AssetView
|
|
6
6
|
|
7
7
|
|
8
8
|
class AssetsRouter(Router):
|
9
|
+
"""
|
10
|
+
The router for serving static assets.
|
11
|
+
|
12
|
+
Include this router in your app router if you are serving assets yourself.
|
13
|
+
"""
|
14
|
+
|
9
15
|
namespace = "assets"
|
10
16
|
urls = [
|
11
17
|
path("<path:path>", AssetView, name="asset"),
|
@@ -13,6 +19,9 @@ class AssetsRouter(Router):
|
|
13
19
|
|
14
20
|
|
15
21
|
def get_asset_url(url_path):
|
22
|
+
"""
|
23
|
+
Get the full URL to a given asset path.
|
24
|
+
"""
|
16
25
|
if settings.DEBUG:
|
17
26
|
# In debug, we only ever use the original URL path.
|
18
27
|
resolved_url_path = url_path
|
plain/assets/views.py
CHANGED
@@ -16,9 +16,9 @@ from plain.runtime import settings
|
|
16
16
|
from plain.urls import reverse
|
17
17
|
from plain.views import View
|
18
18
|
|
19
|
-
from .compile import
|
19
|
+
from .compile import get_compiled_path
|
20
20
|
from .finders import iter_assets
|
21
|
-
from .fingerprints import get_fingerprinted_url_path
|
21
|
+
from .fingerprints import FINGERPRINT_LENGTH, get_fingerprinted_url_path
|
22
22
|
|
23
23
|
|
24
24
|
class AssetView(View):
|
@@ -28,8 +28,12 @@ class AssetView(View):
|
|
28
28
|
This class could be subclassed to further tweak the responses or behavior.
|
29
29
|
"""
|
30
30
|
|
31
|
+
def __init__(self, asset_path=None):
|
32
|
+
# Allow a path to be passed in AssetView.as_view(path="...")
|
33
|
+
self.asset_path = asset_path
|
34
|
+
|
31
35
|
def get_url_path(self):
|
32
|
-
return self.url_kwargs["path"]
|
36
|
+
return self.asset_path or self.url_kwargs["path"]
|
33
37
|
|
34
38
|
def get(self):
|
35
39
|
url_path = self.get_url_path()
|
plain/cli/README.md
CHANGED
@@ -1,101 +1,84 @@
|
|
1
1
|
# CLI
|
2
2
|
|
3
|
-
The `plain` CLI
|
3
|
+
**The `plain` CLI and how to add your own commands to it.**
|
4
4
|
|
5
|
-
Commands are written using [Click](
|
5
|
+
Commands are written using [Click](https://click.palletsprojects.com/en/8.1.x/)
|
6
6
|
(one of Plain's few dependencies),
|
7
|
-
which has been one of those most popular CLI frameworks in Python for a long time
|
7
|
+
which has been one of those most popular CLI frameworks in Python for a long time.
|
8
8
|
|
9
9
|
## Built-in commands
|
10
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
11
|
### `plain build`
|
28
12
|
|
29
13
|
Compile static assets (used in the deploy/production process).
|
30
14
|
|
31
|
-
Automatically runs `plain tailwind build` if [plain
|
15
|
+
Automatically runs `plain tailwind build` if [plain.tailwind](/plain-tailwind/) is installed.
|
32
16
|
|
33
|
-
### `plain
|
17
|
+
### `plain create`
|
34
18
|
|
35
|
-
|
19
|
+
Create a new local package.
|
36
20
|
|
37
21
|
### `plain preflight`
|
38
22
|
|
39
23
|
Run preflight checks to ensure your app is ready to run.
|
40
24
|
|
41
|
-
### `plain
|
25
|
+
### `plain run`
|
42
26
|
|
43
|
-
|
27
|
+
Run a Python script in the context of your app.
|
44
28
|
|
45
29
|
### `plain setting`
|
46
30
|
|
47
31
|
View the runtime value of a named setting.
|
48
32
|
|
49
|
-
|
33
|
+
### `plain shell`
|
50
34
|
|
51
|
-
|
35
|
+
Open a Python shell with the Plain loaded.
|
52
36
|
|
53
|
-
|
37
|
+
To auto-load models or run other code at shell launch,
|
38
|
+
create an `app/shell.py` and it will be imported automatically.
|
54
39
|
|
55
40
|
```python
|
56
|
-
|
57
|
-
|
41
|
+
# app/shell.py
|
42
|
+
from app.organizations.models import Organization
|
58
43
|
|
59
|
-
|
60
|
-
|
61
|
-
|
44
|
+
__all__ = [
|
45
|
+
"Organization",
|
46
|
+
]
|
47
|
+
```
|
62
48
|
|
49
|
+
### `plain urls`
|
63
50
|
|
64
|
-
|
65
|
-
def custom_command():
|
66
|
-
click.echo("An app command!")
|
67
|
-
```
|
51
|
+
List all the URL patterns in your app.
|
68
52
|
|
69
|
-
|
53
|
+
### `plain utils generate-secret-key`
|
70
54
|
|
71
|
-
|
72
|
-
$ plain custom-command
|
73
|
-
An app command!
|
74
|
-
```
|
55
|
+
Generate a new secret key for your app, to be used in `settings.SECRET_KEY`.
|
75
56
|
|
76
|
-
|
57
|
+
## Adding commands
|
77
58
|
|
78
|
-
|
79
|
-
In `cli.py`, create a command or group of commands named `cli`.
|
59
|
+
The `register_cli` decorator can be used to add your own commands to the `plain` CLI.
|
80
60
|
|
81
61
|
```python
|
82
62
|
import click
|
63
|
+
from plain.cli import register_cli
|
83
64
|
|
84
65
|
|
66
|
+
@register_cli("example-subgroup-name")
|
85
67
|
@click.group()
|
86
68
|
def cli():
|
69
|
+
"""Custom example commands"""
|
87
70
|
pass
|
88
71
|
|
89
|
-
|
90
72
|
@cli.command()
|
91
|
-
def
|
92
|
-
click.echo("
|
73
|
+
def example_command():
|
74
|
+
click.echo("An example command!")
|
93
75
|
```
|
94
76
|
|
95
|
-
|
96
|
-
then any commands you defined.
|
77
|
+
Then you can run the command with `plain`.
|
97
78
|
|
98
79
|
```bash
|
99
|
-
$ plain
|
100
|
-
|
80
|
+
$ plain example-subgroup-name example-command
|
81
|
+
An example command!
|
101
82
|
```
|
83
|
+
|
84
|
+
Technically you can register a CLI from anywhere, but typically you will do it in either `app/cli.py` or a package's `<pkg>/cli.py`, as those modules will be autoloaded by Plain.
|
plain/cli/core.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
import ast
|
2
|
+
import importlib.util
|
1
3
|
import os
|
2
4
|
import shutil
|
3
5
|
import subprocess
|
@@ -26,10 +28,207 @@ def plain_cli():
|
|
26
28
|
pass
|
27
29
|
|
28
30
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
31
|
+
def symbolicate(file_path: Path):
|
32
|
+
if "internal" in str(file_path).split("/"):
|
33
|
+
return ""
|
34
|
+
|
35
|
+
source = file_path.read_text()
|
36
|
+
|
37
|
+
parsed = ast.parse(source)
|
38
|
+
|
39
|
+
def should_skip(node):
|
40
|
+
if isinstance(node, ast.ClassDef | ast.FunctionDef):
|
41
|
+
if any(
|
42
|
+
isinstance(d, ast.Name) and d.id == "internalcode"
|
43
|
+
for d in node.decorator_list
|
44
|
+
):
|
45
|
+
return True
|
46
|
+
if node.name.startswith("_"): # and not node.name.endswith("__"):
|
47
|
+
return True
|
48
|
+
elif isinstance(node, ast.Assign):
|
49
|
+
for target in node.targets:
|
50
|
+
if (
|
51
|
+
isinstance(target, ast.Name) and target.id.startswith("_")
|
52
|
+
# and not target.id.endswith("__")
|
53
|
+
):
|
54
|
+
return True
|
55
|
+
return False
|
56
|
+
|
57
|
+
def process_node(node, indent=0):
|
58
|
+
lines = []
|
59
|
+
prefix = " " * indent
|
60
|
+
|
61
|
+
if should_skip(node):
|
62
|
+
return []
|
63
|
+
|
64
|
+
if isinstance(node, ast.ClassDef):
|
65
|
+
decorators = [
|
66
|
+
f"{prefix}@{ast.unparse(d)}"
|
67
|
+
for d in node.decorator_list
|
68
|
+
if not (isinstance(d, ast.Name) and d.id == "internal")
|
69
|
+
]
|
70
|
+
lines.extend(decorators)
|
71
|
+
bases = [ast.unparse(base) for base in node.bases]
|
72
|
+
lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
|
73
|
+
# if ast.get_docstring(node):
|
74
|
+
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
75
|
+
for child in node.body:
|
76
|
+
child_lines = process_node(child, indent + 1)
|
77
|
+
if child_lines:
|
78
|
+
lines.extend(child_lines)
|
79
|
+
# if not has_body:
|
80
|
+
# lines.append(f"{prefix} pass")
|
81
|
+
|
82
|
+
elif isinstance(node, ast.FunctionDef):
|
83
|
+
decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
|
84
|
+
lines.extend(decorators)
|
85
|
+
args = ast.unparse(node.args)
|
86
|
+
lines.append(f"{prefix}def {node.name}({args})")
|
87
|
+
# if ast.get_docstring(node):
|
88
|
+
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
89
|
+
# lines.append(f"{prefix} pass")
|
90
|
+
|
91
|
+
elif isinstance(node, ast.Assign):
|
92
|
+
for target in node.targets:
|
93
|
+
if isinstance(target, ast.Name):
|
94
|
+
lines.append(f"{prefix}{target.id} = {ast.unparse(node.value)}")
|
95
|
+
|
96
|
+
return lines
|
97
|
+
|
98
|
+
symbolicated_lines = []
|
99
|
+
for node in parsed.body:
|
100
|
+
symbolicated_lines.extend(process_node(node))
|
101
|
+
|
102
|
+
return "\n".join(symbolicated_lines)
|
103
|
+
|
104
|
+
|
105
|
+
@plain_cli.command
|
106
|
+
@click.option("--llm", "llm", is_flag=True)
|
107
|
+
@click.option("--open")
|
108
|
+
@click.argument("module", default="")
|
109
|
+
def docs(module, llm, open):
|
110
|
+
if not module and not llm:
|
111
|
+
click.secho("You must specify a module or use --llm", fg="red")
|
112
|
+
sys.exit(1)
|
113
|
+
|
114
|
+
if llm:
|
115
|
+
click.echo(
|
116
|
+
"Below is all of the documentation and abbreviated source code for the Plain web framework. "
|
117
|
+
"Your job is to read and understand it, and then act as the Plain Framework Assistant and "
|
118
|
+
"help the developer accomplish whatever they want to do next."
|
119
|
+
"\n\n---\n\n"
|
120
|
+
)
|
121
|
+
|
122
|
+
docs = set()
|
123
|
+
sources = set()
|
124
|
+
|
125
|
+
# Get everything for Plain core
|
126
|
+
for path in Path(__file__).parent.parent.glob("**/*.md"):
|
127
|
+
docs.add(path)
|
128
|
+
for source in Path(__file__).parent.parent.glob("**/*.py"):
|
129
|
+
sources.add(source)
|
130
|
+
|
131
|
+
# Find every *.md file in the other plain packages and installed apps
|
132
|
+
for package_config in packages_registry.get_package_configs():
|
133
|
+
if package_config.name.startswith("app."):
|
134
|
+
# Ignore app packages for now
|
135
|
+
continue
|
136
|
+
|
137
|
+
for path in Path(package_config.path).glob("**/*.md"):
|
138
|
+
docs.add(path)
|
139
|
+
|
140
|
+
for source in Path(package_config.path).glob("**/*.py"):
|
141
|
+
sources.add(source)
|
142
|
+
|
143
|
+
docs = sorted(docs)
|
144
|
+
sources = sorted(sources)
|
145
|
+
|
146
|
+
for doc in docs:
|
147
|
+
try:
|
148
|
+
display_path = doc.relative_to(Path.cwd())
|
149
|
+
except ValueError:
|
150
|
+
display_path = doc.absolute()
|
151
|
+
click.secho(f"<Docs: {display_path}>", fg="yellow")
|
152
|
+
click.echo(doc.read_text())
|
153
|
+
click.secho(f"</Docs: {display_path}>", fg="yellow")
|
154
|
+
click.echo()
|
155
|
+
|
156
|
+
for source in sources:
|
157
|
+
if symbolicated := symbolicate(source):
|
158
|
+
try:
|
159
|
+
display_path = source.relative_to(Path.cwd())
|
160
|
+
except ValueError:
|
161
|
+
display_path = source.absolute()
|
162
|
+
click.secho(f"<Source: {display_path}>", fg="yellow")
|
163
|
+
click.echo(symbolicated)
|
164
|
+
click.secho(f"</Source: {display_path}>", fg="yellow")
|
165
|
+
click.echo()
|
166
|
+
|
167
|
+
click.secho(
|
168
|
+
"That's everything! Copy this into your AI tool of choice.",
|
169
|
+
err=True,
|
170
|
+
fg="green",
|
171
|
+
)
|
172
|
+
|
173
|
+
return
|
174
|
+
|
175
|
+
if module:
|
176
|
+
# Automatically prefix if we need to
|
177
|
+
if not module.startswith("plain"):
|
178
|
+
module = f"plain.{module}"
|
179
|
+
|
180
|
+
# Get the README.md file for the module
|
181
|
+
spec = importlib.util.find_spec(module)
|
182
|
+
if not spec:
|
183
|
+
click.secho(f"Module {module} not found", fg="red")
|
184
|
+
sys.exit(1)
|
185
|
+
|
186
|
+
module_path = Path(spec.origin).parent
|
187
|
+
readme_path = module_path / "README.md"
|
188
|
+
if not readme_path.exists():
|
189
|
+
click.secho(f"README.md not found for {module}", fg="red")
|
190
|
+
sys.exit(1)
|
191
|
+
|
192
|
+
if open:
|
193
|
+
click.launch(str(readme_path))
|
194
|
+
else:
|
195
|
+
|
196
|
+
def _iterate_markdown(content):
|
197
|
+
"""
|
198
|
+
Iterator that does basic markdown for a Click pager.
|
199
|
+
|
200
|
+
Headings are yellow and bright, code blocks are indented.
|
201
|
+
"""
|
202
|
+
|
203
|
+
in_code_block = False
|
204
|
+
for line in content.splitlines():
|
205
|
+
if line.startswith("```"):
|
206
|
+
in_code_block = not in_code_block
|
207
|
+
|
208
|
+
if in_code_block:
|
209
|
+
yield click.style(line, dim=True)
|
210
|
+
elif line.startswith("# "):
|
211
|
+
yield click.style(line, fg="yellow", bold=True)
|
212
|
+
elif line.startswith("## "):
|
213
|
+
yield click.style(line, fg="yellow", bold=True)
|
214
|
+
elif line.startswith("### "):
|
215
|
+
yield click.style(line, fg="yellow", bold=True)
|
216
|
+
elif line.startswith("#### "):
|
217
|
+
yield click.style(line, fg="yellow", bold=True)
|
218
|
+
elif line.startswith("##### "):
|
219
|
+
yield click.style(line, fg="yellow", bold=True)
|
220
|
+
elif line.startswith("###### "):
|
221
|
+
yield click.style(line, fg="yellow", bold=True)
|
222
|
+
elif line.startswith("**") and line.endswith("**"):
|
223
|
+
yield click.style(line, bold=True)
|
224
|
+
elif line.startswith("> "):
|
225
|
+
yield click.style(line, italic=True)
|
226
|
+
else:
|
227
|
+
yield line
|
228
|
+
|
229
|
+
yield "\n"
|
230
|
+
|
231
|
+
click.echo_via_pager(_iterate_markdown(readme_path.read_text()))
|
33
232
|
|
34
233
|
|
35
234
|
@plain_cli.command()
|