plain 0.37.0__py3-none-any.whl → 0.39.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 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?
@@ -0,0 +1,3 @@
1
+ from .registry import register_chore
2
+
3
+ __all__ = ["register_chore"]
@@ -0,0 +1,79 @@
1
+ from importlib import import_module
2
+ from importlib.util import find_spec
3
+
4
+ from plain.packages import packages_registry
5
+
6
+
7
+ class Chore:
8
+ def __init__(self, *, group, func):
9
+ self.group = group
10
+ self.func = func
11
+ self.name = f"{group}.{func.__name__}"
12
+ self.description = func.__doc__.strip() if func.__doc__ else ""
13
+
14
+ def __str__(self):
15
+ return self.name
16
+
17
+ def run(self):
18
+ """
19
+ Run the chore.
20
+ """
21
+ return self.func()
22
+
23
+
24
+ class ChoresRegistry:
25
+ def __init__(self):
26
+ self._chores = {}
27
+
28
+ def register_chore(self, chore):
29
+ """
30
+ Register a chore with the specified name.
31
+ """
32
+ self._chores[chore.func] = chore
33
+
34
+ def import_modules(self):
35
+ """
36
+ Import modules from installed packages and app to trigger registration.
37
+ """
38
+ # Import from installed packages
39
+ for package_config in packages_registry.get_package_configs():
40
+ import_name = f"{package_config.name}.chores"
41
+ try:
42
+ import_module(import_name)
43
+ except ModuleNotFoundError:
44
+ pass
45
+
46
+ # Import from app
47
+ import_name = "app.chores"
48
+ if find_spec(import_name):
49
+ try:
50
+ import_module(import_name)
51
+ except ModuleNotFoundError:
52
+ pass
53
+
54
+ def get_chores(self):
55
+ """
56
+ Get all registered chores.
57
+ """
58
+ return list(self._chores.values())
59
+
60
+
61
+ chores_registry = ChoresRegistry()
62
+
63
+
64
+ def register_chore(group):
65
+ """
66
+ Register a chore with a given group.
67
+
68
+ Usage:
69
+ @register_chore("clear_expired")
70
+ def clear_expired():
71
+ pass
72
+ """
73
+
74
+ def wrapper(func):
75
+ chore = Chore(group=group, func=func)
76
+ chores_registry.register_chore(chore)
77
+ return func
78
+
79
+ return wrapper
plain/cli/chores.py ADDED
@@ -0,0 +1,91 @@
1
+ import logging
2
+ import sys
3
+
4
+ import click
5
+
6
+ logger = logging.getLogger("plain.chores")
7
+
8
+
9
+ @click.group()
10
+ def chores():
11
+ """Routine maintenance tasks"""
12
+ pass
13
+
14
+
15
+ @chores.command("list")
16
+ @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
17
+ @click.option(
18
+ "--name", default=None, type=str, help="Name of the chore to run", multiple=True
19
+ )
20
+ def list_chores(group, name):
21
+ """
22
+ List all registered chores.
23
+ """
24
+ from plain.chores.registry import chores_registry
25
+
26
+ chores_registry.import_modules()
27
+
28
+ if group or name:
29
+ chores = [
30
+ chore
31
+ for chore in chores_registry.get_chores()
32
+ if (chore.group in group or not group) and (chore.name in name or not name)
33
+ ]
34
+ else:
35
+ chores = chores_registry.get_chores()
36
+
37
+ for chore in chores:
38
+ click.secho(f"{chore}", bold=True, nl=False)
39
+ if chore.description:
40
+ click.echo(f": {chore.description}")
41
+ else:
42
+ click.echo("")
43
+
44
+
45
+ @chores.command("run")
46
+ @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
47
+ @click.option(
48
+ "--name", default=None, type=str, help="Name of the chore to run", multiple=True
49
+ )
50
+ @click.option(
51
+ "--dry-run", is_flag=True, help="Show what would be done without executing"
52
+ )
53
+ def run_chores(group, name, dry_run):
54
+ """
55
+ Run the specified chores.
56
+ """
57
+ from plain.chores.registry import chores_registry
58
+
59
+ chores_registry.import_modules()
60
+
61
+ if group or name:
62
+ chores = [
63
+ chore
64
+ for chore in chores_registry.get_chores()
65
+ if (chore.group in group or not group) and (chore.name in name or not name)
66
+ ]
67
+ else:
68
+ chores = chores_registry.get_chores()
69
+
70
+ chores_failed = []
71
+
72
+ for chore in chores:
73
+ click.echo(f"{chore.name}:", nl=False)
74
+ if dry_run:
75
+ click.echo(" (dry run)", fg="yellow")
76
+ else:
77
+ try:
78
+ result = chore.run()
79
+ except Exception:
80
+ click.secho(" Failed", fg="red")
81
+ chores_failed.append(chore)
82
+ logger.exception(f"Error running chore {chore.name}")
83
+ continue
84
+
85
+ if result is None:
86
+ click.secho(" Done", fg="green")
87
+ else:
88
+ click.secho(f" {result}", fg="green")
89
+
90
+ if chores_failed:
91
+ sys.exit(1)
plain/cli/core.py CHANGED
@@ -7,6 +7,7 @@ import plain.runtime
7
7
  from plain.exceptions import ImproperlyConfigured
