plain 0.34.1__py3-none-any.whl → 0.36.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/cli/docs.py ADDED
@@ -0,0 +1,211 @@
1
+ import ast
2
+ import importlib.util
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from plain.packages import packages_registry
9
+
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
+ @click.command()
86
+ @click.option("--llm", "llm", is_flag=True)
87
+ @click.option("--open")
88
+ @click.argument("module", default="")
89
+ def docs(module, llm, open):
90
+ if not module and not llm:
91
+ click.secho("You must specify a module or use --llm", fg="red")
92
+ sys.exit(1)
93
+
94
+ 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
+ )
101
+
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
+ for package_config in packages_registry.get_package_configs():
113
+ if package_config.name.startswith("app."):
114
+ # Ignore app packages for now
115
+ continue
116
+
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()
135
+
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()
146
+
147
+ click.secho(
148
+ "That's everything! Copy this into your AI tool of choice.",
149
+ err=True,
150
+ fg="green",
151
+ )
152
+
153
+ return
154
+
155
+ if module:
156
+ # Automatically prefix if we need to
157
+ if not module.startswith("plain"):
158
+ module = f"plain.{module}"
159
+
160
+ # Get the README.md file for the module
161
+ spec = importlib.util.find_spec(module)
162
+ if not spec:
163
+ click.secho(f"Module {module} not found", fg="red")
164
+ sys.exit(1)
165
+
166
+ module_path = Path(spec.origin).parent
167
+ readme_path = module_path / "README.md"
168
+ if not readme_path.exists():
169
+ click.secho(f"README.md not found for {module}", fg="red")
170
+ sys.exit(1)
171
+
172
+ if open:
173
+ click.launch(str(readme_path))
174
+ else:
175
+
176
+ def _iterate_markdown(content):
177
+ """
178
+ Iterator that does basic markdown for a Click pager.
179
+
180
+ Headings are yellow and bright, code blocks are indented.
181
+ """
182
+
183
+ in_code_block = False
184
+ for line in content.splitlines():
185
+ if line.startswith("```"):
186
+ in_code_block = not in_code_block
187
+
188
+ if in_code_block:
189
+ yield click.style(line, dim=True)
190
+ elif line.startswith("# "):
191
+ yield click.style(line, fg="yellow", bold=True)
192
+ elif line.startswith("## "):
193
+ yield click.style(line, fg="yellow", bold=True)
194
+ elif line.startswith("### "):
195
+ yield click.style(line, fg="yellow", bold=True)
196
+ elif line.startswith("#### "):
197
+ yield click.style(line, fg="yellow", bold=True)
198
+ elif line.startswith("##### "):
199
+ yield click.style(line, fg="yellow", bold=True)
200
+ elif line.startswith("###### "):
201
+ yield click.style(line, fg="yellow", bold=True)
202
+ elif line.startswith("**") and line.endswith("**"):
203
+ yield click.style(line, bold=True)
204
+ elif line.startswith("> "):
205
+ yield click.style(line, italic=True)
206
+ else:
207
+ yield line
208
+
209
+ yield "\n"
210
+
211
+ click.echo_via_pager(_iterate_markdown(readme_path.read_text()))
plain/cli/preflight.py ADDED
@@ -0,0 +1,127 @@
1
+ import click
2
+
3
+ from plain import preflight
4
+ from plain.packages import packages_registry
5
+
6
+
7
+ @click.command("preflight")
8
+ @click.argument("package_label", nargs=-1)
9
+ @click.option(
10
+ "--deploy",
11
+ is_flag=True,
12
+ help="Check deployment settings.",
13
+ )
14
+ @click.option(
15
+ "--fail-level",
16
+ default="ERROR",
17
+ type=click.Choice(["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]),
18
+ help="Message level that will cause the command to exit with a non-zero status. Default is ERROR.",
19
+ )
20
+ @click.option(
21
+ "--database",
22
+ "databases",
23
+ multiple=True,
24
+ help="Run database related checks against these aliases.",
25
+ )
26
+ def preflight_checks(package_label, deploy, fail_level, databases):
27
+ """
28
+ Use the system check framework to validate entire Plain project.
29
+ Raise CommandError for any serious message (error or critical errors).
30
+ If there are only light messages (like warnings), print them to stderr
31
+ and don't raise an exception.
32
+ """
33
+ include_deployment_checks = deploy
34
+
35
+ if package_label:
36
+ package_configs = [
37
+ packages_registry.get_package_config(label) for label in package_label
38
+ ]
39
+ else:
40
+ package_configs = None
41
+
42
+ all_issues = preflight.run_checks(
43
+ package_configs=package_configs,
44
+ include_deployment_checks=include_deployment_checks,
45
+ databases=databases,
46
+ )
47
+
48
+ header, body, footer = "", "", ""
49
+ visible_issue_count = 0 # excludes silenced warnings
50
+
51
+ if all_issues:
52
+ debugs = [
53
+ e for e in all_issues if e.level < preflight.INFO and not e.is_silenced()
54
+ ]
55
+ infos = [
56
+ e
57
+ for e in all_issues
58
+ if preflight.INFO <= e.level < preflight.WARNING and not e.is_silenced()
59
+ ]
60
+ warnings = [
61
+ e
62
+ for e in all_issues
63
+ if preflight.WARNING <= e.level < preflight.ERROR and not e.is_silenced()
64
+ ]
65
+ errors = [
66
+ e
67
+ for e in all_issues
68
+ if preflight.ERROR <= e.level < preflight.CRITICAL and not e.is_silenced()
69
+ ]
70
+ criticals = [
71
+ e
72
+ for e in all_issues
73
+ if preflight.CRITICAL <= e.level and not e.is_silenced()
74
+ ]
75
+ sorted_issues = [
76
+ (criticals, "CRITICALS"),
77
+ (errors, "ERRORS"),
78
+ (warnings, "WARNINGS"),
79
+ (infos, "INFOS"),
80
+ (debugs, "DEBUGS"),
81
+ ]
82
+
83
+ for issues, group_name in sorted_issues:
84
+ if issues:
85
+ visible_issue_count += len(issues)
86
+ formatted = (
87
+ click.style(str(e), fg="red")
88
+ if e.is_serious()
89
+ else click.style(str(e), fg="yellow")
90
+ for e in issues
91
+ )
92
+ formatted = "\n".join(sorted(formatted))
93
+ body += f"\n{group_name}:\n{formatted}\n"
94
+
95
+ if visible_issue_count:
96
+ header = "Preflight check identified some issues:\n"
97
+
98
+ if any(
99
+ e.is_serious(getattr(preflight, fail_level)) and not e.is_silenced()
100
+ for e in all_issues
101
+ ):
102
+ footer += "\n"
103
+ footer += "Preflight check identified {} ({} silenced).".format(
104
+ "no issues"
105
+ if visible_issue_count == 0
106
+ else "1 issue"
107
+ if visible_issue_count == 1
108
+ else f"{visible_issue_count} issues",
109
+ len(all_issues) - visible_issue_count,
110
+ )
111
+ msg = click.style(f"SystemCheckError: {header}", fg="red") + body + footer
112
+ raise click.ClickException(msg)
113
+ else:
114
+ if visible_issue_count:
115
+ footer += "\n"
116
+ footer += "Preflight check identified {} ({} silenced).".format(
117
+ "no issues"
118
+ if visible_issue_count == 0
119
+ else "1 issue"
120
+ if visible_issue_count == 1
121
+ else f"{visible_issue_count} issues",
122
+ len(all_issues) - visible_issue_count,
123
+ )
124
+ msg = header + body + footer
125
+ click.echo(msg, err=True)
126
+ else:
127
+ click.secho("✔ Preflight check identified no issues.", err=True, fg="green")
plain/cli/scaffold.py ADDED
@@ -0,0 +1,53 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ import plain.runtime
6
+
7
+
8
+ @click.command()
9
+ @click.argument("package_name")
10
+ def create(package_name):
11
+ """
12
+ Create a new local package.
13
+
14
+ The PACKAGE_NAME is typically a plural noun, like "users" or "posts",
15
+ where you might create a "User" or "Post" model inside of the package.
16
+ """
17
+ package_dir = plain.runtime.APP_PATH / package_name
18
+ package_dir.mkdir(exist_ok=True)
19
+
20
+ empty_dirs = (
21
+ f"templates/{package_name}",
22
+ "migrations",
23
+ )
24
+ for d in empty_dirs:
25
+ (package_dir / d).mkdir(parents=True, exist_ok=True)
26
+
27
+ empty_files = (
28
+ "__init__.py",
29
+ "migrations/__init__.py",
30
+ "models.py",
31
+ "views.py",
32
+ )
33
+ for f in empty_files:
34
+ (package_dir / f).touch(exist_ok=True)
35
+
36
+ # Create a urls.py file with a default namespace
37
+ if not (package_dir / "urls.py").exists():
38
+ (package_dir / "urls.py").write_text(
39
+ f"""from plain.urls import path, Router
40
+
41
+
42
+ class {package_name.capitalize()}Router(Router):
43
+ namespace = f"{package_name}"
44
+ urls = [
45
+ # path("", views.IndexView, name="index"),
46
+ ]
47
+ """
48
+ )
49
+
50
+ click.secho(
51
+ f'Created {package_dir.relative_to(Path.cwd())}. Make sure to add "{package_name}" to INSTALLED_PACKAGES!',
52
+ fg="green",
53
+ )
plain/cli/settings.py ADDED
@@ -0,0 +1,60 @@
1
+ import click
2
+
3
+ import plain.runtime
4
+
5
+
6
+ @click.command()
7
+ @click.argument("setting_name")
8
+ def setting(setting_name):
9
+ """Print the value of a setting at runtime"""
10
+ try:
11
+ setting = getattr(plain.runtime.settings, setting_name)
12
+ click.echo(setting)
13
+ except AttributeError:
14
+ click.secho(f'Setting "{setting_name}" not found', fg="red")
15
+
16
+
17
+ # @plain_cli.command()
18
+ # @click.option("--filter", "-f", "name_filter", help="Filter settings by name")
19
+ # @click.option("--overridden", is_flag=True, help="Only show overridden settings")
20
+ # def settings(name_filter, overridden):
21
+ # """Print Plain settings"""
22
+ # table = Table(box=box.MINIMAL)
23
+ # table.add_column("Setting")
24
+ # table.add_column("Default value")
25
+ # table.add_column("App value")
26
+ # table.add_column("Type")
27
+ # table.add_column("Module")
28
+
29
+ # for setting in dir(settings):
30
+ # if setting.isupper():
31
+ # if name_filter and name_filter.upper() not in setting:
32
+ # continue
33
+
34
+ # is_overridden = settings.is_overridden(setting)
35
+
36
+ # if overridden and not is_overridden:
37
+ # continue
38
+
39
+ # default_setting = settings._default_settings.get(setting)
40
+ # if default_setting:
41
+ # default_value = default_setting.value
42
+ # annotation = default_setting.annotation
43
+ # module = default_setting.module
44
+ # else:
45
+ # default_value = ""
46
+ # annotation = ""
47
+ # module = ""
48
+
49
+ # table.add_row(
50
+ # setting,
51
+ # Pretty(default_value) if default_value else "",
52
+ # Pretty(getattr(settings, setting))
53
+ # if is_overridden
54
+ # else Text("<Default>", style="italic dim"),
55
+ # Pretty(annotation) if annotation else "",
56
+ # str(module.__name__) if module else "",
57
+ # )
58
+
59
+ # console = Console()
60
+ # console.print(table)
plain/cli/shell.py ADDED
@@ -0,0 +1,56 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+
5
+ import click
6
+
7
+
8
+ @click.command()
9
+ @click.option(
10
+ "-i",
11
+ "--interface",
12
+ type=click.Choice(["ipython", "bpython", "python"]),
13
+ help="Specify an interactive interpreter interface.",
14
+ )
15
+ def shell(interface):
16
+ """
17
+ Runs a Python interactive interpreter. Tries to use IPython or
18
+ bpython, if one of them is available.
19
+ """
20
+
21
+ if interface:
22
+ interface = [interface]
23
+ else:
24
+
25
+ def get_default_interface():
26
+ try:
27
+ import IPython # noqa
28
+
29
+ return ["python", "-m", "IPython"]
30
+ except ImportError:
31
+ pass
32
+
33
+ return ["python"]
34
+
35
+ interface = get_default_interface()
36
+
37
+ result = subprocess.run(
38
+ interface,
39
+ env={
40
+ "PYTHONSTARTUP": os.path.join(os.path.dirname(__file__), "startup.py"),
41
+ **os.environ,
42
+ },
43
+ )
44
+ if result.returncode:
45
+ sys.exit(result.returncode)
46
+
47
+
48
+ @click.command()
49
+ @click.argument("script", nargs=1, type=click.Path(exists=True))
50
+ def run(script):
51
+ """Run a Python script in the context of your app"""
52
+ before_script = "import plain.runtime; plain.runtime.setup()"
53
+ command = f"{before_script}; exec(open('{script}').read())"
54
+ result = subprocess.run(["python", "-c", command])
55
+ if result.returncode:
56
+ sys.exit(result.returncode)
plain/cli/startup.py CHANGED
@@ -3,31 +3,43 @@ import plain.runtime
3
3
  plain.runtime.setup()
4
4
 
5
5
 
6
- def _print_bold(s):
6
+ def print_bold(s):
7
7
  print("\033[1m", end="")
8
8
  print(s)
9
9
  print("\033[0m", end="")
10
10
 
11
11
 
12
- def _print_italic(s):
12
+ def print_italic(s):
13
13
  print("\x1b[3m", end="")
14
14
  print(s)
15
15
  print("\x1b[0m", end="")
16
16
 
17
17
 
18
- _print_bold("\n⬣ Welcome to the Plain shell! ⬣")
18
+ def print_dim(s):
19
+ print("\x1b[2m", end="")
20
+ print(s)
21
+ print("\x1b[0m", end="")
22
+
23
+
24
+ print_bold("\n⬣ Welcome to the Plain shell! ⬣\n")
19
25
 
20
- _app_shell = plain.runtime.APP_PATH / "shell.py"
26
+ if shell_import := plain.runtime.settings.SHELL_IMPORT:
27
+ from importlib import import_module
21
28
 
22
- if _app_shell.exists():
23
- _print_bold("\nImporting custom app/shell.py")
24
- contents = _app_shell.read_text()
29
+ print_bold(f"Importing {shell_import}")
30
+ module = import_module(shell_import)
25
31
 
26
- for line in contents.splitlines():
27
- _print_italic(f">>> {line}")
32
+ with open(module.__file__) as f:
33
+ contents = f.read()
34
+ for line in contents.splitlines():
35
+ print_dim(f"{line}")
28
36
 
29
37
  print()
30
38
 
31
- # Import * so we get everything that file imported
32
- # (which is mostly the point of having it)
33
- from app.shell import * # noqa
39
+ # Emulate `from module import *`
40
+ names = getattr(
41
+ module, "__all__", [name for name in dir(module) if not name.startswith("_")]
42
+ )
43
+ globals().update({name: getattr(module, name) for name in names})
44
+ else:
45
+ print_italic("Use settings.SHELL_IMPORT to customize the shell startup.\n")
plain/cli/urls.py ADDED
@@ -0,0 +1,87 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+
6
+ @click.command()
7
+ @click.option("--flat", is_flag=True, help="List all URLs in a flat list")
8
+ def urls(flat):
9
+ """Print all URL patterns under settings.URLS_ROUTER"""
10
+ from plain.runtime import settings
11
+ from plain.urls import URLResolver, get_resolver
12
+
13
+ if not settings.URLS_ROUTER:
14
+ click.secho("URLS_ROUTER is not set", fg="red")
15
+ sys.exit(1)
16
+
17
+ resolver = get_resolver(settings.URLS_ROUTER)
18
+ if flat:
19
+
20
+ def flat_list(patterns, prefix="", curr_ns=""):
21
+ for pattern in patterns:
22
+ full_pattern = f"{prefix}{pattern.pattern}"
23
+ if isinstance(pattern, URLResolver):
24
+ # Update current namespace
25
+ new_ns = (
26
+ f"{curr_ns}:{pattern.namespace}"
27
+ if curr_ns and pattern.namespace
28
+ else (pattern.namespace or curr_ns)
29
+ )
30
+ yield from flat_list(
31
+ pattern.url_patterns, prefix=full_pattern, curr_ns=new_ns
32
+ )
33
+ else:
34
+ if pattern.name:
35
+ if curr_ns:
36
+ styled_namespace = click.style(f"{curr_ns}:", fg="yellow")
37
+ styled_name = click.style(pattern.name, fg="blue")
38
+ full_name = f"{styled_namespace}{styled_name}"
39
+ else:
40
+ full_name = click.style(pattern.name, fg="blue")
41
+ name_part = f" [{full_name}]"
42
+ else:
43
+ name_part = ""
44
+ yield f"{click.style(full_pattern)}{name_part}"
45
+
46
+ for p in flat_list(resolver.url_patterns):
47
+ click.echo(p)
48
+ else:
49
+
50
+ def print_tree(patterns, prefix="", curr_ns=""):
51
+ count = len(patterns)
52
+ for idx, pattern in enumerate(patterns):
53
+ is_last = idx == (count - 1)
54
+ connector = "└── " if is_last else "├── "
55
+ styled_connector = click.style(connector)
56
+ styled_pattern = click.style(pattern.pattern)
57
+ if isinstance(pattern, URLResolver):
58
+ if pattern.namespace:
59
+ new_ns = (
60
+ f"{curr_ns}:{pattern.namespace}"
61
+ if curr_ns
62
+ else pattern.namespace
63
+ )
64
+ styled_namespace = click.style(f"[{new_ns}]", fg="yellow")
65
+ click.echo(
66
+ f"{prefix}{styled_connector}{styled_pattern} {styled_namespace}"
67
+ )
68
+ else:
69
+ new_ns = curr_ns
70
+ click.echo(f"{prefix}{styled_connector}{styled_pattern}")
71
+ extension = " " if is_last else "│ "
72
+ print_tree(pattern.url_patterns, prefix + extension, new_ns)
73
+ else:
74
+ if pattern.name:
75
+ if curr_ns:
76
+ styled_namespace = click.style(f"{curr_ns}:", fg="yellow")
77
+ styled_name = click.style(pattern.name, fg="blue")
78
+ full_name = f"[{styled_namespace}{styled_name}]"
79
+ else:
80
+ full_name = click.style(f"[{pattern.name}]", fg="blue")
81
+ click.echo(
82
+ f"{prefix}{styled_connector}{styled_pattern} {full_name}"
83
+ )
84
+ else:
85
+ click.echo(f"{prefix}{styled_connector}{styled_pattern}")
86
+
87
+ print_tree(resolver.url_patterns)
plain/cli/utils.py ADDED
@@ -0,0 +1,15 @@
1
+ import click
2
+
3
+ from plain.utils.crypto import get_random_string
4
+
5
+
6
+ @click.group()
7
+ def utils():
8
+ pass
9
+
10
+
11
+ @utils.command()
12
+ def generate_secret_key():
13
+ """Generate a new secret key"""
14
+ new_secret_key = get_random_string(50)
15
+ click.echo(new_secret_key)
plain/csrf/README.md CHANGED
@@ -2,7 +2,7 @@
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 `csrfmiddlewaretoken` 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 `X-CSRFToken` header).
6
6
 
7
7
  ## Usage
8
8