plain 0.37.0__py3-none-any.whl → 0.38.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/assets/README.md +16 -0
- plain/cli/docs.py +160 -118
- plain/csrf/README.md +2 -2
- plain/forms/exceptions.py +3 -1
- plain/forms/fields.py +21 -27
- plain/forms/forms.py +32 -10
- plain/logs/README.md +1 -1
- plain/views/README.md +38 -7
- plain/views/base.py +40 -39
- plain/views/forms.py +3 -11
- {plain-0.37.0.dist-info → plain-0.38.0.dist-info}/METADATA +1 -1
- {plain-0.37.0.dist-info → plain-0.38.0.dist-info}/RECORD +15 -15
- {plain-0.37.0.dist-info → plain-0.38.0.dist-info}/WHEEL +0 -0
- {plain-0.37.0.dist-info → plain-0.38.0.dist-info}/entry_points.txt +0 -0
- {plain-0.37.0.dist-info → plain-0.38.0.dist-info}/licenses/LICENSE +0 -0
plain/assets/README.md
CHANGED
@@ -44,6 +44,22 @@ By default, this [generates "fingerprinted" and compressed versions of the asset
|
|
44
44
|
|
45
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.
|
46
46
|
|
47
|
+
## Using `AssetView` directly
|
48
|
+
|
49
|
+
In some situations you may want to use the `AssetView` at a custom URL, for example to serve a `favicon.ico`. You can do this quickly by using the `AssetView.as_view()` class method.
|
50
|
+
|
51
|
+
```python
|
52
|
+
from plain.assets.views import AssetView
|
53
|
+
from plain.urls import path, Router
|
54
|
+
|
55
|
+
|
56
|
+
class AppRouter(Router):
|
57
|
+
namespace = ""
|
58
|
+
urls = [
|
59
|
+
path("favicon.ico", AssetView.as_view(asset_path="favicon.ico")),
|
60
|
+
]
|
61
|
+
```
|
62
|
+
|
47
63
|
## FAQs
|
48
64
|
|
49
65
|
### How do you reference assets in Python code?
|
plain/cli/docs.py
CHANGED
@@ -8,80 +8,6 @@ import click
|
|
8
8
|
from plain.packages import packages_registry
|
9
9
|
|
10
10
|
|
11
|
-
def symbolicate(file_path: Path):
|
12
|
-
if "internal" in str(file_path).split("/"):
|
13
|
-
return ""
|
14
|
-
|
15
|
-
source = file_path.read_text()
|
16
|
-
|
17
|
-
parsed = ast.parse(source)
|
18
|
-
|
19
|
-
def should_skip(node):
|
20
|
-
if isinstance(node, ast.ClassDef | ast.FunctionDef):
|
21
|
-
if any(
|
22
|
-
isinstance(d, ast.Name) and d.id == "internalcode"
|
23
|
-
for d in node.decorator_list
|
24
|
-
):
|
25
|
-
return True
|
26
|
-
if node.name.startswith("_"): # and not node.name.endswith("__"):
|
27
|
-
return True
|
28
|
-
elif isinstance(node, ast.Assign):
|
29
|
-
for target in node.targets:
|
30
|
-
if (
|
31
|
-
isinstance(target, ast.Name) and target.id.startswith("_")
|
32
|
-
# and not target.id.endswith("__")
|
33
|
-
):
|
34
|
-
return True
|
35
|
-
return False
|
36
|
-
|
37
|
-
def process_node(node, indent=0):
|
38
|
-
lines = []
|
39
|
-
prefix = " " * indent
|
40
|
-
|
41
|
-
if should_skip(node):
|
42
|
-
return []
|
43
|
-
|
44
|
-
if isinstance(node, ast.ClassDef):
|
45
|
-
decorators = [
|
46
|
-
f"{prefix}@{ast.unparse(d)}"
|
47
|
-
for d in node.decorator_list
|
48
|
-
if not (isinstance(d, ast.Name) and d.id == "internal")
|
49
|
-
]
|
50
|
-
lines.extend(decorators)
|
51
|
-
bases = [ast.unparse(base) for base in node.bases]
|
52
|
-
lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
|
53
|
-
# if ast.get_docstring(node):
|
54
|
-
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
55
|
-
for child in node.body:
|
56
|
-
child_lines = process_node(child, indent + 1)
|
57
|
-
if child_lines:
|
58
|
-
lines.extend(child_lines)
|
59
|
-
# if not has_body:
|
60
|
-
# lines.append(f"{prefix} pass")
|
61
|
-
|
62
|
-
elif isinstance(node, ast.FunctionDef):
|
63
|
-
decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
|
64
|
-
lines.extend(decorators)
|
65
|
-
args = ast.unparse(node.args)
|
66
|
-
lines.append(f"{prefix}def {node.name}({args})")
|
67
|
-
# if ast.get_docstring(node):
|
68
|
-
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
69
|
-
# lines.append(f"{prefix} pass")
|
70
|
-
|
71
|
-
elif isinstance(node, ast.Assign):
|
72
|
-
for target in node.targets:
|
73
|
-
if isinstance(target, ast.Name):
|
74
|
-
lines.append(f"{prefix}{target.id} = {ast.unparse(node.value)}")
|
75
|
-
|
76
|
-
return lines
|
77
|
-
|
78
|
-
symbolicated_lines = []
|
79
|
-
for node in parsed.body:
|
80
|
-
symbolicated_lines.extend(process_node(node))
|
81
|
-
|
82
|
-
return "\n".join(symbolicated_lines)
|
83
|
-
|
84
|
-
|
85
11
|
@click.command()
|
86
12
|
@click.option("--llm", "llm", is_flag=True)
|
87
13
|
@click.option("--open")
|
@@ -92,57 +18,18 @@ def docs(module, llm, open):
|
|
92
18
|
sys.exit(1)
|
93
19
|
|
94
20
|
if llm:
|
95
|
-
|
96
|
-
"Below is all of the documentation and abbreviated source code for the Plain web framework. "
|
97
|
-
"Your job is to read and understand it, and then act as the Plain Framework Assistant and "
|
98
|
-
"help the developer accomplish whatever they want to do next."
|
99
|
-
"\n\n---\n\n"
|
100
|
-
)
|
21
|
+
paths = [Path(__file__).parent.parent]
|
101
22
|
|
102
|
-
docs = set()
|
103
|
-
sources = set()
|
104
|
-
|
105
|
-
# Get everything for Plain core
|
106
|
-
for path in Path(__file__).parent.parent.glob("**/*.md"):
|
107
|
-
docs.add(path)
|
108
|
-
for source in Path(__file__).parent.parent.glob("**/*.py"):
|
109
|
-
sources.add(source)
|
110
|
-
|
111
|
-
# Find every *.md file in the other plain packages and installed apps
|
112
23
|
for package_config in packages_registry.get_package_configs():
|
113
24
|
if package_config.name.startswith("app."):
|
114
25
|
# Ignore app packages for now
|
115
26
|
continue
|
116
27
|
|
117
|
-
|
118
|
-
docs.add(path)
|
119
|
-
|
120
|
-
for source in Path(package_config.path).glob("**/*.py"):
|
121
|
-
sources.add(source)
|
122
|
-
|
123
|
-
docs = sorted(docs)
|
124
|
-
sources = sorted(sources)
|
125
|
-
|
126
|
-
for doc in docs:
|
127
|
-
try:
|
128
|
-
display_path = doc.relative_to(Path.cwd())
|
129
|
-
except ValueError:
|
130
|
-
display_path = doc.absolute()
|
131
|
-
click.secho(f"<Docs: {display_path}>", fg="yellow")
|
132
|
-
click.echo(doc.read_text())
|
133
|
-
click.secho(f"</Docs: {display_path}>", fg="yellow")
|
134
|
-
click.echo()
|
28
|
+
paths.append(Path(package_config.path))
|
135
29
|
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
display_path = source.relative_to(Path.cwd())
|
140
|
-
except ValueError:
|
141
|
-
display_path = source.absolute()
|
142
|
-
click.secho(f"<Source: {display_path}>", fg="yellow")
|
143
|
-
click.echo(symbolicated)
|
144
|
-
click.secho(f"</Source: {display_path}>", fg="yellow")
|
145
|
-
click.echo()
|
30
|
+
source_docs = LLMDocs(paths)
|
31
|
+
source_docs.load()
|
32
|
+
source_docs.print()
|
146
33
|
|
147
34
|
click.secho(
|
148
35
|
"That's everything! Copy this into your AI tool of choice.",
|
@@ -209,3 +96,158 @@ def docs(module, llm, open):
|
|
209
96
|
yield "\n"
|
210
97
|
|
211
98
|
click.echo_via_pager(_iterate_markdown(readme_path.read_text()))
|
99
|
+
|
100
|
+
|
101
|
+
class LLMDocs:
|
102
|
+
preamble = (
|
103
|
+
"Below is all of the documentation and abbreviated source code for the Plain web framework. "
|
104
|
+
"Your job is to read and understand it, and then act as the Plain Framework Assistant and "
|
105
|
+
"help the developer accomplish whatever they want to do next."
|
106
|
+
"\n\n---\n\n"
|
107
|
+
)
|
108
|
+
|
109
|
+
def __init__(self, paths):
|
110
|
+
self.paths = paths
|
111
|
+
|
112
|
+
def load(self):
|
113
|
+
self.docs = set()
|
114
|
+
self.sources = set()
|
115
|
+
|
116
|
+
for path in self.paths:
|
117
|
+
if path.is_dir():
|
118
|
+
self.docs.update(path.glob("**/*.md"))
|
119
|
+
self.sources.update(path.glob("**/*.py"))
|
120
|
+
elif path.suffix == ".py":
|
121
|
+
self.sources.add(path)
|
122
|
+
elif path.suffix == ".md":
|
123
|
+
self.docs.add(path)
|
124
|
+
|
125
|
+
# Exclude "migrations" code from plain apps, except for plain/models/migrations
|
126
|
+
self.docs = {
|
127
|
+
doc
|
128
|
+
for doc in self.docs
|
129
|
+
if not (
|
130
|
+
"/migrations/" in str(doc)
|
131
|
+
and "/plain/models/migrations/" not in str(doc)
|
132
|
+
)
|
133
|
+
}
|
134
|
+
self.sources = {
|
135
|
+
source
|
136
|
+
for source in self.sources
|
137
|
+
if not (
|
138
|
+
"/migrations/" in str(source)
|
139
|
+
and "/plain/models/migrations/" not in str(source)
|
140
|
+
)
|
141
|
+
}
|
142
|
+
|
143
|
+
self.docs = sorted(self.docs)
|
144
|
+
self.sources = sorted(self.sources)
|
145
|
+
|
146
|
+
def display_path(self, path):
|
147
|
+
if "plain" in path.parts:
|
148
|
+
root_index = path.parts.index("plain")
|
149
|
+
elif "plainx" in path.parts:
|
150
|
+
root_index = path.parts.index("plainx")
|
151
|
+
else:
|
152
|
+
raise ValueError("Path does not contain 'plain' or 'plainx'")
|
153
|
+
|
154
|
+
plain_root = Path(*path.parts[: root_index + 1])
|
155
|
+
return path.relative_to(plain_root.parent)
|
156
|
+
|
157
|
+
def print(self, relative_to=None):
|
158
|
+
click.secho(self.preamble, fg="yellow")
|
159
|
+
|
160
|
+
for doc in self.docs:
|
161
|
+
if relative_to:
|
162
|
+
display_path = doc.relative_to(relative_to)
|
163
|
+
else:
|
164
|
+
display_path = self.display_path(doc)
|
165
|
+
click.secho(f"<Docs: {display_path}>", fg="yellow")
|
166
|
+
click.echo(doc.read_text())
|
167
|
+
click.secho(f"</Docs: {display_path}>", fg="yellow")
|
168
|
+
click.echo()
|
169
|
+
|
170
|
+
for source in self.sources:
|
171
|
+
if symbolicated := self.symbolicate(source):
|
172
|
+
if relative_to:
|
173
|
+
display_path = source.relative_to(relative_to)
|
174
|
+
else:
|
175
|
+
display_path = self.display_path(source)
|
176
|
+
click.secho(f"<Source: {display_path}>", fg="yellow")
|
177
|
+
click.echo(symbolicated)
|
178
|
+
click.secho(f"</Source: {display_path}>", fg="yellow")
|
179
|
+
click.echo()
|
180
|
+
|
181
|
+
@staticmethod
|
182
|
+
def symbolicate(file_path: Path):
|
183
|
+
if "internal" in str(file_path).split("/"):
|
184
|
+
return ""
|
185
|
+
|
186
|
+
source = file_path.read_text()
|
187
|
+
|
188
|
+
parsed = ast.parse(source)
|
189
|
+
|
190
|
+
def should_skip(node):
|
191
|
+
if isinstance(node, ast.ClassDef | ast.FunctionDef):
|
192
|
+
if any(
|
193
|
+
isinstance(d, ast.Name) and d.id == "internalcode"
|
194
|
+
for d in node.decorator_list
|
195
|
+
):
|
196
|
+
return True
|
197
|
+
if node.name.startswith("_"): # and not node.name.endswith("__"):
|
198
|
+
return True
|
199
|
+
elif isinstance(node, ast.Assign):
|
200
|
+
for target in node.targets:
|
201
|
+
if (
|
202
|
+
isinstance(target, ast.Name) and target.id.startswith("_")
|
203
|
+
# and not target.id.endswith("__")
|
204
|
+
):
|
205
|
+
return True
|
206
|
+
return False
|
207
|
+
|
208
|
+
def process_node(node, indent=0):
|
209
|
+
lines = []
|
210
|
+
prefix = " " * indent
|
211
|
+
|
212
|
+
if should_skip(node):
|
213
|
+
return []
|
214
|
+
|
215
|
+
if isinstance(node, ast.ClassDef):
|
216
|
+
decorators = [
|
217
|
+
f"{prefix}@{ast.unparse(d)}"
|
218
|
+
for d in node.decorator_list
|
219
|
+
if not (isinstance(d, ast.Name) and d.id == "internalcode")
|
220
|
+
]
|
221
|
+
lines.extend(decorators)
|
222
|
+
bases = [ast.unparse(base) for base in node.bases]
|
223
|
+
lines.append(f"{prefix}class {node.name}({', '.join(bases)})")
|
224
|
+
# if ast.get_docstring(node):
|
225
|
+
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
226
|
+
for child in node.body:
|
227
|
+
child_lines = process_node(child, indent + 1)
|
228
|
+
if child_lines:
|
229
|
+
lines.extend(child_lines)
|
230
|
+
# if not has_body:
|
231
|
+
# lines.append(f"{prefix} pass")
|
232
|
+
|
233
|
+
elif isinstance(node, ast.FunctionDef):
|
234
|
+
decorators = [f"{prefix}@{ast.unparse(d)}" for d in node.decorator_list]
|
235
|
+
lines.extend(decorators)
|
236
|
+
args = ast.unparse(node.args)
|
237
|
+
lines.append(f"{prefix}def {node.name}({args})")
|
238
|
+
# if ast.get_docstring(node):
|
239
|
+
# lines.append(f'{prefix} """{ast.get_docstring(node)}"""')
|
240
|
+
# lines.append(f"{prefix} pass")
|
241
|
+
|
242
|
+
elif isinstance(node, ast.Assign):
|
243
|
+
for target in node.targets:
|
244
|
+
if isinstance(target, ast.Name):
|
245
|
+
lines.append(f"{prefix}{target.id} = {ast.unparse(node.value)}")
|
246
|
+
|
247
|
+
return lines
|
248
|
+
|
249
|
+
symbolicated_lines = []
|
250
|
+
for node in parsed.body:
|
251
|
+
symbolicated_lines.extend(process_node(node))
|
252
|
+
|
253
|
+
return "\n".join(symbolicated_lines)
|
plain/csrf/README.md
CHANGED
@@ -2,13 +2,13 @@
|
|
2
2
|
|
3
3
|
**Cross-Site Request Forgery (CSRF) protection.**
|
4
4
|
|
5
|
-
Plain protects against [CSRF attacks](https://en.wikipedia.org/wiki/Cross-site_request_forgery) through a [middleware](middleware.py) that compares the generated `csrftoken` cookie with the CSRF token from the request (either `_csrftoken` in form data or the `
|
5
|
+
Plain protects against [CSRF attacks](https://en.wikipedia.org/wiki/Cross-site_request_forgery) through a [middleware](middleware.py) that compares the generated `csrftoken` cookie with the CSRF token from the request (either `_csrftoken` in form data or the `CSRF-Token` header).
|
6
6
|
|
7
7
|
## Usage
|
8
8
|
|
9
9
|
The `CsrfViewMiddleware` is [automatically installed](../internal/handlers/base.py#BUILTIN_BEFORE_MIDDLEWARE), so you don't need to add it to your `settings.MIDDLEWARE`.
|
10
10
|
|
11
|
-
When you use HTML forms, you should include the CSRF token in the form data:
|
11
|
+
When you use HTML forms, you should include the CSRF token in the form data via a hidden input:
|
12
12
|
|
13
13
|
```html
|
14
14
|
<form method="post">
|
plain/forms/exceptions.py
CHANGED
plain/forms/fields.py
CHANGED
@@ -67,23 +67,16 @@ class Field:
|
|
67
67
|
initial=None,
|
68
68
|
error_messages=None,
|
69
69
|
validators=(),
|
70
|
-
disabled=False,
|
71
70
|
):
|
72
71
|
# required -- Boolean that specifies whether the field is required.
|
73
72
|
# True by default.
|
74
|
-
# widget -- A Widget class, or instance of a Widget class, that should
|
75
|
-
# be used for this Field when displaying it. Each Field has a
|
76
|
-
# default Widget that it'll use if you don't specify this. In
|
77
|
-
# most cases, the default widget is TextInput.
|
78
73
|
# initial -- A value to use in this Field's initial display. This value
|
79
74
|
# is *not* used as a fallback if data isn't given.
|
80
75
|
# error_messages -- An optional dictionary to override the default
|
81
76
|
# messages that the field will raise.
|
82
77
|
# validators -- List of additional validators to use
|
83
|
-
|
84
|
-
|
85
|
-
self.required, self.initial = required, initial
|
86
|
-
self.disabled = disabled
|
78
|
+
self.required = required
|
79
|
+
self.initial = initial
|
87
80
|
|
88
81
|
messages = {}
|
89
82
|
for c in reversed(self.__class__.__mro__):
|
@@ -136,16 +129,10 @@ class Field:
|
|
136
129
|
For most fields, this will simply be data; FileFields need to handle it
|
137
130
|
a bit differently.
|
138
131
|
"""
|
139
|
-
if self.disabled:
|
140
|
-
return initial
|
141
132
|
return data
|
142
133
|
|
143
134
|
def has_changed(self, initial, data):
|
144
135
|
"""Return True if data differs from initial."""
|
145
|
-
# Always return False if the field is disabled since self.bound_data
|
146
|
-
# always uses the initial value in this case.
|
147
|
-
if self.disabled:
|
148
|
-
return False
|
149
136
|
try:
|
150
137
|
data = self.to_python(data)
|
151
138
|
if hasattr(self, "_coerce"):
|
@@ -174,12 +161,17 @@ class Field:
|
|
174
161
|
return result
|
175
162
|
|
176
163
|
def value_from_form_data(self, data, files, html_name):
|
164
|
+
# By default, all fields are expected to be present in HTML form data.
|
177
165
|
try:
|
178
166
|
return data[html_name]
|
179
167
|
except KeyError as e:
|
180
|
-
raise FormFieldMissingError(
|
181
|
-
|
182
|
-
|
168
|
+
raise FormFieldMissingError(html_name) from e
|
169
|
+
|
170
|
+
def value_from_json_data(self, data, files, html_name):
|
171
|
+
if self.required and html_name not in data:
|
172
|
+
raise FormFieldMissingError(html_name)
|
173
|
+
|
174
|
+
return data.get(html_name, None)
|
183
175
|
|
184
176
|
|
185
177
|
class CharField(Field):
|
@@ -592,11 +584,14 @@ class FileField(Field):
|
|
592
584
|
return initial
|
593
585
|
|
594
586
|
def has_changed(self, initial, data):
|
595
|
-
return
|
587
|
+
return data is not None
|
596
588
|
|
597
589
|
def value_from_form_data(self, data, files, html_name):
|
598
590
|
return files.get(html_name)
|
599
591
|
|
592
|
+
def value_from_json_data(self, data, files, html_name):
|
593
|
+
return files.get(html_name)
|
594
|
+
|
600
595
|
|
601
596
|
class ImageField(FileField):
|
602
597
|
default_validators = [validators.validate_image_file_extension]
|
@@ -706,8 +701,6 @@ class BooleanField(Field):
|
|
706
701
|
raise ValidationError(self.error_messages["required"], code="required")
|
707
702
|
|
708
703
|
def has_changed(self, initial, data):
|
709
|
-
if self.disabled:
|
710
|
-
return False
|
711
704
|
# Sometimes data or initial may be a string equivalent of a boolean
|
712
705
|
# so we should run it through to_python first to get a boolean value
|
713
706
|
return self.to_python(initial) != self.to_python(data)
|
@@ -729,6 +722,13 @@ class BooleanField(Field):
|
|
729
722
|
"on": True,
|
730
723
|
}.get(value)
|
731
724
|
|
725
|
+
def value_from_json_data(self, data, files, html_name):
|
726
|
+
# Boolean fields must be present in the JSON data
|
727
|
+
try:
|
728
|
+
return data[html_name]
|
729
|
+
except KeyError as e:
|
730
|
+
raise FormFieldMissingError(html_name) from e
|
731
|
+
|
732
732
|
|
733
733
|
class NullBooleanField(BooleanField):
|
734
734
|
"""
|
@@ -883,8 +883,6 @@ class MultipleChoiceField(ChoiceField):
|
|
883
883
|
)
|
884
884
|
|
885
885
|
def has_changed(self, initial, data):
|
886
|
-
if self.disabled:
|
887
|
-
return False
|
888
886
|
if initial is None:
|
889
887
|
initial = []
|
890
888
|
if data is None:
|
@@ -944,8 +942,6 @@ class JSONField(CharField):
|
|
944
942
|
super().__init__(**kwargs)
|
945
943
|
|
946
944
|
def to_python(self, value):
|
947
|
-
if self.disabled:
|
948
|
-
return value
|
949
945
|
if value in self.empty_values:
|
950
946
|
return None
|
951
947
|
elif isinstance(value, list | dict | int | float | JSONString):
|
@@ -964,8 +960,6 @@ class JSONField(CharField):
|
|
964
960
|
return converted
|
965
961
|
|
966
962
|
def bound_data(self, data, initial):
|
967
|
-
if self.disabled:
|
968
|
-
return initial
|
969
963
|
if data is None:
|
970
964
|
return None
|
971
965
|
try:
|
plain/forms/forms.py
CHANGED
@@ -3,6 +3,7 @@ Form classes
|
|
3
3
|
"""
|
4
4
|
|
5
5
|
import copy
|
6
|
+
import json
|
6
7
|
|
7
8
|
from plain.exceptions import NON_FIELD_ERRORS
|
8
9
|
from plain.utils.datastructures import MultiValueDict
|
@@ -57,15 +58,36 @@ class BaseForm:
|
|
57
58
|
|
58
59
|
def __init__(
|
59
60
|
self,
|
60
|
-
|
61
|
-
|
61
|
+
*,
|
62
|
+
request,
|
62
63
|
auto_id="id_%s",
|
63
64
|
prefix=None,
|
64
65
|
initial=None,
|
65
66
|
):
|
66
|
-
|
67
|
-
|
68
|
-
|
67
|
+
if request.method in ("POST", "PUT", "PATCH"):
|
68
|
+
if request.headers.get("Content-Type") == "application/json":
|
69
|
+
self.data = json.loads(request.body)
|
70
|
+
self.is_json_request = True
|
71
|
+
elif request.headers.get("Content-Type") in [
|
72
|
+
"application/x-www-form-urlencoded",
|
73
|
+
"multipart/form-data",
|
74
|
+
]:
|
75
|
+
self.data = request.POST
|
76
|
+
self.is_json_request = False
|
77
|
+
else:
|
78
|
+
raise ValueError(
|
79
|
+
"Unsupported Content-Type. Supported types are "
|
80
|
+
"'application/json', 'application/x-www-form-urlencoded', "
|
81
|
+
"and 'multipart/form-data'."
|
82
|
+
)
|
83
|
+
else:
|
84
|
+
self.data = MultiValueDict()
|
85
|
+
self.is_json_request = False
|
86
|
+
|
87
|
+
self.files = request.FILES
|
88
|
+
|
89
|
+
self.is_bound = bool(self.data or self.files)
|
90
|
+
|
69
91
|
self._auto_id = auto_id
|
70
92
|
if prefix is not None:
|
71
93
|
self.prefix = prefix
|
@@ -224,16 +246,16 @@ class BaseForm:
|
|
224
246
|
# Allow custom parsing from form data/files at the form level
|
225
247
|
return getattr(self, f"parse_{html_name}")()
|
226
248
|
|
227
|
-
|
249
|
+
if self.is_json_request:
|
250
|
+
return field.value_from_json_data(self.data, self.files, html_name)
|
251
|
+
else:
|
252
|
+
return field.value_from_form_data(self.data, self.files, html_name)
|
228
253
|
|
229
254
|
def _clean_fields(self):
|
230
255
|
for name, bf in self._bound_items():
|
231
256
|
field = bf.field
|
232
257
|
|
233
|
-
|
234
|
-
value = bf.initial
|
235
|
-
else:
|
236
|
-
value = self._field_data_value(bf.field, bf.html_name)
|
258
|
+
value = self._field_data_value(bf.field, bf.html_name)
|
237
259
|
|
238
260
|
try:
|
239
261
|
if isinstance(field, FileField):
|
plain/logs/README.md
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
**Logging configuration and utilities.**
|
4
4
|
|
5
|
-
In Python, configuring logging can be surprisingly complex. For most use cases, Plain provides a [default configuration](configure.py) that "just works".
|
5
|
+
In Python, configuring logging can be surprisingly complex. For most use cases, Plain provides a [default configuration](./configure.py) that "just works".
|
6
6
|
|
7
7
|
By default, both the `plain` and `app` loggers are set to the `INFO` level. You can quickly change this by using the `PLAIN_LOG_LEVEL` and `APP_LOG_LEVEL` environment variables.
|
8
8
|
|
plain/views/README.md
CHANGED
@@ -12,7 +12,7 @@ from plain.views import View
|
|
12
12
|
|
13
13
|
class ExampleView(View):
|
14
14
|
def get(self):
|
15
|
-
return "Hello, world
|
15
|
+
return "<html><body>Hello, world!</body></html>"
|
16
16
|
```
|
17
17
|
|
18
18
|
## HTTP methods -> class methods
|
@@ -51,18 +51,23 @@ but you can override these too.
|
|
51
51
|
|
52
52
|
## Return types
|
53
53
|
|
54
|
-
For simple
|
54
|
+
For simple JSON responses, HTML, or status code responses,
|
55
55
|
you don't need to instantiate a `Response` object.
|
56
56
|
|
57
57
|
```python
|
58
|
-
class
|
58
|
+
class JsonView(View):
|
59
59
|
def get(self):
|
60
|
-
return "Hello, world!"
|
60
|
+
return {"message": "Hello, world!"}
|
61
61
|
|
62
62
|
|
63
|
-
class
|
63
|
+
class HtmlView(View):
|
64
64
|
def get(self):
|
65
|
-
return
|
65
|
+
return "<html><body>Hello, world!</body></html>"
|
66
|
+
|
67
|
+
|
68
|
+
class StatusCodeView(View):
|
69
|
+
def get(self):
|
70
|
+
return 204 # No content
|
66
71
|
```
|
67
72
|
|
68
73
|
## Template views
|
@@ -84,6 +89,19 @@ class ExampleView(TemplateView):
|
|
84
89
|
|
85
90
|
The `TemplateView` is also the base class for _most_ of the other built-in view classes.
|
86
91
|
|
92
|
+
Template views that don't need any custom context can use `TemplateView.as_view()` direcly in the URL route.
|
93
|
+
|
94
|
+
```python
|
95
|
+
from plain.views import TemplateView
|
96
|
+
from plain.urls import path, Router
|
97
|
+
|
98
|
+
|
99
|
+
class AppRouter(Router):
|
100
|
+
routes = [
|
101
|
+
path("/example/", TemplateView.as_view(template_name="example.html")),
|
102
|
+
]
|
103
|
+
```
|
104
|
+
|
87
105
|
## Form views
|
88
106
|
|
89
107
|
Standard [forms](../forms) can be rendered and processed by a `FormView`.
|
@@ -255,7 +273,20 @@ class ExampleRedirectView(RedirectView):
|
|
255
273
|
permanent = True
|
256
274
|
```
|
257
275
|
|
258
|
-
|
276
|
+
Redirect views can also be used in the URL router.
|
277
|
+
|
278
|
+
```python
|
279
|
+
from plain.views import RedirectView
|
280
|
+
from plain.urls import path, Router
|
281
|
+
|
282
|
+
|
283
|
+
class AppRouter(Router):
|
284
|
+
routes = [
|
285
|
+
path("/old-location/", RedirectView.as_view(url="/new-location/", permanent=True)),
|
286
|
+
]
|
287
|
+
```
|
288
|
+
|
289
|
+
## CSRF exempt views
|
259
290
|
|
260
291
|
```python
|
261
292
|
from plain.views import View
|
plain/views/base.py
CHANGED
@@ -6,6 +6,7 @@ from plain.http import (
|
|
6
6
|
Response,
|
7
7
|
ResponseBase,
|
8
8
|
ResponseNotAllowed,
|
9
|
+
ResponseNotFound,
|
9
10
|
)
|
10
11
|
from plain.utils.decorators import classonlymethod
|
11
12
|
|
@@ -19,19 +20,6 @@ class View:
|
|
19
20
|
url_args: tuple
|
20
21
|
url_kwargs: dict
|
21
22
|
|
22
|
-
# By default, any of these are allowed if a method is defined for it.
|
23
|
-
# To disallow a defined method, remove it from this list.
|
24
|
-
allowed_http_methods = [
|
25
|
-
"get",
|
26
|
-
"post",
|
27
|
-
"put",
|
28
|
-
"patch",
|
29
|
-
"delete",
|
30
|
-
"head",
|
31
|
-
"options",
|
32
|
-
"trace",
|
33
|
-
]
|
34
|
-
|
35
23
|
# View.as_view(example="foo") usage can be customized by defining your own __init__ method.
|
36
24
|
# def __init__(self, *args, **kwargs):
|
37
25
|
|
@@ -48,10 +36,7 @@ class View:
|
|
48
36
|
def view(request, *url_args, **url_kwargs):
|
49
37
|
v = cls(*init_args, **init_kwargs)
|
50
38
|
v.setup(request, *url_args, **url_kwargs)
|
51
|
-
|
52
|
-
return v.get_response()
|
53
|
-
except ResponseException as e:
|
54
|
-
return e.response
|
39
|
+
return v.get_response()
|
55
40
|
|
56
41
|
view.view_class = cls
|
57
42
|
|
@@ -63,43 +48,49 @@ class View:
|
|
63
48
|
if not self.request.method:
|
64
49
|
raise AttributeError("HTTP method is not set")
|
65
50
|
|
66
|
-
|
51
|
+
return getattr(self, self.request.method.lower(), None)
|
52
|
+
|
53
|
+
def get_response(self) -> ResponseBase:
|
54
|
+
handler = self.get_request_handler()
|
67
55
|
|
68
|
-
if not handler
|
56
|
+
if not handler:
|
69
57
|
logger.warning(
|
70
58
|
"Method Not Allowed (%s): %s",
|
71
59
|
self.request.method,
|
72
60
|
self.request.path,
|
73
61
|
extra={"status_code": 405, "request": self.request},
|
74
62
|
)
|
75
|
-
|
63
|
+
return ResponseNotAllowed(self._allowed_methods())
|
76
64
|
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
65
|
+
try:
|
66
|
+
result = handler()
|
67
|
+
except ResponseException as e:
|
68
|
+
return e.response
|
81
69
|
|
82
|
-
|
70
|
+
return self.convert_value_to_response(result)
|
83
71
|
|
84
|
-
|
85
|
-
|
72
|
+
def convert_value_to_response(self, value) -> ResponseBase:
|
73
|
+
"""Convert a value to a Response."""
|
74
|
+
if isinstance(value, ResponseBase):
|
75
|
+
return value
|
86
76
|
|
87
|
-
if isinstance(
|
88
|
-
# Assume
|
89
|
-
return Response(
|
77
|
+
if isinstance(value, str):
|
78
|
+
# Assume a str is rendered HTML
|
79
|
+
return Response(value)
|
90
80
|
|
91
|
-
if isinstance(
|
92
|
-
return JsonResponse(
|
81
|
+
if isinstance(value, list):
|
82
|
+
return JsonResponse(value, safe=False)
|
93
83
|
|
94
|
-
if isinstance(
|
95
|
-
return JsonResponse(
|
84
|
+
if isinstance(value, dict):
|
85
|
+
return JsonResponse(value)
|
96
86
|
|
97
|
-
if isinstance(
|
98
|
-
return Response(status_code=
|
87
|
+
if isinstance(value, int):
|
88
|
+
return Response(status_code=value)
|
99
89
|
|
100
|
-
|
90
|
+
if value is None:
|
91
|
+
return ResponseNotFound()
|
101
92
|
|
102
|
-
raise ValueError(f"Unexpected view return type: {type(
|
93
|
+
raise ValueError(f"Unexpected view return type: {type(value)}")
|
103
94
|
|
104
95
|
def options(self) -> Response:
|
105
96
|
"""Handle responding to requests for the OPTIONS HTTP verb."""
|
@@ -109,4 +100,14 @@ class View:
|
|
109
100
|
return response
|
110
101
|
|
111
102
|
def _allowed_methods(self) -> list[str]:
|
112
|
-
|
103
|
+
known_http_methods = [
|
104
|
+
"get",
|
105
|
+
"post",
|
106
|
+
"put",
|
107
|
+
"patch",
|
108
|
+
"delete",
|
109
|
+
"head",
|
110
|
+
"options",
|
111
|
+
"trace",
|
112
|
+
]
|
113
|
+
return [m.upper() for m in known_http_methods if hasattr(self, m)]
|
plain/views/forms.py
CHANGED
@@ -27,19 +27,11 @@ class FormView(TemplateView):
|
|
27
27
|
|
28
28
|
def get_form_kwargs(self) -> dict:
|
29
29
|
"""Return the keyword arguments for instantiating the form."""
|
30
|
-
|
31
|
-
"initial": {},
|
30
|
+
return {
|
31
|
+
"initial": {},
|
32
|
+
"request": self.request,
|
32
33
|
}
|
33
34
|
|
34
|
-
if hasattr(self, "request") and self.request.method in ("POST", "PUT"):
|
35
|
-
kwargs.update(
|
36
|
-
{
|
37
|
-
"data": self.request.POST,
|
38
|
-
"files": self.request.FILES,
|
39
|
-
}
|
40
|
-
)
|
41
|
-
return kwargs
|
42
|
-
|
43
35
|
def get_success_url(self, form: "BaseForm") -> str:
|
44
36
|
"""Return the URL to redirect to after processing a valid form."""
|
45
37
|
if not self.success_url:
|
@@ -7,7 +7,7 @@ plain/paginator.py,sha256=-fpLJd6c-V8bLCaNCHfTqPtm-Lm2Y1TuKqFDfy7n3ZE,5857
|
|
7
7
|
plain/signing.py,sha256=sf7g1Mp-FzdjFAEoLxHyu2YvbUl5w4FOtTVDAfq6TO0,8733
|
8
8
|
plain/validators.py,sha256=TePzFHzwR4JXUAZ_Y2vC6mkKgVxHX3QBXI6Oex0rV8c,19236
|
9
9
|
plain/wsgi.py,sha256=R6k5FiAElvGDApEbMPTT0MPqSD7n2e2Az5chQqJZU0I,236
|
10
|
-
plain/assets/README.md,sha256=
|
10
|
+
plain/assets/README.md,sha256=wgsmBK5vAHgFMZ010q7LTrgLq5BTfuPuuug5HLZ9GNw,3774
|
11
11
|
plain/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
12
|
plain/assets/compile.py,sha256=rABDHj7k_YAIPVvS7JJLCukEgUhb7h5oz9ajt5fGirI,3298
|
13
13
|
plain/assets/finders.py,sha256=2k8QZAbfUbc1LykxbzdazTSB6xNxJZnsZaGhWbSFZZs,1452
|
@@ -18,7 +18,7 @@ plain/cli/README.md,sha256=ompPcgzY2Fdpm579ITmCpFIaZIsiXYbfD61mqkq312M,1860
|
|
18
18
|
plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
|
19
19
|
plain/cli/build.py,sha256=dKUYBNegvb6QNckR7XZ7CJJtINwZcmDvbUdv2dWwjf8,3226
|
20
20
|
plain/cli/core.py,sha256=N7xUO8Ox2xz8wJyWZ0fSyCfqVFmKz9czH9gulRyQuM4,2809
|
21
|
-
plain/cli/docs.py,sha256=
|
21
|
+
plain/cli/docs.py,sha256=2CpTv5k-TNWf593tPiglvUVXWBGdfPmbGf8vl5AfJwU,8995
|
22
22
|
plain/cli/formatting.py,sha256=1hZH13y1qwHcU2K2_Na388nw9uvoeQH8LrWL-O9h8Yc,2207
|
23
23
|
plain/cli/preflight.py,sha256=NKyYjcoDjigzfJIDhf7A7degYadaUI05Bw7U9OQ73vs,4170
|
24
24
|
plain/cli/print.py,sha256=XraUYrgODOJquIiEv78wSCYGRBplHXtXSS9QtFG5hqY,217
|
@@ -29,15 +29,15 @@ plain/cli/shell.py,sha256=iIwvlTdTBjLBBUdXMAmIRWSoynszOZI79-mrBg4RegU,1373
|
|
29
29
|
plain/cli/startup.py,sha256=wLaFuyUb4ewWhtehBCGicrRCXIIGCRbeCT3ce9hUv-A,1022
|
30
30
|
plain/cli/urls.py,sha256=CS9NFpwZBWveAR8F3YsoUNySDEK_PwF73oSgLDfkOdI,3776
|
31
31
|
plain/cli/utils.py,sha256=VwlIh0z7XxzVV8I3qM2kZo07fkJFPoeeVZa1ODG616k,258
|
32
|
-
plain/csrf/README.md,sha256=
|
32
|
+
plain/csrf/README.md,sha256=nxCpPk1HF5eAM-7paxg9D-9RVCU9jXsSPAVHkJvA_DU,717
|
33
33
|
plain/csrf/middleware.py,sha256=bn8KOE45aucSam0m3F9g8FVfnmjjt-6jxS6iSNwHamU,17413
|
34
34
|
plain/csrf/views.py,sha256=HwQqfI6KPelHP9gSXhjfZaTLQic71PKsoZ6DPhr1rKI,572
|
35
35
|
plain/forms/README.md,sha256=TawBdy1jP0-8HUsfUzd7vvgkwl3EJGejDxFhWR8F-80,2242
|
36
36
|
plain/forms/__init__.py,sha256=UxqPwB8CiYPCQdHmUc59jadqaXqDmXBH8y4bt9vTPms,226
|
37
37
|
plain/forms/boundfield.py,sha256=LhydhCVR0okrli0-QBMjGjAJ8-06gTCXVEaBZhBouQk,1741
|
38
|
-
plain/forms/exceptions.py,sha256=
|
39
|
-
plain/forms/fields.py,sha256=
|
40
|
-
plain/forms/forms.py,sha256=
|
38
|
+
plain/forms/exceptions.py,sha256=NYk1wjYDkk-lA_XMJQDItOebQcL_m_r2eNRc2mkLQkg,315
|
39
|
+
plain/forms/fields.py,sha256=C71ed7m8VBnsgS-eIC6Xsmz7ruNSkdGa1BuSo7V3QKc,34574
|
40
|
+
plain/forms/forms.py,sha256=Zcl5euWWxuE-CTcOHsOVYixWESdVk7no2Sz40AxmudY,11204
|
41
41
|
plain/http/README.md,sha256=G662f56feQWiEHw9tTncZqK8kNB_nbR1HTGeOR1ygNM,713
|
42
42
|
plain/http/__init__.py,sha256=DIsDRbBsCGa4qZgq-fUuQS0kkxfbTU_3KpIM9VvH04w,1067
|
43
43
|
plain/http/cookie.py,sha256=11FnSG3Plo6T3jZDbPoCw7SKh9ExdBio3pTmIO03URg,597
|
@@ -61,7 +61,7 @@ plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
|
|
61
61
|
plain/internal/middleware/headers.py,sha256=ENIW1Gwat54hv-ejgp2R8QTZm-PlaI7k44WU01YQrNk,964
|
62
62
|
plain/internal/middleware/https.py,sha256=XpuQK8HicYX1jNanQHqNgyQ9rqe4NLUOZO3ZzKdsP8k,1203
|
63
63
|
plain/internal/middleware/slash.py,sha256=FMU8b9w0NSx4eJs9Y7Ew6RAoSTbUqe2oOM68kg3wOng,2817
|
64
|
-
plain/logs/README.md,sha256=
|
64
|
+
plain/logs/README.md,sha256=wXqqAQym4g6hUiJOothj-t0RacZLf2-o9JskoZhsehA,1276
|
65
65
|
plain/logs/__init__.py,sha256=rASvo4qFBDIHfkACmGLNGa6lRGbG9PbNjW6FmBt95ys,168
|
66
66
|
plain/logs/configure.py,sha256=6mV7d1IxkDYT3VBz61qhIj0Esuy5l5QdQfsHaGCfI6w,1063
|
67
67
|
plain/logs/loggers.py,sha256=iz9SYcwP9w5QAuwpULl48SFkVyJuuMoQ_fdLgdCHpNg,2121
|
@@ -132,18 +132,18 @@ plain/utils/text.py,sha256=42hJv06sadbWfsaAHNhqCQaP1W9qZ69trWDTS-Xva7k,9496
|
|
132
132
|
plain/utils/timesince.py,sha256=a_-ZoPK_s3Pt998CW4rWp0clZ1XyK2x04hCqak2giII,5928
|
133
133
|
plain/utils/timezone.py,sha256=6u0sE-9RVp0_OCe0Y1KiYYQoq5THWLokZFQYY8jf78g,6221
|
134
134
|
plain/utils/tree.py,sha256=wdWzmfsgc26YDF2wxhAY3yVxXTixQYqYDKE9mL3L3ZY,4383
|
135
|
-
plain/views/README.md,sha256=
|
135
|
+
plain/views/README.md,sha256=_jR_8_eccE1Qwc9sbUhD_hpZGGf0r-HY4W-al6kqtGs,6762
|
136
136
|
plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
|
137
|
-
plain/views/base.py,sha256=
|
137
|
+
plain/views/base.py,sha256=bFSSZABqANn3DPrUa97p6XxzXh0My-FSJSWjvLoFljQ,3240
|
138
138
|
plain/views/csrf.py,sha256=7q6l5xzLWhRnMY64aNj0hR6G-3pxI2yhRwG6k_5j00E,144
|
139
139
|
plain/views/errors.py,sha256=jbNCJIzowwCsEvqyJ3opMeZpPDqTyhtrbqb0VnAm2HE,1263
|
140
140
|
plain/views/exceptions.py,sha256=b4euI49ZUKS9O8AGAcFfiDpstzkRAuuj_uYQXzWNHME,138
|
141
|
-
plain/views/forms.py,sha256=
|
141
|
+
plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
|
142
142
|
plain/views/objects.py,sha256=GGbcfg_9fPZ-PiaBwIHG2e__8GfWDR7JQtQ15wTyiHg,5970
|
143
143
|
plain/views/redirect.py,sha256=vMXx8430FtyKcT0V0gyY92SkLtyULBX52KhX4eu4gEA,1985
|
144
144
|
plain/views/templates.py,sha256=kMcHKkKNvucF91SFGkaq-ugjrCwn4zJBpFV1JkwA544,2027
|
145
|
-
plain-0.
|
146
|
-
plain-0.
|
147
|
-
plain-0.
|
148
|
-
plain-0.
|
149
|
-
plain-0.
|
145
|
+
plain-0.38.0.dist-info/METADATA,sha256=UXCQr1j9RRhGH4fDnS65T0j-DSZIGHOVjt8XeWq5GT0,4297
|
146
|
+
plain-0.38.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
147
|
+
plain-0.38.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
|
148
|
+
plain-0.38.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
149
|
+
plain-0.38.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|