8
8
 
9
9
  from .build import build
10
+ from .chores import chores
10
11
  from .docs import docs
11
12
  from .formatting import PlainContext
12
13
  from .preflight import preflight_checks
@@ -26,6 +27,7 @@ def plain_cli():
26
27
  plain_cli.add_command(docs)
27
28
  plain_cli.add_command(preflight_checks)
28
29
  plain_cli.add_command(create)
30
+ plain_cli.add_command(chores)
29
31
  plain_cli.add_command(build)
30
32
  plain_cli.add_command(utils)
31
33
  plain_cli.add_command(urls)
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
- click.echo(
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
- for path in Path(package_config.path).glob("**/*.md"):
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
- for source in sources:
137
- if symbolicated := symbolicate(source):
138
- try:
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 `X-CSRFToken` header).
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
@@ -2,7 +2,9 @@ from plain.exceptions import ValidationError
2
2
 
3
3
 
4
4
  class FormFieldMissingError(Exception):
5
- pass
5
+ def __init__(self, field_name):
6
+ self.field_name = field_name
7
+ self.message = f'The "{self.field_name}" field is missing from the form data.'
6
8
 
7
9
 
8
10
  __all__ = [
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
- # disabled -- Boolean that specifies whether the field is disabled, that
84
- # is its widget is shown in the form but not editable.
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
- f'The "{html_name}" field is missing from the form data.'
182
- ) from e
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 not self.disabled and data is not None
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
- data=None,
61
- files=None,
61
+ *,
62
+ request,
62
63
  auto_id="id_%s",
63
64
  prefix=None,
64
65
  initial=None,
65
66
  ):
66
- self.is_bound = data is not None or files is not None
67
- self.data = MultiValueDict() if data is None else data
68
- self.files = MultiValueDict() if files is None else files
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
- return field.value_from_form_data(self.data, self.files, html_name)
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
- if field.disabled:
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 plain text and JSON responses,
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 TextView(View):
58
+ class JsonView(View):
59
59
  def get(self):
60
- return "Hello, world!"
60
+ return {"message": "Hello, world!"}
61
61
 
62
62
 
63
- class JsonView(View):
63
+ class HtmlView(View):
64
64
  def get(self):
65
- return {"message": "Hello, world!"}
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
- ## CSRF exemption
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
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ from http import HTTPMethod
2
3
 
