plain 0.68.0__py3-none-any.whl → 0.103.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/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +234 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -8
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +38 -22
- plain/urls/resolvers.py +35 -25
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/licenses/LICENSE +0 -0
plain/logs/debug.py
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import logging
|
|
2
4
|
import threading
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from types import TracebackType
|
|
3
9
|
|
|
4
10
|
|
|
5
11
|
class DebugMode:
|
|
6
12
|
"""Context manager to temporarily set DEBUG level on a logger with reference counting."""
|
|
7
13
|
|
|
8
|
-
def __init__(self, logger):
|
|
14
|
+
def __init__(self, logger: logging.Logger):
|
|
9
15
|
self.logger = logger
|
|
10
16
|
self.original_level = None
|
|
11
17
|
self._ref_count = 0
|
|
12
18
|
self._lock = threading.Lock()
|
|
13
19
|
|
|
14
|
-
def __enter__(self):
|
|
20
|
+
def __enter__(self) -> DebugMode:
|
|
15
21
|
"""Store original level and set to DEBUG."""
|
|
16
22
|
self.start()
|
|
17
23
|
return self
|
|
18
24
|
|
|
19
|
-
def __exit__(
|
|
25
|
+
def __exit__(
|
|
26
|
+
self,
|
|
27
|
+
exc_type: type[BaseException] | None,
|
|
28
|
+
exc_val: BaseException | None,
|
|
29
|
+
exc_tb: TracebackType | None,
|
|
30
|
+
) -> None:
|
|
20
31
|
"""Restore original level."""
|
|
21
32
|
self.end()
|
|
22
33
|
|
|
23
|
-
def start(self):
|
|
34
|
+
def start(self) -> None:
|
|
24
35
|
"""Enable DEBUG logging level."""
|
|
25
36
|
with self._lock:
|
|
26
37
|
if self._ref_count == 0:
|
|
@@ -28,9 +39,9 @@ class DebugMode:
|
|
|
28
39
|
self.logger.setLevel(logging.DEBUG)
|
|
29
40
|
self._ref_count += 1
|
|
30
41
|
|
|
31
|
-
def end(self):
|
|
42
|
+
def end(self) -> None:
|
|
32
43
|
"""Restore original logging level."""
|
|
33
44
|
with self._lock:
|
|
34
45
|
self._ref_count = max(0, self._ref_count - 1)
|
|
35
|
-
if self._ref_count == 0:
|
|
46
|
+
if self._ref_count == 0 and self.original_level is not None:
|
|
36
47
|
self.logger.setLevel(self.original_level)
|
plain/logs/filters.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DebugInfoFilter(logging.Filter):
|
|
5
|
+
"""Filter that only allows DEBUG and INFO log records."""
|
|
6
|
+
|
|
7
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
8
|
+
return record.levelno <= logging.INFO
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WarningErrorCriticalFilter(logging.Filter):
|
|
12
|
+
"""Filter that only allows WARNING, ERROR, and CRITICAL log records."""
|
|
13
|
+
|
|
14
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
15
|
+
return record.levelno >= logging.WARNING
|
plain/logs/formatters.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
4
|
import logging
|
|
5
|
+
from typing import Any
|
|
3
6
|
|
|
4
7
|
|
|
5
8
|
class KeyValueFormatter(logging.Formatter):
|
|
6
9
|
"""Formatter that outputs key-value pairs from Plain's context system."""
|
|
7
10
|
|
|
8
|
-
def format(self, record):
|
|
11
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
9
12
|
# Build key-value pairs from context
|
|
10
13
|
kv_pairs = []
|
|
11
14
|
|
|
@@ -22,7 +25,7 @@ class KeyValueFormatter(logging.Formatter):
|
|
|
22
25
|
return super().format(record)
|
|
23
26
|
|
|
24
27
|
@staticmethod
|
|
25
|
-
def _format_value(value):
|
|
28
|
+
def _format_value(value: Any) -> str:
|
|
26
29
|
"""Format a value for key-value output."""
|
|
27
30
|
if isinstance(value, str):
|
|
28
31
|
s = value
|
|
@@ -46,7 +49,7 @@ class KeyValueFormatter(logging.Formatter):
|
|
|
46
49
|
class JSONFormatter(logging.Formatter):
|
|
47
50
|
"""Formatter that outputs JSON from Plain's context system, with optional format string."""
|
|
48
51
|
|
|
49
|
-
def format(self, record):
|
|
52
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
50
53
|
# Build the JSON object from Plain's context data
|
|
51
54
|
log_obj = {
|
|
52
55
|
"timestamp": self.formatTime(record),
|
|
@@ -57,7 +60,7 @@ class JSONFormatter(logging.Formatter):
|
|
|
57
60
|
|
|
58
61
|
# Add Plain's context data to the main JSON object
|
|
59
62
|
if hasattr(record, "context") and isinstance(record.context, dict):
|
|
60
|
-
log_obj.update(record.context)
|
|
63
|
+
log_obj.update(record.context) # type: ignore[arg-type]
|
|
61
64
|
|
|
62
65
|
# Handle exceptions
|
|
63
66
|
if record.exc_info:
|
plain/packages/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# Packages
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Register and configure Python modules as Plain packages.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
6
|
- [Creating app packages](#creating-app-packages)
|
|
7
7
|
- [Package settings](#package-settings)
|
|
8
|
-
- [Package
|
|
8
|
+
- [Package configuration](#package-configuration)
|
|
9
|
+
- [The `ready()` method](#the-ready-method)
|
|
10
|
+
- [Custom package labels](#custom-package-labels)
|
|
11
|
+
- [FAQs](#faqs)
|
|
12
|
+
- [Installation](#installation)
|
|
9
13
|
|
|
10
14
|
## Overview
|
|
11
15
|
|
|
12
|
-
Most Python modules
|
|
13
|
-
|
|
14
|
-
A package can either be a local module inside of your `app`, or a third-party package from PyPI.
|
|
16
|
+
Most Python modules you use with Plain need to be listed in `settings.INSTALLED_PACKAGES`. This enables template detection, per-package settings, database models, and other features.
|
|
15
17
|
|
|
16
18
|
```python
|
|
17
19
|
# app/settings.py
|
|
@@ -26,57 +28,137 @@ INSTALLED_PACKAGES = [
|
|
|
26
28
|
"plain.elements",
|
|
27
29
|
# Local packages
|
|
28
30
|
"app.users",
|
|
31
|
+
"app.teams",
|
|
29
32
|
]
|
|
30
33
|
```
|
|
31
34
|
|
|
35
|
+
A package can be a third-party module from PyPI or a local module inside your `app` directory.
|
|
36
|
+
|
|
32
37
|
## Creating app packages
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
You can split your app into multiple local packages. For example, instead of a single `core` package containing everything, you might have separate `users`, `teams`, and `projects` packages. If you find yourself creating a package with a generic name like `core` or `base`, consider splitting it up.
|
|
40
|
+
|
|
41
|
+
Create a new package by running:
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
```bash
|
|
44
|
+
plain create <package_name>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Make sure to add it to `settings.INSTALLED_PACKAGES` if it uses templates, models, or other Plain-specific features.
|
|
37
48
|
|
|
38
49
|
## Package settings
|
|
39
50
|
|
|
40
|
-
An installed package can
|
|
51
|
+
An installed package can define its own settings. These could be default values for how the package behaves, or required settings that must be configured by the user.
|
|
52
|
+
|
|
53
|
+
Create a `default_settings.py` file in your package:
|
|
41
54
|
|
|
42
55
|
```python
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
# teams/default_settings.py
|
|
57
|
+
|
|
58
|
+
# A default setting (has a value)
|
|
59
|
+
TEAMS_MAX_MEMBERS: int = 10
|
|
46
60
|
|
|
47
61
|
# A required setting (type annotation with no default value)
|
|
48
|
-
|
|
62
|
+
TEAMS_SIGNUP_ENABLED: bool
|
|
49
63
|
```
|
|
50
64
|
|
|
51
|
-
|
|
65
|
+
Access settings at runtime through the `settings` object:
|
|
52
66
|
|
|
53
67
|
```python
|
|
54
|
-
#
|
|
68
|
+
# teams/views.py
|
|
55
69
|
from plain.runtime import settings
|
|
56
70
|
|
|
57
71
|
|
|
58
|
-
def
|
|
59
|
-
|
|
72
|
+
def team_view(request):
|
|
73
|
+
if settings.TEAMS_SIGNUP_ENABLED:
|
|
74
|
+
max_members = settings.TEAMS_MAX_MEMBERS
|
|
75
|
+
# ...
|
|
60
76
|
```
|
|
61
77
|
|
|
62
|
-
|
|
78
|
+
Namespace your settings to avoid conflicts. If your package is named `teams`, prefix all settings with `TEAMS_`.
|
|
79
|
+
|
|
80
|
+
## Package configuration
|
|
81
|
+
|
|
82
|
+
To customize how your package loads or run setup code when Plain starts, create a [`PackageConfig`](./config.py#PackageConfig) subclass in a `config.py` file:
|
|
63
83
|
|
|
64
84
|
```python
|
|
65
|
-
# teams/
|
|
66
|
-
|
|
85
|
+
# teams/config.py
|
|
86
|
+
from plain.packages import PackageConfig, register_config
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@register_config
|
|
90
|
+
class TeamsConfig(PackageConfig):
|
|
91
|
+
pass
|
|
67
92
|
```
|
|
68
93
|
|
|
69
|
-
|
|
94
|
+
The [`@register_config`](./registry.py#register_config) decorator registers your configuration with the [`packages_registry`](./registry.py#packages_registry).
|
|
95
|
+
|
|
96
|
+
### The `ready()` method
|
|
70
97
|
|
|
71
|
-
|
|
98
|
+
Override the `ready()` method to run code when Plain starts. This is useful for connecting signals, initializing caches, or other one-time setup.
|
|
72
99
|
|
|
73
100
|
```python
|
|
74
|
-
#
|
|
101
|
+
# teams/config.py
|
|
75
102
|
from plain.packages import PackageConfig, register_config
|
|
76
103
|
|
|
77
104
|
|
|
78
105
|
@register_config
|
|
79
106
|
class TeamsConfig(PackageConfig):
|
|
80
107
|
def ready(self):
|
|
81
|
-
|
|
108
|
+
# Import signal handlers
|
|
109
|
+
from . import signals # noqa: F401
|
|
110
|
+
print("Teams package ready!")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Custom package labels
|
|
114
|
+
|
|
115
|
+
By default, the package label is the last component of the Python path (e.g., `admin` for `plain.admin`). You can override this by setting the `package_label` attribute:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# teams/config.py
|
|
119
|
+
from plain.packages import PackageConfig, register_config
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@register_config
|
|
123
|
+
class TeamsConfig(PackageConfig):
|
|
124
|
+
package_label = "teams"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## FAQs
|
|
128
|
+
|
|
129
|
+
#### When do I need a `config.py` file?
|
|
130
|
+
|
|
131
|
+
You only need a `config.py` file if you want to run code in `ready()` or customize the package label. For most packages, Plain automatically creates a default configuration.
|
|
132
|
+
|
|
133
|
+
#### How do I access the package registry?
|
|
134
|
+
|
|
135
|
+
You can access the registry directly if you need to inspect installed packages:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from plain.packages import packages_registry
|
|
139
|
+
|
|
140
|
+
# Get all registered package configs
|
|
141
|
+
for config in packages_registry.get_package_configs():
|
|
142
|
+
print(config.name, config.path)
|
|
143
|
+
|
|
144
|
+
# Get a specific package config by label
|
|
145
|
+
teams_config = packages_registry.get_package_config("teams")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
See [`PackagesRegistry`](./registry.py#PackagesRegistry) for all available methods.
|
|
149
|
+
|
|
150
|
+
#### What order are packages loaded?
|
|
151
|
+
|
|
152
|
+
Packages are loaded in the order they appear in `INSTALLED_PACKAGES`. The `ready()` methods are called in the same order after all packages have been imported.
|
|
153
|
+
|
|
154
|
+
#### Can I have duplicate package labels?
|
|
155
|
+
|
|
156
|
+
No. Each package must have a unique label. If two packages have the same label, Plain raises an `ImproperlyConfigured` error.
|
|
157
|
+
|
|
158
|
+
## Installation
|
|
159
|
+
|
|
160
|
+
The `plain.packages` module is included with the `plain` package. No additional installation is required.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
uv add plain
|
|
82
164
|
```
|
plain/packages/config.py
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import os
|
|
2
4
|
from functools import cached_property
|
|
3
5
|
from importlib import import_module
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
4
8
|
|
|
5
9
|
from plain.exceptions import ImproperlyConfigured
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from plain.packages.registry import PackagesRegistry
|
|
13
|
+
|
|
14
|
+
_CONFIG_MODULE_NAME = "config"
|
|
8
15
|
|
|
9
16
|
|
|
10
17
|
class PackageConfig:
|
|
@@ -12,13 +19,13 @@ class PackageConfig:
|
|
|
12
19
|
|
|
13
20
|
package_label: str
|
|
14
21
|
|
|
15
|
-
def __init__(self, name):
|
|
22
|
+
def __init__(self, name: str):
|
|
16
23
|
# Full Python path to the application e.g. 'plain.admin.admin'.
|
|
17
24
|
self.name = name
|
|
18
25
|
|
|
19
26
|
# Reference to the Packages registry that holds this PackageConfig. Set by the
|
|
20
27
|
# registry when it registers the PackageConfig instance.
|
|
21
|
-
self.
|
|
28
|
+
self.packages: PackagesRegistry | None = None
|
|
22
29
|
|
|
23
30
|
if not hasattr(self, "package_label"):
|
|
24
31
|
# Last component of the Python path to the application e.g. 'admin'.
|
|
@@ -30,14 +37,14 @@ class PackageConfig:
|
|
|
30
37
|
f"The app label '{self.package_label}' is not a valid Python identifier."
|
|
31
38
|
)
|
|
32
39
|
|
|
33
|
-
def __repr__(self):
|
|
40
|
+
def __repr__(self) -> str:
|
|
34
41
|
return f"<{self.__class__.__name__}: {self.package_label}>"
|
|
35
42
|
|
|
36
43
|
@cached_property
|
|
37
|
-
def path(self):
|
|
44
|
+
def path(self) -> str:
|
|
38
45
|
# Filesystem path to the application directory e.g.
|
|
39
46
|
# '/path/to/admin'.
|
|
40
|
-
def _path_from_module(module):
|
|
47
|
+
def _path_from_module(module: ModuleType) -> str:
|
|
41
48
|
"""Attempt to determine app's filesystem path from its module."""
|
|
42
49
|
# See #21874 for extended discussion of the behavior of this method in
|
|
43
50
|
# various cases.
|
|
@@ -68,7 +75,8 @@ class PackageConfig:
|
|
|
68
75
|
module = import_module(self.name)
|
|
69
76
|
return _path_from_module(module)
|
|
70
77
|
|
|
71
|
-
def ready(self):
|
|
78
|
+
def ready(self) -> None:
|
|
72
79
|
"""
|
|
73
80
|
Override this method in subclasses to run code when Plain starts.
|
|
74
81
|
"""
|
|
82
|
+
return None
|
plain/packages/registry.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import sys
|
|
2
4
|
import threading
|
|
3
5
|
from collections import Counter
|
|
6
|
+
from collections.abc import Iterable
|
|
4
7
|
from importlib import import_module
|
|
5
8
|
from importlib.util import find_spec
|
|
6
9
|
|
|
@@ -8,7 +11,7 @@ from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady
|
|
|
8
11
|
|
|
9
12
|
from .config import PackageConfig
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
_CONFIG_MODULE_NAME = "config"
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
class PackagesRegistry:
|
|
@@ -18,7 +21,7 @@ class PackagesRegistry:
|
|
|
18
21
|
It also keeps track of models, e.g. to provide reverse relations.
|
|
19
22
|
"""
|
|
20
23
|
|
|
21
|
-
def __init__(self, installed_packages=()):
|
|
24
|
+
def __init__(self, installed_packages: Iterable[str | PackageConfig] | None = ()):
|
|
22
25
|
# installed_packages is set to None when creating the main registry
|
|
23
26
|
# because it cannot be populated at that point. Other registries must
|
|
24
27
|
# provide a list of installed packages and are populated immediately.
|
|
@@ -28,7 +31,7 @@ class PackagesRegistry:
|
|
|
28
31
|
raise RuntimeError("You must supply an installed_packages argument.")
|
|
29
32
|
|
|
30
33
|
# Mapping of labels to PackageConfig instances for installed packages.
|
|
31
|
-
self.package_configs = {}
|
|
34
|
+
self.package_configs: dict[str, PackageConfig] = {}
|
|
32
35
|
|
|
33
36
|
# Whether the registry is populated.
|
|
34
37
|
self.packages_ready = self.ready = False
|
|
@@ -41,7 +44,9 @@ class PackagesRegistry:
|
|
|
41
44
|
if installed_packages is not None:
|
|
42
45
|
self.populate(installed_packages)
|
|
43
46
|
|
|
44
|
-
def populate(
|
|
47
|
+
def populate(
|
|
48
|
+
self, installed_packages: Iterable[str | PackageConfig] | None = None
|
|
49
|
+
) -> None:
|
|
45
50
|
"""
|
|
46
51
|
Load application configurations and models.
|
|
47
52
|
|
|
@@ -67,6 +72,9 @@ class PackagesRegistry:
|
|
|
67
72
|
self.loading = True
|
|
68
73
|
|
|
69
74
|
# Phase 1: initialize app configs and import app modules.
|
|
75
|
+
if installed_packages is None:
|
|
76
|
+
return
|
|
77
|
+
|
|
70
78
|
for entry in installed_packages:
|
|
71
79
|
if isinstance(entry, PackageConfig):
|
|
72
80
|
# Some instances of the registry pass in the
|
|
@@ -74,7 +82,7 @@ class PackagesRegistry:
|
|
|
74
82
|
self.register_config(package_config=entry)
|
|
75
83
|
else:
|
|
76
84
|
try:
|
|
77
|
-
import_module(f"{entry}.{
|
|
85
|
+
import_module(f"{entry}.{_CONFIG_MODULE_NAME}")
|
|
78
86
|
except ModuleNotFoundError:
|
|
79
87
|
pass
|
|
80
88
|
|
|
@@ -92,9 +100,10 @@ class PackagesRegistry:
|
|
|
92
100
|
entry_config = self.register_config(auto_package_config)
|
|
93
101
|
|
|
94
102
|
# Make sure we have the same number of configs as we have installed packages
|
|
95
|
-
|
|
103
|
+
installed_packages_list = list(installed_packages)
|
|
104
|
+
if len(self.package_configs) != len(installed_packages_list):
|
|
96
105
|
raise ImproperlyConfigured(
|
|
97
|
-
f"The number of installed packages ({len(
|
|
106
|
+
f"The number of installed packages ({len(installed_packages_list)}) does not match the number of "
|
|
98
107
|
f"registered configs ({len(self.package_configs)})."
|
|
99
108
|
)
|
|
100
109
|
|
|
@@ -118,7 +127,7 @@ class PackagesRegistry:
|
|
|
118
127
|
|
|
119
128
|
self.ready = True
|
|
120
129
|
|
|
121
|
-
def check_packages_ready(self):
|
|
130
|
+
def check_packages_ready(self) -> None:
|
|
122
131
|
"""Raise an exception if all packages haven't been imported yet."""
|
|
123
132
|
if not self.packages_ready:
|
|
124
133
|
from plain.runtime import settings
|
|
@@ -129,12 +138,12 @@ class PackagesRegistry:
|
|
|
129
138
|
settings.INSTALLED_PACKAGES
|
|
130
139
|
raise PackageRegistryNotReady("Packages aren't loaded yet.")
|
|
131
140
|
|
|
132
|
-
def get_package_configs(self):
|
|
141
|
+
def get_package_configs(self) -> Iterable[PackageConfig]:
|
|
133
142
|
"""Import applications and return an iterable of app configs."""
|
|
134
143
|
self.check_packages_ready()
|
|
135
144
|
return self.package_configs.values()
|
|
136
145
|
|
|
137
|
-
def get_package_config(self, package_label):
|
|
146
|
+
def get_package_config(self, package_label: str) -> PackageConfig:
|
|
138
147
|
"""
|
|
139
148
|
Import applications and returns an app config for the given label.
|
|
140
149
|
|
|
@@ -151,7 +160,7 @@ class PackagesRegistry:
|
|
|
151
160
|
break
|
|
152
161
|
raise LookupError(message)
|
|
153
162
|
|
|
154
|
-
def get_containing_package_config(self, object_name):
|
|
163
|
+
def get_containing_package_config(self, object_name: str) -> PackageConfig | None:
|
|
155
164
|
"""
|
|
156
165
|
Look for an app config containing a given object.
|
|
157
166
|
|
|
@@ -169,8 +178,9 @@ class PackagesRegistry:
|
|
|
169
178
|
candidates.append(package_config)
|
|
170
179
|
if candidates:
|
|
171
180
|
return sorted(candidates, key=lambda ac: -len(ac.name))[0]
|
|
181
|
+
return None
|
|
172
182
|
|
|
173
|
-
def register_config(self, package_config):
|
|
183
|
+
def register_config(self, package_config: PackageConfig) -> PackageConfig:
|
|
174
184
|
"""
|
|
175
185
|
Add a config to the registry.
|
|
176
186
|
|
|
@@ -190,9 +200,10 @@ class PackagesRegistry:
|
|
|
190
200
|
return package_config
|
|
191
201
|
|
|
192
202
|
def autodiscover_modules(self, module_name: str, *, include_app: bool) -> None:
|
|
193
|
-
def _import_if_exists(name):
|
|
203
|
+
def _import_if_exists(name: str) -> None:
|
|
194
204
|
if find_spec(name):
|
|
195
205
|
import_module(name)
|
|
206
|
+
return None
|
|
196
207
|
|
|
197
208
|
# Load from all packages
|
|
198
209
|
for package_config in self.get_package_configs():
|
|
@@ -206,13 +217,13 @@ class PackagesRegistry:
|
|
|
206
217
|
packages_registry = PackagesRegistry(installed_packages=None)
|
|
207
218
|
|
|
208
219
|
|
|
209
|
-
def register_config(package_config_class):
|
|
220
|
+
def register_config(package_config_class: type[PackageConfig]) -> type[PackageConfig]:
|
|
210
221
|
"""A decorator to register a PackageConfig subclass."""
|
|
211
222
|
module_name = package_config_class.__module__
|
|
212
223
|
|
|
213
224
|
# If it is in .config like expected, return the parent module name
|
|
214
|
-
if module_name.endswith(f".{
|
|
215
|
-
module_name = module_name[: -len(
|
|
225
|
+
if module_name.endswith(f".{_CONFIG_MODULE_NAME}"):
|
|
226
|
+
module_name = module_name[: -len(_CONFIG_MODULE_NAME) - 1]
|
|
216
227
|
|
|
217
228
|
package_config = package_config_class(module_name)
|
|
218
229
|
|
plain/paginator.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import collections.abc
|
|
2
4
|
import inspect
|
|
3
5
|
import warnings
|
|
6
|
+
from collections.abc import Iterator
|
|
4
7
|
from functools import cached_property
|
|
5
8
|
from math import ceil
|
|
9
|
+
from typing import Any
|
|
6
10
|
|
|
7
11
|
from plain.utils.inspect import method_has_no_args
|
|
8
12
|
|
|
@@ -24,18 +28,24 @@ class EmptyPage(InvalidPage):
|
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
class Paginator:
|
|
27
|
-
def __init__(
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
object_list: Any,
|
|
34
|
+
per_page: int,
|
|
35
|
+
orphans: int = 0,
|
|
36
|
+
allow_empty_first_page: bool = True,
|
|
37
|
+
) -> None:
|
|
28
38
|
self.object_list = object_list
|
|
29
39
|
self._check_object_list_is_ordered()
|
|
30
40
|
self.per_page = int(per_page)
|
|
31
41
|
self.orphans = int(orphans)
|
|
32
42
|
self.allow_empty_first_page = allow_empty_first_page
|
|
33
43
|
|
|
34
|
-
def __iter__(self):
|
|
44
|
+
def __iter__(self) -> Iterator[Page]:
|
|
35
45
|
for page_number in self.page_range:
|
|
36
46
|
yield self.page(page_number)
|
|
37
47
|
|
|
38
|
-
def validate_number(self, number):
|
|
48
|
+
def validate_number(self, number: Any) -> int:
|
|
39
49
|
"""Validate the given 1-based page number."""
|
|
40
50
|
try:
|
|
41
51
|
if isinstance(number, float) and not number.is_integer():
|
|
@@ -49,7 +59,7 @@ class Paginator:
|
|
|
49
59
|
raise EmptyPage("That page contains no results")
|
|
50
60
|
return number
|
|
51
61
|
|
|
52
|
-
def get_page(self, number):
|
|
62
|
+
def get_page(self, number: Any) -> Page:
|
|
53
63
|
"""
|
|
54
64
|
Return a valid page, even if the page argument isn't a number or isn't
|
|
55
65
|
in range.
|
|
@@ -62,7 +72,7 @@ class Paginator:
|
|
|
62
72
|
number = self.num_pages
|
|
63
73
|
return self.page(number)
|
|
64
74
|
|
|
65
|
-
def page(self, number):
|
|
75
|
+
def page(self, number: Any) -> Page:
|
|
66
76
|
"""Return a Page object for the given 1-based page number."""
|
|
67
77
|
number = self.validate_number(number)
|
|
68
78
|
bottom = (number - 1) * self.per_page
|
|
@@ -71,7 +81,7 @@ class Paginator:
|
|
|
71
81
|
top = self.count
|
|
72
82
|
return self._get_page(self.object_list[bottom:top], number, self)
|
|
73
83
|
|
|
74
|
-
def _get_page(self, *args, **kwargs):
|
|
84
|
+
def _get_page(self, *args: Any, **kwargs: Any) -> Page:
|
|
75
85
|
"""
|
|
76
86
|
Return an instance of a single page.
|
|
77
87
|
|
|
@@ -81,7 +91,7 @@ class Paginator:
|
|
|
81
91
|
return Page(*args, **kwargs)
|
|
82
92
|
|
|
83
93
|
@cached_property
|
|
84
|
-
def count(self):
|
|
94
|
+
def count(self) -> int:
|
|
85
95
|
"""Return the total number of objects, across all pages."""
|
|
86
96
|
c = getattr(self.object_list, "count", None)
|
|
87
97
|
if callable(c) and not inspect.isbuiltin(c) and method_has_no_args(c):
|
|
@@ -89,7 +99,7 @@ class Paginator:
|
|
|
89
99
|
return len(self.object_list)
|
|
90
100
|
|
|
91
101
|
@cached_property
|
|
92
|
-
def num_pages(self):
|
|
102
|
+
def num_pages(self) -> int:
|
|
93
103
|
"""Return the total number of pages."""
|
|
94
104
|
if self.count == 0 and not self.allow_empty_first_page:
|
|
95
105
|
return 0
|
|
@@ -97,14 +107,14 @@ class Paginator:
|
|
|
97
107
|
return ceil(hits / self.per_page)
|
|
98
108
|
|
|
99
109
|
@property
|
|
100
|
-
def page_range(self):
|
|
110
|
+
def page_range(self) -> range:
|
|
101
111
|
"""
|
|
102
112
|
Return a 1-based range of pages for iterating through within
|
|
103
113
|
a template for loop.
|
|
104
114
|
"""
|
|
105
115
|
return range(1, self.num_pages + 1)
|
|
106
116
|
|
|
107
|
-
def _check_object_list_is_ordered(self):
|
|
117
|
+
def _check_object_list_is_ordered(self) -> None:
|
|
108
118
|
"""
|
|
109
119
|
Warn if self.object_list is unordered (typically a QuerySet).
|
|
110
120
|
"""
|
|
@@ -124,18 +134,18 @@ class Paginator:
|
|
|
124
134
|
|
|
125
135
|
|
|
126
136
|
class Page(collections.abc.Sequence):
|
|
127
|
-
def __init__(self, object_list, number, paginator):
|
|
137
|
+
def __init__(self, object_list: Any, number: int, paginator: Paginator) -> None:
|
|
128
138
|
self.object_list = object_list
|
|
129
139
|
self.number = number
|
|
130
140
|
self.paginator = paginator
|
|
131
141
|
|
|
132
|
-
def __repr__(self):
|
|
142
|
+
def __repr__(self) -> str:
|
|
133
143
|
return f"<Page {self.number} of {self.paginator.num_pages}>"
|
|
134
144
|
|
|
135
|
-
def __len__(self):
|
|
145
|
+
def __len__(self) -> int:
|
|
136
146
|
return len(self.object_list)
|
|
137
147
|
|
|
138
|
-
def __getitem__(self, index):
|
|
148
|
+
def __getitem__(self, index: int | slice) -> Any:
|
|
139
149
|
if not isinstance(index, int | slice):
|
|
140
150
|
raise TypeError(
|
|
141
151
|
f"Page indices must be integers or slices, not {type(index).__name__}."
|
|
@@ -146,22 +156,22 @@ class Page(collections.abc.Sequence):
|
|
|
146
156
|
self.object_list = list(self.object_list)
|
|
147
157
|
return self.object_list[index]
|
|
148
158
|
|
|
149
|
-
def has_next(self):
|
|
159
|
+
def has_next(self) -> bool:
|
|
150
160
|
return self.number < self.paginator.num_pages
|
|
151
161
|
|
|
152
|
-
def has_previous(self):
|
|
162
|
+
def has_previous(self) -> bool:
|
|
153
163
|
return self.number > 1
|
|
154
164
|
|
|
155
|
-
def has_other_pages(self):
|
|
165
|
+
def has_other_pages(self) -> bool:
|
|
156
166
|
return self.has_previous() or self.has_next()
|
|
157
167
|
|
|
158
|
-
def next_page_number(self):
|
|
168
|
+
def next_page_number(self) -> int:
|
|
159
169
|
return self.paginator.validate_number(self.number + 1)
|
|
160
170
|
|
|
161
|
-
def previous_page_number(self):
|
|
171
|
+
def previous_page_number(self) -> int:
|
|
162
172
|
return self.paginator.validate_number(self.number - 1)
|
|
163
173
|
|
|
164
|
-
def start_index(self):
|
|
174
|
+
def start_index(self) -> int:
|
|
165
175
|
"""
|
|
166
176
|
Return the 1-based index of the first object on this page,
|
|
167
177
|
relative to total objects in the paginator.
|
|
@@ -171,7 +181,7 @@ class Page(collections.abc.Sequence):
|
|
|
171
181
|
return 0
|
|
172
182
|
return (self.paginator.per_page * (self.number - 1)) + 1
|
|
173
183
|
|
|
174
|
-
def end_index(self):
|
|
184
|
+
def end_index(self) -> int:
|
|
175
185
|
"""
|
|
176
186
|
Return the 1-based index of the last object on this page,
|
|
177
187
|
relative to total objects found (hits).
|