plain 0.66.0__py3-none-any.whl → 0.101.2__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.
Files changed (197) hide show
  1. plain/CHANGELOG.md +684 -0
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -53
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +112 -28
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +175 -102
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +95 -26
  27. plain/cli/request.py +206 -0
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -13
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +40 -15
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +208 -23
  84. plain/preflight/__init__.py +5 -24
  85. plain/preflight/checks.py +12 -0
  86. plain/preflight/files.py +19 -13
  87. plain/preflight/registry.py +80 -58
  88. plain/preflight/results.py +37 -0
  89. plain/preflight/security.py +65 -71
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +10 -48
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +43 -33
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +14 -27
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +56 -40
  145. plain/urls/resolvers.py +38 -28
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/cli/agent/request.py +0 -181
  191. plain/csrf/views.py +0 -31
  192. plain/logs/utils.py +0 -46
  193. plain/preflight/messages.py +0 -81
  194. plain/templates/AGENTS.md +0 -3
  195. plain-0.66.0.dist-info/RECORD +0 -168
  196. plain-0.66.0.dist-info/entry_points.txt +0 -4
  197. {plain-0.66.0.dist-info → plain-0.101.2.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__(self, exc_type, exc_val, exc_tb):
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
- **Install Python modules as Plain packages.**
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 `ready()` method](#package-ready-method)
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 that you use with Plain will need to be installed via `settings.INSTALLED_PACKAGES`. This is what enables template detection, per-package settings, database models, and other features.
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
- It often makes sense to conceptually split your app across multiple local packages. For example, we find it typically works better to have separate `users`, `teams`, and `projects` packages, than a single `core` package that contains all the code for users, teams, and projects together (just as an example). If you find yourself creating a package with a generic name like `core` or `base`, you might want to consider splitting it up into smaller packages.
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
- You can quickly create a new package by running `plain create <package_name>`. Make sure to add it to `settings.INSTALLED_PACKAGES` if it uses templates, models, or other Plain-specific features.
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 optionally define it's own settings. These could be default settings for how the package behaves, or required settings that must be configured by the developer.
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
- # <pkg>/default_settings.py
44
- # A default setting
45
- EXAMPLE_SETTING: str = "example"
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
- REQUIRED_SETTING: str
62
+ TEAMS_SIGNUP_ENABLED: bool
49
63
  ```
50
64
 
51
- Settings can then be accessed at runtime through the `settings` object.
65
+ Access settings at runtime through the `settings` object:
52
66
 
53
67
  ```python
54
- # <pkg>/models.py
68
+ # teams/views.py
55
69
  from plain.runtime import settings
56
70
 
57
71
 
58
- def example_function():
59
- print(settings.EXAMPLE_SETTING)
72
+ def team_view(request):
73
+ if settings.TEAMS_SIGNUP_ENABLED:
74
+ max_members = settings.TEAMS_MAX_MEMBERS
75
+ # ...
60
76
  ```
61
77
 
62
- It is strongly recommended to "namespace" your settings to your package. So if your package is named "teams", you might want to prefix all your settings with `TEAMS_`.
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/default_settings.py
66
- TEAMS_EXAMPLE_SETTING: str = "example"
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
- ## Package `ready()` method
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
- To run setup code when your package is loaded, you can define a package configuration and the `ready()` method.
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
- # <pkg>/config.py
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
- print("Teams package is ready!")
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
- CONFIG_MODULE_NAME = "config"
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.packages_registry = None
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
@@ -1,13 +1,17 @@
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
8
+ from importlib.util import find_spec
5
9
 
6
10
  from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady
7
11
 
8
12
  from .config import PackageConfig
9
13
 
10
- CONFIG_MODULE_NAME = "config"
14
+ _CONFIG_MODULE_NAME = "config"
11
15
 
12
16
 
13
17
  class PackagesRegistry:
@@ -17,7 +21,7 @@ class PackagesRegistry:
17
21
  It also keeps track of models, e.g. to provide reverse relations.
18
22
  """
19
23
 
20
- def __init__(self, installed_packages=()):
24
+ def __init__(self, installed_packages: Iterable[str | PackageConfig] | None = ()):
21
25
  # installed_packages is set to None when creating the main registry
22
26
  # because it cannot be populated at that point. Other registries must
23
27
  # provide a list of installed packages and are populated immediately.
@@ -27,7 +31,7 @@ class PackagesRegistry:
27
31
  raise RuntimeError("You must supply an installed_packages argument.")
28
32
 
29
33
  # Mapping of labels to PackageConfig instances for installed packages.
30
- self.package_configs = {}
34
+ self.package_configs: dict[str, PackageConfig] = {}
31
35
 
32
36
  # Whether the registry is populated.
33
37
  self.packages_ready = self.ready = False
@@ -40,7 +44,9 @@ class PackagesRegistry:
40
44
  if installed_packages is not None:
41
45
  self.populate(installed_packages)
42
46
 
43
- def populate(self, installed_packages=None):
47
+ def populate(
48
+ self, installed_packages: Iterable[str | PackageConfig] | None = None
49
+ ) -> None:
44
50
  """
45
51
  Load application configurations and models.
46
52
 
@@ -66,6 +72,9 @@ class PackagesRegistry:
66
72
  self.loading = True
67
73
 
68
74
  # Phase 1: initialize app configs and import app modules.
75
+ if installed_packages is None:
76
+ return
77
+
69
78
  for entry in installed_packages:
70
79
  if isinstance(entry, PackageConfig):
71
80
  # Some instances of the registry pass in the
@@ -73,7 +82,7 @@ class PackagesRegistry:
73
82
  self.register_config(package_config=entry)
74
83
  else:
75
84
  try:
76
- import_module(f"{entry}.{CONFIG_MODULE_NAME}")
85
+ import_module(f"{entry}.{_CONFIG_MODULE_NAME}")
77
86
  except ModuleNotFoundError:
78
87
  pass
79
88
 
@@ -91,9 +100,10 @@ class PackagesRegistry:
91
100
  entry_config = self.register_config(auto_package_config)
92
101
 
93
102
  # Make sure we have the same number of configs as we have installed packages
94
- if len(self.package_configs) != len(installed_packages):
103
+ installed_packages_list = list(installed_packages)
104
+ if len(self.package_configs) != len(installed_packages_list):
95
105
  raise ImproperlyConfigured(
96
- f"The number of installed packages ({len(installed_packages)}) does not match the number of "
106
+ f"The number of installed packages ({len(installed_packages_list)}) does not match the number of "
97
107
  f"registered configs ({len(self.package_configs)})."
98
108
  )
99
109
 
@@ -117,7 +127,7 @@ class PackagesRegistry:
117
127
 
118
128
  self.ready = True
119
129
 
120
- def check_packages_ready(self):
130
+ def check_packages_ready(self) -> None:
121
131
  """Raise an exception if all packages haven't been imported yet."""
122
132
  if not self.packages_ready:
123
133
  from plain.runtime import settings
@@ -128,12 +138,12 @@ class PackagesRegistry:
128
138
  settings.INSTALLED_PACKAGES
129
139
  raise PackageRegistryNotReady("Packages aren't loaded yet.")
130
140
 
131
- def get_package_configs(self):
141
+ def get_package_configs(self) -> Iterable[PackageConfig]:
132
142
  """Import applications and return an iterable of app configs."""
133
143
  self.check_packages_ready()
134
144
  return self.package_configs.values()
135
145
 
136
- def get_package_config(self, package_label):
146
+ def get_package_config(self, package_label: str) -> PackageConfig:
137
147
  """
138
148
  Import applications and returns an app config for the given label.
139
149
 
@@ -150,7 +160,7 @@ class PackagesRegistry:
150
160
  break
151
161
  raise LookupError(message)
152
162
 
153
- def get_containing_package_config(self, object_name):
163
+ def get_containing_package_config(self, object_name: str) -> PackageConfig | None:
154
164
  """
155
165
  Look for an app config containing a given object.
156
166
 
@@ -168,8 +178,9 @@ class PackagesRegistry:
168
178
  candidates.append(package_config)
169
179
  if candidates:
170
180
  return sorted(candidates, key=lambda ac: -len(ac.name))[0]
181
+ return None
171
182
 
172
- def register_config(self, package_config):
183
+ def register_config(self, package_config: PackageConfig) -> PackageConfig:
173
184
  """
174
185
  Add a config to the registry.
175
186
 
@@ -188,17 +199,31 @@ class PackagesRegistry:
188
199
 
189
200
  return package_config
190
201
 
202
+ def autodiscover_modules(self, module_name: str, *, include_app: bool) -> None:
203
+ def _import_if_exists(name: str) -> None:
204
+ if find_spec(name):
205
+ import_module(name)
206
+ return None
207
+
208
+ # Load from all packages
209
+ for package_config in self.get_package_configs():
210
+ _import_if_exists(f"{package_config.name}.{module_name}")
211
+
212
+ # Load from app if requested
213
+ if include_app:
214
+ _import_if_exists(f"app.{module_name}")
215
+
191
216
 
192
217
  packages_registry = PackagesRegistry(installed_packages=None)
193
218
 
194
219
 
195
- def register_config(package_config_class):
220
+ def register_config(package_config_class: type[PackageConfig]) -> type[PackageConfig]:
196
221
  """A decorator to register a PackageConfig subclass."""
197
222
  module_name = package_config_class.__module__
198
223
 
199
224
  # If it is in .config like expected, return the parent module name
200
- if module_name.endswith(f".{CONFIG_MODULE_NAME}"):
201
- module_name = module_name[: -len(CONFIG_MODULE_NAME) - 1]
225
+ if module_name.endswith(f".{_CONFIG_MODULE_NAME}"):
226
+ module_name = module_name[: -len(_CONFIG_MODULE_NAME) - 1]
202
227
 
203
228
  package_config = package_config_class(module_name)
204
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__(self, object_list, per_page, orphans=0, allow_empty_first_page=True):
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).