3
4
  from plain.http import (
4
5
  HttpRequest,
@@ -6,6 +7,7 @@ from plain.http import (
6
7
  Response,
7
8
  ResponseBase,
8
9
  ResponseNotAllowed,
10
+ ResponseNotFound,
9
11
  )
10
12
  from plain.utils.decorators import classonlymethod
11
13
 
@@ -19,19 +21,6 @@ class View:
19
21
  url_args: tuple
20
22
  url_kwargs: dict
21
23
 
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
24
  # View.as_view(example="foo") usage can be customized by defining your own __init__ method.
36
25
  # def __init__(self, *args, **kwargs):
37
26
 
@@ -48,10 +37,7 @@ class View:
48
37
  def view(request, *url_args, **url_kwargs):
49
38
  v = cls(*init_args, **init_kwargs)
50
39
  v.setup(request, *url_args, **url_kwargs)
51
- try:
52
- return v.get_response()
53
- except ResponseException as e:
54
- return e.response
40
+ return v.get_response()
55
41
 
56
42
  view.view_class = cls
57
43
 
@@ -63,43 +49,49 @@ class View:
63
49
  if not self.request.method:
64
50
  raise AttributeError("HTTP method is not set")
65
51
 
66
- handler = getattr(self, self.request.method.lower(), None)
52
+ return getattr(self, self.request.method.lower(), None)
53
+
54
+ def get_response(self) -> ResponseBase:
55
+ handler = self.get_request_handler()
67
56
 
68
- if not handler or self.request.method.lower() not in self.allowed_http_methods:
57
+ if not handler:
69
58
  logger.warning(
70
59
  "Method Not Allowed (%s): %s",
71
60
  self.request.method,
72
61
  self.request.path,
73
62
  extra={"status_code": 405, "request": self.request},
74
63
  )
75
- raise ResponseException(ResponseNotAllowed(self._allowed_methods()))
64
+ return ResponseNotAllowed(self._allowed_methods())
76
65
 
77
- return handler
78
-
79
- def get_response(self) -> ResponseBase:
80
- handler = self.get_request_handler()
66
+ try:
67
+ result = handler()
68
+ except ResponseException as e:
69
+ return e.response
81
70
 
82
- result = handler()
71
+ return self.convert_value_to_response(result)
83
72
 
84
- if isinstance(result, ResponseBase):
85
- return result
73
+ def convert_value_to_response(self, value) -> ResponseBase:
74
+ """Convert a value to a Response."""
75
+ if isinstance(value, ResponseBase):
76
+ return value
86
77
 
87
- if isinstance(result, str):
88
- # Assume the str is rendered HTML
89
- return Response(result)
78
+ if isinstance(value, str):
79
+ # Assume a str is rendered HTML
80
+ return Response(value)
90
81
 
91
- if isinstance(result, list):
92
- return JsonResponse(result, safe=False)
82
+ if isinstance(value, list):
83
+ return JsonResponse(value, safe=False)
93
84
 
94
- if isinstance(result, dict):
95
- return JsonResponse(result)
85
+ if isinstance(value, dict):
86
+ return JsonResponse(value)
96
87
 
97
- if isinstance(result, int):
98
- return Response(status_code=result)
88
+ if isinstance(value, int):
89
+ return Response(status_code=value)
99
90
 
100
- # Allow tuple for (status_code, content)?
91
+ if value is None:
92
+ return ResponseNotFound()
101
93
 
102
- raise ValueError(f"Unexpected view return type: {type(result)}")
94
+ raise ValueError(f"Unexpected view return type: {type(value)}")
103
95
 
104
96
  def options(self) -> Response:
105
97
  """Handle responding to requests for the OPTIONS HTTP verb."""
@@ -109,4 +101,4 @@ class View:
109
101
  return response
110
102
 
111
103
  def _allowed_methods(self) -> list[str]:
112
- return [m.upper() for m in self.allowed_http_methods if hasattr(self, m)]
104
+ return [m.upper() for m in HTTPMethod if hasattr(self, m.lower())]
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
- kwargs: dict = {
31
- "initial": {}, # Makes it easier to set keys in subclasses
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.37.0
3
+ Version: 0.39.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -7,18 +7,21 @@ 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=8JDbusQPWu4HneNPMJ-vQ30r2FdVBD72mSL-Fn82CAM,3328
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
14
14
  plain/assets/fingerprints.py,sha256=2LPHLUkoITMseLDmemTpBtMRDWCR2H5GAHjC6AN4gz0,1367
15
15
  plain/assets/urls.py,sha256=zQUA8bAlh9qVqskPJJrqWd9DjvetOi5jPSqy4vUX0J4,1161
16
16
  plain/assets/views.py,sha256=T_0Qh6v9qBerEBYbhToigwOzsij-x1z_R-1zETQcIh0,9447
17
+ plain/chores/__init__.py,sha256=r9TXtQCH-VbvfnIJ5F8FxgQC35GRWFOfmMZN3q9niLg,67
18
+ plain/chores/registry.py,sha256=V3WjuekRI22LFvJbqSkUXQtiOtuE2ZK8gKV1TRvxRUI,1866
17
19
  plain/cli/README.md,sha256=ompPcgzY2Fdpm579ITmCpFIaZIsiXYbfD61mqkq312M,1860
18
20
  plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
19
21
  plain/cli/build.py,sha256=dKUYBNegvb6QNckR7XZ7CJJtINwZcmDvbUdv2dWwjf8,3226
20
- plain/cli/core.py,sha256=N7xUO8Ox2xz8wJyWZ0fSyCfqVFmKz9czH9gulRyQuM4,2809
21
- plain/cli/docs.py,sha256=dHInHcweVLOew3hCNspVvUQ8epEfovDh3yI6O_FEsnI,7546
22
+ plain/cli/chores.py,sha256=xXSSFvr8T5jWfLWqe6E8YVMw1BkQxyOHHVuY0x9RH0A,2412
23
+ plain/cli/core.py,sha256=sy5otyN5jNOHkcy1EXISB7iMr1itp0rzmBSnEXYGe2k,2866
24
+ plain/cli/docs.py,sha256=2CpTv5k-TNWf593tPiglvUVXWBGdfPmbGf8vl5AfJwU,8995
22
25
  plain/cli/formatting.py,sha256=1hZH13y1qwHcU2K2_Na388nw9uvoeQH8LrWL-O9h8Yc,2207
23
26
  plain/cli/preflight.py,sha256=NKyYjcoDjigzfJIDhf7A7degYadaUI05Bw7U9OQ73vs,4170
24
27
  plain/cli/print.py,sha256=XraUYrgODOJquIiEv78wSCYGRBplHXtXSS9QtFG5hqY,217
@@ -29,15 +32,15 @@ plain/cli/shell.py,sha256=iIwvlTdTBjLBBUdXMAmIRWSoynszOZI79-mrBg4RegU,1373
29
32
  plain/cli/startup.py,sha256=wLaFuyUb4ewWhtehBCGicrRCXIIGCRbeCT3ce9hUv-A,1022
30
33
  plain/cli/urls.py,sha256=CS9NFpwZBWveAR8F3YsoUNySDEK_PwF73oSgLDfkOdI,3776
31
34
  plain/cli/utils.py,sha256=VwlIh0z7XxzVV8I3qM2kZo07fkJFPoeeVZa1ODG616k,258
32
- plain/csrf/README.md,sha256=Xqb3pRZcIF6TJlOzEksQN0zue4mY7OTebNta9cq0T8g,699
35
+ plain/csrf/README.md,sha256=nxCpPk1HF5eAM-7paxg9D-9RVCU9jXsSPAVHkJvA_DU,717
33
36
  plain/csrf/middleware.py,sha256=bn8KOE45aucSam0m3F9g8FVfnmjjt-6jxS6iSNwHamU,17413
34
37
  plain/csrf/views.py,sha256=HwQqfI6KPelHP9gSXhjfZaTLQic71PKsoZ6DPhr1rKI,572
35
38
  plain/forms/README.md,sha256=TawBdy1jP0-8HUsfUzd7vvgkwl3EJGejDxFhWR8F-80,2242
36
39
  plain/forms/__init__.py,sha256=UxqPwB8CiYPCQdHmUc59jadqaXqDmXBH8y4bt9vTPms,226
37
40
  plain/forms/boundfield.py,sha256=LhydhCVR0okrli0-QBMjGjAJ8-06gTCXVEaBZhBouQk,1741
38
- plain/forms/exceptions.py,sha256=XCLDRl5snIEDu5-8mLB0NnU_tegcBfyIHMiJxqvbxnc,164
39
- plain/forms/fields.py,sha256=Fw77LP06aO5h6ZdJmS2S_2On4YSrsl4gu142Y6nGF50,34987
40
- plain/forms/forms.py,sha256=fEKBee1b8I_DJ-FufzWJGtSQoUoyieYfqUaGEre9B4Q,10418
41
+ plain/forms/exceptions.py,sha256=NYk1wjYDkk-lA_XMJQDItOebQcL_m_r2eNRc2mkLQkg,315
42
+ plain/forms/fields.py,sha256=C71ed7m8VBnsgS-eIC6Xsmz7ruNSkdGa1BuSo7V3QKc,34574
43
+ plain/forms/forms.py,sha256=Zcl5euWWxuE-CTcOHsOVYixWESdVk7no2Sz40AxmudY,11204
41
44
  plain/http/README.md,sha256=G662f56feQWiEHw9tTncZqK8kNB_nbR1HTGeOR1ygNM,713
42
45
  plain/http/__init__.py,sha256=DIsDRbBsCGa4qZgq-fUuQS0kkxfbTU_3KpIM9VvH04w,1067
43
46
  plain/http/cookie.py,sha256=11FnSG3Plo6T3jZDbPoCw7SKh9ExdBio3pTmIO03URg,597
@@ -61,7 +64,7 @@ plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJW
61
64
  plain/internal/middleware/headers.py,sha256=ENIW1Gwat54hv-ejgp2R8QTZm-PlaI7k44WU01YQrNk,964
62
65
  plain/internal/middleware/https.py,sha256=XpuQK8HicYX1jNanQHqNgyQ9rqe4NLUOZO3ZzKdsP8k,1203
63
66
  plain/internal/middleware/slash.py,sha256=FMU8b9w0NSx4eJs9Y7Ew6RAoSTbUqe2oOM68kg3wOng,2817
64
- plain/logs/README.md,sha256=C1eeqIolTUyaQPLFy8PpewOnLWwOaaqXH9nC9S6n9lk,1274
67
+ plain/logs/README.md,sha256=wXqqAQym4g6hUiJOothj-t0RacZLf2-o9JskoZhsehA,1276
65
68
  plain/logs/__init__.py,sha256=rASvo4qFBDIHfkACmGLNGa6lRGbG9PbNjW6FmBt95ys,168
66
69
  plain/logs/configure.py,sha256=6mV7d1IxkDYT3VBz61qhIj0Esuy5l5QdQfsHaGCfI6w,1063
67
70
  plain/logs/loggers.py,sha256=iz9SYcwP9w5QAuwpULl48SFkVyJuuMoQ_fdLgdCHpNg,2121
@@ -132,18 +135,18 @@ plain/utils/text.py,sha256=42hJv06sadbWfsaAHNhqCQaP1W9qZ69trWDTS-Xva7k,9496
132
135
  plain/utils/timesince.py,sha256=a_-ZoPK_s3Pt998CW4rWp0clZ1XyK2x04hCqak2giII,5928
133
136
  plain/utils/timezone.py,sha256=6u0sE-9RVp0_OCe0Y1KiYYQoq5THWLokZFQYY8jf78g,6221
134
137
  plain/utils/tree.py,sha256=wdWzmfsgc26YDF2wxhAY3yVxXTixQYqYDKE9mL3L3ZY,4383
135
- plain/views/README.md,sha256=mveTtHrd2z3knSAchW2jTwfg59_bowwhAacx-3QBBHw,6003
138
+ plain/views/README.md,sha256=_jR_8_eccE1Qwc9sbUhD_hpZGGf0r-HY4W-al6kqtGs,6762
136
139
  plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
137
- plain/views/base.py,sha256=LomceHi01okLk41YfUr6cBE0VB5FNsSVODTOQ-Rmv0I,3280
140
+ plain/views/base.py,sha256=bF_yBHrBfOdq3OcA_CTGCs7hNI1O6LpDVLM2Pkzyfjs,3062
138
141
  plain/views/csrf.py,sha256=7q6l5xzLWhRnMY64aNj0hR6G-3pxI2yhRwG6k_5j00E,144
139
142
  plain/views/errors.py,sha256=jbNCJIzowwCsEvqyJ3opMeZpPDqTyhtrbqb0VnAm2HE,1263
140
143
  plain/views/exceptions.py,sha256=b4euI49ZUKS9O8AGAcFfiDpstzkRAuuj_uYQXzWNHME,138
141
- plain/views/forms.py,sha256=5L6dYkwcZFMD3-w_QC2QDElo9hhSPrhVVFq9CB5yL9k,2692
144
+ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
142
145
  plain/views/objects.py,sha256=GGbcfg_9fPZ-PiaBwIHG2e__8GfWDR7JQtQ15wTyiHg,5970
143
146
  plain/views/redirect.py,sha256=vMXx8430FtyKcT0V0gyY92SkLtyULBX52KhX4eu4gEA,1985
144
147
  plain/views/templates.py,sha256=kMcHKkKNvucF91SFGkaq-ugjrCwn4zJBpFV1JkwA544,2027
145
- plain-0.37.0.dist-info/METADATA,sha256=Oj1bF-2a2hpbWTokTcq-TFKjKyfaZhvpBFSqTxapSNU,4297
146
- plain-0.37.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
147
- plain-0.37.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
148
- plain-0.37.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
149
- plain-0.37.0.dist-info/RECORD,,
148
+ plain-0.39.0.dist-info/METADATA,sha256=DlIAs31jS4S87R0UhAlZlYdgxZGKOebjo3IcgK5yas4,4297
149
+ plain-0.39.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
150
+ plain-0.39.0.dist-info/entry_points.txt,sha256=1Ys2lsSeMepD1vz8RSrJopna0RQfUd951vYvCRsvl6A,45
151
+ plain-0.39.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
152
+ plain-0.39.0.dist-info/RECORD,,
File without changes