plain 0.77.0__tar.gz → 0.78.0__tar.gz
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.
Potentially problematic release.
This version of plain might be problematic. Click here for more details.
- {plain-0.77.0 → plain-0.78.0}/PKG-INFO +1 -1
- {plain-0.77.0 → plain-0.78.0}/plain/CHANGELOG.md +33 -0
- {plain-0.77.0 → plain-0.78.0}/plain/chores/README.md +24 -22
- plain-0.78.0/plain/chores/__init__.py +4 -0
- plain-0.78.0/plain/chores/core.py +27 -0
- plain-0.78.0/plain/chores/registry.py +49 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/README.md +26 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/chores.py +28 -26
- {plain-0.77.0 → plain-0.78.0}/plain/views/base.py +2 -3
- {plain-0.77.0 → plain-0.78.0}/pyproject.toml +1 -1
- plain-0.77.0/plain/chores/__init__.py +0 -3
- plain-0.77.0/plain/chores/registry.py +0 -67
- {plain-0.77.0 → plain-0.78.0}/.gitignore +0 -0
- {plain-0.77.0 → plain-0.78.0}/LICENSE +0 -0
- {plain-0.77.0 → plain-0.78.0}/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/AGENTS.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/__main__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/assets/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/assets/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/assets/compile.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/assets/finders.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/assets/fingerprints.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/assets/urls.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/assets/views.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/agent/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/agent/docs.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/agent/llmdocs.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/agent/md.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/agent/prompt.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/agent/request.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/build.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/changelog.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/core.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/docs.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/formatting.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/install.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/output.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/preflight.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/print.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/registry.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/runtime.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/scaffold.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/server.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/settings.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/shell.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/startup.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/upgrade.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/urls.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/cli/utils.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/csrf/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/csrf/middleware.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/csrf/views.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/debug.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/exceptions.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/forms/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/forms/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/forms/boundfield.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/forms/exceptions.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/forms/fields.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/forms/forms.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/http/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/http/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/http/cookie.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/http/multipartparser.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/http/request.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/http/response.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/base.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/locks.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/move.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/temp.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/files/utils.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/handlers/base.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/handlers/exception.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/middleware/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/middleware/headers.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/middleware/hosts.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/middleware/https.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/middleware/slash.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/internal/reloader.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/json.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/logs/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/logs/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/logs/configure.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/logs/debug.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/logs/formatters.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/logs/loggers.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/logs/utils.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/packages/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/packages/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/packages/config.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/packages/registry.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/paginator.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/checks.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/files.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/registry.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/results.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/security.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/preflight/urls.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/runtime/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/runtime/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/runtime/global_settings.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/runtime/user_settings.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/runtime/utils.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/LICENSE +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/app.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/arbiter.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/config.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/errors.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/glogging.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/http/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/http/body.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/http/errors.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/http/message.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/http/parser.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/http/unreader.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/http/wsgi.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/pidfile.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/sock.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/util.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/workers/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/workers/base.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/workers/sync.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/workers/thread.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/server/workers/workertmp.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/signals/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/signals/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/signing.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/AGENTS.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/core.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/jinja/environments.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/jinja/filters.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/templates/jinja/globals.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/test/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/test/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/test/client.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/test/encoding.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/test/exceptions.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/converters.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/exceptions.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/patterns.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/resolvers.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/routers.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/urls/utils.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/cache.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/crypto.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/datastructures.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/dateparse.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/deconstruct.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/decorators.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/duration.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/encoding.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/functional.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/hashable.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/html.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/http.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/inspect.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/ipv6.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/itercompat.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/module_loading.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/regex_helper.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/safestring.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/text.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/timesince.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/timezone.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/utils/tree.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/validators.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/README.md +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/errors.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/exceptions.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/forms.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/objects.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/redirect.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/views/templates.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/plain/wsgi.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/.gitignore +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/app/.gitignore +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/app/settings.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/app/test/__init__.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/app/test/default_settings.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/app/urls.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/conftest.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/test_cli.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/test_csrf.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/test_http_hosts.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/test_logs.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/test_runtime.py +0 -0
- {plain-0.77.0 → plain-0.78.0}/tests/test_wsgi.py +0 -0
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# plain changelog
|
|
2
2
|
|
|
3
|
+
## [0.78.0](https://github.com/dropseed/plain/releases/plain@0.78.0) (2025-10-17)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- Chores have been refactored to use abstract base classes instead of decorated functions ([c4466d3c60](https://github.com/dropseed/plain/commit/c4466d3c60))
|
|
8
|
+
- Added `SHELL_IMPORT` setting to customize what gets automatically imported in `plain shell` ([9055f59c08](https://github.com/dropseed/plain/commit/9055f59c08))
|
|
9
|
+
- Views that return `None` now raise `Http404` instead of returning `ResponseNotFound` ([5bb60016eb](https://github.com/dropseed/plain/commit/5bb60016eb))
|
|
10
|
+
- The `plain chores list` command output formatting now matches the `plain jobs list` format ([4b6881a49e](https://github.com/dropseed/plain/commit/4b6881a49e))
|
|
11
|
+
|
|
12
|
+
### Upgrade instructions
|
|
13
|
+
|
|
14
|
+
- Update any chores from decorated functions to class-based chores:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
# Before:
|
|
18
|
+
@register_chore("group")
|
|
19
|
+
def chore_name():
|
|
20
|
+
"""Description"""
|
|
21
|
+
return "Done!"
|
|
22
|
+
|
|
23
|
+
# After:
|
|
24
|
+
from plain.chores import Chore, register_chore
|
|
25
|
+
|
|
26
|
+
@register_chore
|
|
27
|
+
class ChoreName(Chore):
|
|
28
|
+
"""Description"""
|
|
29
|
+
|
|
30
|
+
def run(self):
|
|
31
|
+
return "Done!"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- Import `Chore` base class from `plain.chores` when creating new chores
|
|
35
|
+
|
|
3
36
|
## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
|
|
4
37
|
|
|
5
38
|
### What's changed
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
## Overview
|
|
10
10
|
|
|
11
|
-
Chores are registered
|
|
11
|
+
Chores are registered classes that can be run at any time to keep an app in a desirable state.
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
|
@@ -16,19 +16,19 @@ A good example is the clearing of expired sessions in [`plain.sessions`](/plain-
|
|
|
16
16
|
|
|
17
17
|
```python
|
|
18
18
|
# plain/sessions/chores.py
|
|
19
|
-
from plain.chores import register_chore
|
|
19
|
+
from plain.chores import Chore, register_chore
|
|
20
20
|
from plain.utils import timezone
|
|
21
21
|
|
|
22
22
|
from .models import Session
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
@register_chore
|
|
26
|
-
|
|
27
|
-
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
@register_chore
|
|
26
|
+
class ClearExpired(Chore):
|
|
27
|
+
"""Delete sessions that have expired."""
|
|
28
|
+
|
|
29
|
+
def run(self):
|
|
30
|
+
result = Session.query.filter(expires_at__lt=timezone.now()).delete()
|
|
31
|
+
return f"{result[0]} expired sessions deleted"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Running chores
|
|
@@ -44,27 +44,29 @@ There are several ways you can run chores depending on your needs:
|
|
|
44
44
|
|
|
45
45
|
## Writing chores
|
|
46
46
|
|
|
47
|
-
A chore is a
|
|
47
|
+
A chore is a class that inherits from [`Chore`](./core.py#Chore) and implements the `run()` method. Register the chore using the [`@register_chore`](./registry.py#register_chore) decorator. The chore name is the class's qualified name (`__qualname__`), and the description comes from the class docstring.
|
|
48
48
|
|
|
49
49
|
```python
|
|
50
50
|
# app/chores.py
|
|
51
|
-
from plain.chores import register_chore
|
|
51
|
+
from plain.chores import Chore, register_chore
|
|
52
|
+
|
|
52
53
|
|
|
54
|
+
@register_chore
|
|
55
|
+
class ChoreName(Chore):
|
|
56
|
+
"""A chore description can go here."""
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
A chore description can go here
|
|
58
|
-
"""
|
|
59
|
-
# Do a thing!
|
|
60
|
-
return "We did it!"
|
|
58
|
+
def run(self):
|
|
59
|
+
# Do a thing!
|
|
60
|
+
return "We did it!"
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
### Best practices
|
|
64
|
+
|
|
63
65
|
A good chore is:
|
|
64
66
|
|
|
65
|
-
- Fast
|
|
66
|
-
- Idempotent
|
|
67
|
-
- Recurring
|
|
68
|
-
- Stateless
|
|
67
|
+
- **Fast** - Should complete quickly, not block for long periods
|
|
68
|
+
- **Idempotent** - Safe to run multiple times without side effects
|
|
69
|
+
- **Recurring** - Designed to run regularly, not just once
|
|
70
|
+
- **Stateless** - Doesn't rely on external state between runs
|
|
69
71
|
|
|
70
72
|
If chores are written in `app/chores.py` or `{pkg}/chores.py`, then they will be imported automatically and registered.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Chore(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Abstract base class for chores.
|
|
10
|
+
|
|
11
|
+
Subclasses must implement:
|
|
12
|
+
- run() method
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
@register_chore
|
|
16
|
+
class ClearExpired(Chore):
|
|
17
|
+
'''Delete sessions that have expired.'''
|
|
18
|
+
|
|
19
|
+
def run(self):
|
|
20
|
+
# ... implementation
|
|
21
|
+
return "10 sessions deleted"
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def run(self) -> Any:
|
|
26
|
+
"""Run the chore. Must be implemented by subclasses."""
|
|
27
|
+
pass
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from plain.packages import packages_registry
|
|
4
|
+
|
|
5
|
+
from .core import Chore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ChoresRegistry:
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._chores: dict[str, type[Chore]] = {}
|
|
11
|
+
|
|
12
|
+
def register_chore(self, chore_class: type[Chore]) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Register a chore class.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
chore_class: A Chore subclass to register
|
|
18
|
+
"""
|
|
19
|
+
name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
20
|
+
self._chores[name] = chore_class
|
|
21
|
+
|
|
22
|
+
def import_modules(self) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Import modules from installed packages and app to trigger registration.
|
|
25
|
+
"""
|
|
26
|
+
packages_registry.autodiscover_modules("chores", include_app=True)
|
|
27
|
+
|
|
28
|
+
def get_chores(self) -> list[type[Chore]]:
|
|
29
|
+
"""
|
|
30
|
+
Get all registered chore classes.
|
|
31
|
+
"""
|
|
32
|
+
return list(self._chores.values())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
chores_registry = ChoresRegistry()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_chore(cls: type[Chore]) -> type[Chore]:
|
|
39
|
+
"""
|
|
40
|
+
Decorator to register a chore class.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
@register_chore
|
|
44
|
+
class ClearExpired(Chore):
|
|
45
|
+
def run(self):
|
|
46
|
+
return "Done!"
|
|
47
|
+
"""
|
|
48
|
+
chores_registry.register_chore(cls)
|
|
49
|
+
return cls
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
6
|
- [Adding commands](#adding-commands)
|
|
7
|
+
- [Shell](#shell)
|
|
7
8
|
|
|
8
9
|
## Overview
|
|
9
10
|
|
|
@@ -39,3 +40,28 @@ An example command!
|
|
|
39
40
|
```
|
|
40
41
|
|
|
41
42
|
Technically you can register a CLI from anywhere, but typically you will do it in either `app/cli.py` or a package's `<pkg>/cli.py`, as those modules will be autoloaded by Plain.
|
|
43
|
+
|
|
44
|
+
## Shell
|
|
45
|
+
|
|
46
|
+
The `plain shell` command starts an interactive Python shell with your Plain app already loaded.
|
|
47
|
+
|
|
48
|
+
### SHELL_IMPORT
|
|
49
|
+
|
|
50
|
+
You can customize what gets imported automatically when the shell starts by setting `SHELL_IMPORT` to a module path in your settings:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# app/settings.py
|
|
54
|
+
SHELL_IMPORT = "app.shell"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then create that module with the objects you want available:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
# app/shell.py
|
|
61
|
+
from app.projects.models import Project
|
|
62
|
+
from app.users.models import User
|
|
63
|
+
|
|
64
|
+
__all__ = ["Project", "User"]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Now when you run `plain shell`, those objects will be automatically imported and available.
|
|
@@ -13,11 +13,10 @@ def chores() -> None:
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
@chores.command("list")
|
|
16
|
-
@click.option("--group", default=None, type=str, help="Group to run", multiple=True)
|
|
17
16
|
@click.option(
|
|
18
17
|
"--name", default=None, type=str, help="Name of the chore to run", multiple=True
|
|
19
18
|
)
|
|
20
|
-
def list_chores(
|
|
19
|
+
def list_chores(name: tuple[str, ...]) -> None:
|
|
21
20
|
"""
|
|
22
21
|
List all registered chores.
|
|
23
22
|
"""
|
|
@@ -25,32 +24,33 @@ def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
|
|
|
25
24
|
|
|
26
25
|
chores_registry.import_modules()
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
chore_classes = chores_registry.get_chores()
|
|
28
|
+
|
|
29
|
+
if name:
|
|
30
|
+
chore_classes = [
|
|
31
|
+
chore_class
|
|
32
|
+
for chore_class in chore_classes
|
|
33
|
+
if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
|
|
33
34
|
]
|
|
34
|
-
else:
|
|
35
|
-
chores = chores_registry.get_chores()
|
|
36
35
|
|
|
37
|
-
for
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
for chore_class in chore_classes:
|
|
37
|
+
chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
38
|
+
click.secho(f"{chore_name}", bold=True, nl=False)
|
|
39
|
+
description = chore_class.__doc__.strip() if chore_class.__doc__ else ""
|
|
40
|
+
if description:
|
|
41
|
+
click.secho(f": {description}", dim=True)
|
|
41
42
|
else:
|
|
42
43
|
click.echo("")
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
@chores.command("run")
|
|
46
|
-
@click.option("--group", default=None, type=str, help="Group to run", multiple=True)
|
|
47
47
|
@click.option(
|
|
48
48
|
"--name", default=None, type=str, help="Name of the chore to run", multiple=True
|
|
49
49
|
)
|
|
50
50
|
@click.option(
|
|
51
51
|
"--dry-run", is_flag=True, help="Show what would be done without executing"
|
|
52
52
|
)
|
|
53
|
-
def run_chores(
|
|
53
|
+
def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
|
|
54
54
|
"""
|
|
55
55
|
Run the specified chores.
|
|
56
56
|
"""
|
|
@@ -58,28 +58,30 @@ def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) ->
|
|
|
58
58
|
|
|
59
59
|
chores_registry.import_modules()
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
chore_classes = chores_registry.get_chores()
|
|
62
|
+
|
|
63
|
+
if name:
|
|
64
|
+
chore_classes = [
|
|
65
|
+
chore_class
|
|
66
|
+
for chore_class in chore_classes
|
|
67
|
+
if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
|
|
66
68
|
]
|
|
67
|
-
else:
|
|
68
|
-
chores = chores_registry.get_chores()
|
|
69
69
|
|
|
70
70
|
chores_failed = []
|
|
71
71
|
|
|
72
|
-
for
|
|
73
|
-
|
|
72
|
+
for chore_class in chore_classes:
|
|
73
|
+
chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
74
|
+
click.echo(f"{chore_name}:", nl=False)
|
|
74
75
|
if dry_run:
|
|
75
76
|
click.secho(" (dry run)", fg="yellow", nl=False)
|
|
76
77
|
else:
|
|
77
78
|
try:
|
|
79
|
+
chore = chore_class()
|
|
78
80
|
result = chore.run()
|
|
79
81
|
except Exception:
|
|
80
82
|
click.secho(" Failed", fg="red")
|
|
81
|
-
chores_failed.append(
|
|
82
|
-
logger.exception(f"Error running chore {
|
|
83
|
+
chores_failed.append(chore_class)
|
|
84
|
+
logger.exception(f"Error running chore {chore_name}")
|
|
83
85
|
continue
|
|
84
86
|
|
|
85
87
|
if result is None:
|
|
@@ -12,12 +12,12 @@ from opentelemetry.semconv._incubating.attributes.code_attributes import (
|
|
|
12
12
|
)
|
|
13
13
|
|
|
14
14
|
from plain.http import (
|
|
15
|
+
Http404,
|
|
15
16
|
JsonResponse,
|
|
16
17
|
Request,
|
|
17
18
|
Response,
|
|
18
19
|
ResponseBase,
|
|
19
20
|
ResponseNotAllowed,
|
|
20
|
-
ResponseNotFound,
|
|
21
21
|
)
|
|
22
22
|
from plain.utils.decorators import classonlymethod
|
|
23
23
|
|
|
@@ -110,8 +110,7 @@ class View:
|
|
|
110
110
|
return Response(status_code=value)
|
|
111
111
|
|
|
112
112
|
if value is None:
|
|
113
|
-
|
|
114
|
-
return ResponseNotFound()
|
|
113
|
+
raise Http404
|
|
115
114
|
|
|
116
115
|
status_code = 200
|
|
117
116
|
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from types import FunctionType
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from plain.packages import packages_registry
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class Chore:
|
|
10
|
-
def __init__(self, *, group: str, func: FunctionType):
|
|
11
|
-
self.group = group
|
|
12
|
-
self.func = func
|
|
13
|
-
self.name = f"{group}.{func.__name__}"
|
|
14
|
-
self.description = func.__doc__.strip() if func.__doc__ else ""
|
|
15
|
-
|
|
16
|
-
def __str__(self) -> str:
|
|
17
|
-
return self.name
|
|
18
|
-
|
|
19
|
-
def run(self) -> Any:
|
|
20
|
-
"""
|
|
21
|
-
Run the chore.
|
|
22
|
-
"""
|
|
23
|
-
return self.func()
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class ChoresRegistry:
|
|
27
|
-
def __init__(self):
|
|
28
|
-
self._chores: dict[FunctionType, Chore] = {}
|
|
29
|
-
|
|
30
|
-
def register_chore(self, chore: Chore) -> None:
|
|
31
|
-
"""
|
|
32
|
-
Register a chore with the specified name.
|
|
33
|
-
"""
|
|
34
|
-
self._chores[chore.func] = chore
|
|
35
|
-
|
|
36
|
-
def import_modules(self) -> None:
|
|
37
|
-
"""
|
|
38
|
-
Import modules from installed packages and app to trigger registration.
|
|
39
|
-
"""
|
|
40
|
-
packages_registry.autodiscover_modules("chores", include_app=True)
|
|
41
|
-
|
|
42
|
-
def get_chores(self) -> list[Chore]:
|
|
43
|
-
"""
|
|
44
|
-
Get all registered chores.
|
|
45
|
-
"""
|
|
46
|
-
return list(self._chores.values())
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
chores_registry = ChoresRegistry()
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def register_chore(group: str) -> Any:
|
|
53
|
-
"""
|
|
54
|
-
Register a chore with a given group.
|
|
55
|
-
|
|
56
|
-
Usage:
|
|
57
|
-
@register_chore("clear_expired")
|
|
58
|
-
def clear_expired():
|
|
59
|
-
pass
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
def wrapper(func: FunctionType) -> FunctionType:
|
|
63
|
-
chore = Chore(group=group, func=func)
|
|
64
|
-
chores_registry.register_chore(chore)
|
|
65
|
-
return func
|
|
66
|
-
|
|
67
|
-
return wrapper
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|