plain 0.76.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.76.0 → plain-0.78.0}/PKG-INFO +2 -1
- {plain-0.76.0 → plain-0.78.0}/plain/CHANGELOG.md +46 -0
- {plain-0.76.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.76.0 → plain-0.78.0}/plain/cli/README.md +26 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/chores.py +28 -26
- {plain-0.76.0 → plain-0.78.0}/plain/cli/server.py +0 -8
- plain-0.78.0/plain/internal/reloader.py +77 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/README.md +1 -2
- {plain-0.76.0 → plain-0.78.0}/plain/server/arbiter.py +2 -2
- {plain-0.76.0 → plain-0.78.0}/plain/server/config.py +6 -6
- {plain-0.76.0 → plain-0.78.0}/plain/server/util.py +0 -67
- plain-0.78.0/plain/server/workers/__init__.py +6 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/workers/base.py +4 -7
- {plain-0.76.0 → plain-0.78.0}/plain/views/base.py +2 -3
- {plain-0.76.0 → plain-0.78.0}/pyproject.toml +2 -1
- plain-0.76.0/plain/chores/__init__.py +0 -3
- plain-0.76.0/plain/chores/registry.py +0 -67
- plain-0.76.0/plain/server/reloader.py +0 -158
- plain-0.76.0/plain/server/workers/__init__.py +0 -12
- {plain-0.76.0 → plain-0.78.0}/.gitignore +0 -0
- {plain-0.76.0 → plain-0.78.0}/LICENSE +0 -0
- {plain-0.76.0 → plain-0.78.0}/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/AGENTS.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/__main__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/assets/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/assets/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/assets/compile.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/assets/finders.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/assets/fingerprints.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/assets/urls.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/assets/views.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/docs.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/llmdocs.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/md.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/prompt.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/request.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/build.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/changelog.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/core.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/docs.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/formatting.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/install.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/output.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/preflight.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/print.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/registry.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/runtime.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/scaffold.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/settings.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/shell.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/startup.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/upgrade.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/urls.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/cli/utils.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/csrf/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/csrf/middleware.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/csrf/views.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/debug.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/exceptions.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/forms/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/forms/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/forms/boundfield.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/forms/exceptions.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/forms/fields.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/forms/forms.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/http/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/http/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/http/cookie.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/http/multipartparser.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/http/request.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/http/response.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/base.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/locks.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/move.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/temp.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/uploadedfile.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/uploadhandler.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/files/utils.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/base.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/exception.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/wsgi.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/headers.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/hosts.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/https.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/slash.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/json.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/logs/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/logs/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/logs/configure.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/logs/debug.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/logs/formatters.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/logs/loggers.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/logs/utils.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/packages/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/packages/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/packages/config.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/packages/registry.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/paginator.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/checks.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/files.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/registry.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/results.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/security.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/preflight/urls.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/runtime/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/runtime/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/runtime/global_settings.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/runtime/user_settings.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/runtime/utils.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/LICENSE +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/app.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/errors.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/glogging.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/http/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/http/body.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/http/errors.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/http/message.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/http/parser.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/http/unreader.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/http/wsgi.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/pidfile.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/sock.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/workers/sync.py +0 -0
- /plain-0.76.0/plain/server/workers/gthread.py → /plain-0.78.0/plain/server/workers/thread.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/server/workers/workertmp.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/signals/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/signals/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/signals/dispatch/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/signals/dispatch/dispatcher.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/signals/dispatch/license.txt +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/signing.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/AGENTS.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/core.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/environments.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/extensions.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/filters.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/globals.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/test/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/test/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/test/client.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/test/encoding.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/test/exceptions.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/converters.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/exceptions.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/patterns.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/resolvers.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/routers.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/urls/utils.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/cache.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/crypto.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/datastructures.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/dateparse.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/deconstruct.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/decorators.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/duration.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/encoding.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/functional.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/hashable.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/html.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/http.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/inspect.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/ipv6.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/itercompat.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/module_loading.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/regex_helper.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/safestring.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/text.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/timesince.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/timezone.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/utils/tree.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/validators.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/README.md +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/errors.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/exceptions.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/forms.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/objects.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/redirect.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/views/templates.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/plain/wsgi.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/.gitignore +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/app/.gitignore +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/app/settings.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/app/test/__init__.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/app/test/default_settings.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/app/urls.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/conftest.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/test_cli.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/test_csrf.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/test_http_hosts.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/test_logs.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/test_runtime.py +0 -0
- {plain-0.76.0 → plain-0.78.0}/tests/test_wsgi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plain
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.78.0
|
|
4
4
|
Summary: A web framework for building products with Python.
|
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -9,6 +9,7 @@ Requires-Dist: click>=8.0.0
|
|
|
9
9
|
Requires-Dist: jinja2>=3.1.2
|
|
10
10
|
Requires-Dist: opentelemetry-api>=1.34.1
|
|
11
11
|
Requires-Dist: opentelemetry-semantic-conventions>=0.55b1
|
|
12
|
+
Requires-Dist: watchfiles>=0.18.0
|
|
12
13
|
Description-Content-Type: text/markdown
|
|
13
14
|
|
|
14
15
|
# Plain
|
|
@@ -1,5 +1,51 @@
|
|
|
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
|
+
|
|
36
|
+
## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
|
|
37
|
+
|
|
38
|
+
### What's changed
|
|
39
|
+
|
|
40
|
+
- The `plain server --reload` now uses `watchfiles` for improved cross-platform file watching ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
|
|
41
|
+
- Server reloader now watches `.env*` files for changes and triggers automatic reload ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
|
|
42
|
+
- HTML template additions and deletions now trigger automatic server reload when using `--reload` ([f2f31c288b](https://github.com/dropseed/plain/commit/f2f31c288b))
|
|
43
|
+
- Internal server worker type renamed from "gthread" to "thread" for clarity ([6470748e91](https://github.com/dropseed/plain/commit/6470748e91))
|
|
44
|
+
|
|
45
|
+
### Upgrade instructions
|
|
46
|
+
|
|
47
|
+
- No changes required
|
|
48
|
+
|
|
3
49
|
## [0.76.0](https://github.com/dropseed/plain/releases/plain@0.76.0) (2025-10-12)
|
|
4
50
|
|
|
5
51
|
### 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:
|
|
@@ -58,12 +58,6 @@ from plain.cli.runtime import without_runtime_setup
|
|
|
58
58
|
is_flag=True,
|
|
59
59
|
help="Restart workers when code changes (dev only)",
|
|
60
60
|
)
|
|
61
|
-
@click.option(
|
|
62
|
-
"--reload-extra-file",
|
|
63
|
-
multiple=True,
|
|
64
|
-
type=click.Path(exists=True),
|
|
65
|
-
help="Additional files to watch for reload (can be used multiple times)",
|
|
66
|
-
)
|
|
67
61
|
@click.option(
|
|
68
62
|
"--access-log",
|
|
69
63
|
default="-",
|
|
@@ -109,7 +103,6 @@ def server(
|
|
|
109
103
|
keyfile: str | None,
|
|
110
104
|
log_level: str,
|
|
111
105
|
reload: bool,
|
|
112
|
-
reload_extra_file: tuple[str, ...],
|
|
113
106
|
access_log: str,
|
|
114
107
|
error_log: str,
|
|
115
108
|
log_format: str,
|
|
@@ -130,7 +123,6 @@ def server(
|
|
|
130
123
|
timeout=timeout,
|
|
131
124
|
max_requests=max_requests,
|
|
132
125
|
reload=reload,
|
|
133
|
-
reload_extra_files=list(reload_extra_file) if reload_extra_file else [],
|
|
134
126
|
pidfile=pidfile,
|
|
135
127
|
certfile=certfile,
|
|
136
128
|
keyfile=keyfile,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import os.path
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
import watchfiles
|
|
11
|
+
|
|
12
|
+
COMPILED_EXT_RE = re.compile(r"py[co]$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Reloader(threading.Thread):
|
|
16
|
+
"""File change reloader using watchfiles for cross-platform native file watching."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, callback: Callable[[str], None], watch_html: bool) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.daemon = True
|
|
21
|
+
self._callback = callback
|
|
22
|
+
self._watch_html = watch_html
|
|
23
|
+
|
|
24
|
+
def get_watch_paths(self) -> set[str]:
|
|
25
|
+
"""Get all directories to watch for changes."""
|
|
26
|
+
paths = set()
|
|
27
|
+
|
|
28
|
+
# Get directories from loaded Python modules
|
|
29
|
+
for module in tuple(sys.modules.values()):
|
|
30
|
+
if not hasattr(module, "__file__") or not module.__file__:
|
|
31
|
+
continue
|
|
32
|
+
# Convert .pyc/.pyo to .py and get directory
|
|
33
|
+
file_path = COMPILED_EXT_RE.sub("py", module.__file__)
|
|
34
|
+
dir_path = os.path.dirname(os.path.abspath(file_path))
|
|
35
|
+
if os.path.isdir(dir_path):
|
|
36
|
+
paths.add(dir_path)
|
|
37
|
+
|
|
38
|
+
# Add current working directory for .env files
|
|
39
|
+
cwd = os.getcwd()
|
|
40
|
+
if os.path.isdir(cwd):
|
|
41
|
+
paths.add(cwd)
|
|
42
|
+
|
|
43
|
+
return paths
|
|
44
|
+
|
|
45
|
+
def run(self) -> None:
|
|
46
|
+
"""Watch for file changes and trigger callback."""
|
|
47
|
+
watch_paths = self.get_watch_paths()
|
|
48
|
+
|
|
49
|
+
for changes in watchfiles.watch(*watch_paths, rust_timeout=1000):
|
|
50
|
+
for change_type, file_path in changes:
|
|
51
|
+
should_reload = False
|
|
52
|
+
filename = os.path.basename(file_path)
|
|
53
|
+
|
|
54
|
+
# Python files: reload on modify/add
|
|
55
|
+
if change_type in (watchfiles.Change.modified, watchfiles.Change.added):
|
|
56
|
+
if file_path.endswith(".py"):
|
|
57
|
+
should_reload = True
|
|
58
|
+
|
|
59
|
+
# .env files: reload on modify/add/delete
|
|
60
|
+
if change_type in (
|
|
61
|
+
watchfiles.Change.modified,
|
|
62
|
+
watchfiles.Change.added,
|
|
63
|
+
watchfiles.Change.deleted,
|
|
64
|
+
):
|
|
65
|
+
if filename.startswith(".env"):
|
|
66
|
+
should_reload = True
|
|
67
|
+
|
|
68
|
+
# HTML files: only reload on add/delete (Jinja auto-reloads modifications)
|
|
69
|
+
if self._watch_html and change_type in (
|
|
70
|
+
watchfiles.Change.added,
|
|
71
|
+
watchfiles.Change.deleted,
|
|
72
|
+
):
|
|
73
|
+
if file_path.endswith(".html"):
|
|
74
|
+
should_reload = True
|
|
75
|
+
|
|
76
|
+
if should_reload:
|
|
77
|
+
self._callback(file_path)
|
|
@@ -39,8 +39,7 @@ Common options:
|
|
|
39
39
|
- `--workers` / `-w` - Number of worker processes (default: 1, or `$WEB_CONCURRENCY` env var)
|
|
40
40
|
- `--threads` - Number of threads per worker (default: 1)
|
|
41
41
|
- `--timeout` / `-t` - Worker timeout in seconds (default: 30)
|
|
42
|
-
- `--reload` - Enable auto-reload on code changes (default: False)
|
|
43
|
-
- `--reload-extra-file` - Additional files to watch for reloading (can be used multiple times)
|
|
42
|
+
- `--reload` - Enable auto-reload on code changes, including `.env*` files (default: False)
|
|
44
43
|
- `--certfile` - Path to SSL certificate file
|
|
45
44
|
- `--keyfile` - Path to SSL key file
|
|
46
45
|
- `--log-level` - Logging level: debug, info, warning, error, critical (default: info)
|
|
@@ -490,7 +490,7 @@ class Arbiter:
|
|
|
490
490
|
# Process Child
|
|
491
491
|
worker.pid = os.getpid()
|
|
492
492
|
try:
|
|
493
|
-
self.log.info("
|
|
493
|
+
self.log.info("Server worker started pid=%s", worker.pid)
|
|
494
494
|
worker.init_process()
|
|
495
495
|
sys.exit(0)
|
|
496
496
|
except SystemExit:
|
|
@@ -506,7 +506,7 @@ class Arbiter:
|
|
|
506
506
|
sys.exit(self.WORKER_BOOT_ERROR)
|
|
507
507
|
sys.exit(-1)
|
|
508
508
|
finally:
|
|
509
|
-
self.log.info("
|
|
509
|
+
self.log.info("Server worker exiting (pid: %s)", worker.pid)
|
|
510
510
|
try:
|
|
511
511
|
worker.tmp.close()
|
|
512
512
|
except Exception:
|
|
@@ -10,6 +10,8 @@ import os
|
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
|
|
12
12
|
from . import util
|
|
13
|
+
from .workers.sync import SyncWorker
|
|
14
|
+
from .workers.thread import ThreadWorker
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
@dataclass
|
|
@@ -27,7 +29,6 @@ class Config:
|
|
|
27
29
|
timeout: int
|
|
28
30
|
max_requests: int
|
|
29
31
|
reload: bool
|
|
30
|
-
reload_extra_files: list[str]
|
|
31
32
|
pidfile: str | None
|
|
32
33
|
certfile: str | None
|
|
33
34
|
keyfile: str | None
|
|
@@ -41,20 +42,19 @@ class Config:
|
|
|
41
42
|
def worker_class_str(self) -> str:
|
|
42
43
|
# Auto-select based on threads
|
|
43
44
|
if self.threads > 1:
|
|
44
|
-
return "
|
|
45
|
+
return "thread"
|
|
45
46
|
return "sync"
|
|
46
47
|
|
|
47
48
|
@property
|
|
48
49
|
def worker_class(self) -> type:
|
|
49
50
|
# Auto-select based on threads
|
|
50
51
|
if self.threads > 1:
|
|
51
|
-
|
|
52
|
+
worker_class = ThreadWorker
|
|
52
53
|
else:
|
|
53
|
-
|
|
54
|
+
worker_class = SyncWorker
|
|
54
55
|
|
|
55
|
-
worker_class = util.load_class(uri)
|
|
56
56
|
if hasattr(worker_class, "setup"):
|
|
57
|
-
worker_class.setup()
|
|
57
|
+
worker_class.setup()
|
|
58
58
|
return worker_class
|
|
59
59
|
|
|
60
60
|
@property
|
|
@@ -10,8 +10,6 @@ import email.utils
|
|
|
10
10
|
import errno
|
|
11
11
|
import fcntl
|
|
12
12
|
import html
|
|
13
|
-
import importlib
|
|
14
|
-
import inspect
|
|
15
13
|
import io
|
|
16
14
|
import os
|
|
17
15
|
import random
|
|
@@ -20,14 +18,11 @@ import socket
|
|
|
20
18
|
import sys
|
|
21
19
|
import textwrap
|
|
22
20
|
import time
|
|
23
|
-
import traceback
|
|
24
21
|
import urllib.parse
|
|
25
22
|
import warnings
|
|
26
23
|
from collections.abc import Callable
|
|
27
24
|
from typing import Any
|
|
28
25
|
|
|
29
|
-
from .workers import SUPPORTED_WORKERS
|
|
30
|
-
|
|
31
26
|
# Server and Date aren't technically hop-by-hop
|
|
32
27
|
# headers, but they are in the purview of the
|
|
33
28
|
# origin server which the WSGI spec says we should
|
|
@@ -44,55 +39,6 @@ hop_headers = set(
|
|
|
44
39
|
""".split()
|
|
45
40
|
)
|
|
46
41
|
|
|
47
|
-
|
|
48
|
-
def load_class(
|
|
49
|
-
uri: str | type,
|
|
50
|
-
default: str = "plain.server.workers.sync.SyncWorker",
|
|
51
|
-
section: str = "plain.server.workers",
|
|
52
|
-
) -> type:
|
|
53
|
-
if inspect.isclass(uri):
|
|
54
|
-
return uri # type: ignore[return-value]
|
|
55
|
-
|
|
56
|
-
components = uri.split(".") # type: ignore[union-attr]
|
|
57
|
-
if len(components) == 1:
|
|
58
|
-
# Handle short names like "sync" or "gthread"
|
|
59
|
-
if uri.startswith("#"): # type: ignore[union-attr]
|
|
60
|
-
uri = uri[1:] # type: ignore[union-attr]
|
|
61
|
-
|
|
62
|
-
if uri in SUPPORTED_WORKERS:
|
|
63
|
-
components = SUPPORTED_WORKERS[uri].split(".")
|
|
64
|
-
else:
|
|
65
|
-
exc_msg = f"Worker type {uri!r} not found in SUPPORTED_WORKERS"
|
|
66
|
-
raise RuntimeError(exc_msg)
|
|
67
|
-
|
|
68
|
-
klass = components.pop(-1)
|
|
69
|
-
|
|
70
|
-
try:
|
|
71
|
-
mod = importlib.import_module(".".join(components))
|
|
72
|
-
except Exception:
|
|
73
|
-
exc = traceback.format_exc()
|
|
74
|
-
msg = "class uri %r invalid or not found: \n\n[%s]"
|
|
75
|
-
raise RuntimeError(msg % (uri, exc))
|
|
76
|
-
return getattr(mod, klass)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
positionals = (
|
|
80
|
-
inspect.Parameter.POSITIONAL_ONLY,
|
|
81
|
-
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def get_arity(f: Callable[..., Any]) -> int:
|
|
86
|
-
sig = inspect.signature(f)
|
|
87
|
-
arity = 0
|
|
88
|
-
|
|
89
|
-
for param in sig.parameters.values():
|
|
90
|
-
if param.kind in positionals:
|
|
91
|
-
arity += 1
|
|
92
|
-
|
|
93
|
-
return arity
|
|
94
|
-
|
|
95
|
-
|
|
96
42
|
if sys.platform.startswith("win"):
|
|
97
43
|
|
|
98
44
|
def _waitfor(
|
|
@@ -317,19 +263,6 @@ def has_fileno(obj: Any) -> bool:
|
|
|
317
263
|
return True
|
|
318
264
|
|
|
319
265
|
|
|
320
|
-
def warn(msg: str) -> None:
|
|
321
|
-
print("!!!", file=sys.stderr)
|
|
322
|
-
|
|
323
|
-
lines = msg.splitlines()
|
|
324
|
-
for i, line in enumerate(lines):
|
|
325
|
-
if i == 0:
|
|
326
|
-
line = f"WARNING: {line}"
|
|
327
|
-
print(f"!!! {line}", file=sys.stderr)
|
|
328
|
-
|
|
329
|
-
print("!!!\n", file=sys.stderr)
|
|
330
|
-
sys.stderr.flush()
|
|
331
|
-
|
|
332
|
-
|
|
333
266
|
def make_fail_app(msg: str | bytes) -> Callable[..., Any]:
|
|
334
267
|
msg = to_bytestring(msg)
|
|
335
268
|
|