plain 0.76.0__py3-none-any.whl → 0.78.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of plain might be problematic. Click here for more details.
- plain/CHANGELOG.md +46 -0
- plain/chores/README.md +24 -22
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +20 -38
- plain/cli/README.md +26 -0
- plain/cli/chores.py +28 -26
- plain/cli/server.py +0 -8
- plain/internal/reloader.py +77 -0
- plain/server/README.md +1 -2
- plain/server/arbiter.py +2 -2
- plain/server/config.py +6 -6
- plain/server/util.py +0 -67
- plain/server/workers/__init__.py +0 -6
- plain/server/workers/base.py +4 -7
- plain/views/base.py +2 -3
- {plain-0.76.0.dist-info → plain-0.78.0.dist-info}/METADATA +2 -1
- {plain-0.76.0.dist-info → plain-0.78.0.dist-info}/RECORD +22 -21
- plain/server/reloader.py +0 -158
- /plain/server/workers/{gthread.py → thread.py} +0 -0
- {plain-0.76.0.dist-info → plain-0.78.0.dist-info}/WHEEL +0 -0
- {plain-0.76.0.dist-info → plain-0.78.0.dist-info}/entry_points.txt +0 -0
- {plain-0.76.0.dist-info → plain-0.78.0.dist-info}/licenses/LICENSE +0 -0
plain/CHANGELOG.md
CHANGED
|
@@ -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
|
plain/chores/README.md
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.
|
plain/chores/__init__.py
CHANGED
plain/chores/core.py
ADDED
|
@@ -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
|
plain/chores/registry.py
CHANGED
|
@@ -1,37 +1,23 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from types import FunctionType
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
3
|
from plain.packages import packages_registry
|
|
7
4
|
|
|
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()
|
|
5
|
+
from .core import Chore
|
|
24
6
|
|
|
25
7
|
|
|
26
8
|
class ChoresRegistry:
|
|
27
|
-
def __init__(self):
|
|
28
|
-
self._chores: dict[
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._chores: dict[str, type[Chore]] = {}
|
|
29
11
|
|
|
30
|
-
def register_chore(self,
|
|
12
|
+
def register_chore(self, chore_class: type[Chore]) -> None:
|
|
31
13
|
"""
|
|
32
|
-
Register a chore
|
|
14
|
+
Register a chore class.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
chore_class: A Chore subclass to register
|
|
33
18
|
"""
|
|
34
|
-
|
|
19
|
+
name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
20
|
+
self._chores[name] = chore_class
|
|
35
21
|
|
|
36
22
|
def import_modules(self) -> None:
|
|
37
23
|
"""
|
|
@@ -39,9 +25,9 @@ class ChoresRegistry:
|
|
|
39
25
|
"""
|
|
40
26
|
packages_registry.autodiscover_modules("chores", include_app=True)
|
|
41
27
|
|
|
42
|
-
def get_chores(self) -> list[Chore]:
|
|
28
|
+
def get_chores(self) -> list[type[Chore]]:
|
|
43
29
|
"""
|
|
44
|
-
Get all registered
|
|
30
|
+
Get all registered chore classes.
|
|
45
31
|
"""
|
|
46
32
|
return list(self._chores.values())
|
|
47
33
|
|
|
@@ -49,19 +35,15 @@ class ChoresRegistry:
|
|
|
49
35
|
chores_registry = ChoresRegistry()
|
|
50
36
|
|
|
51
37
|
|
|
52
|
-
def register_chore(
|
|
38
|
+
def register_chore(cls: type[Chore]) -> type[Chore]:
|
|
53
39
|
"""
|
|
54
|
-
|
|
40
|
+
Decorator to register a chore class.
|
|
55
41
|
|
|
56
42
|
Usage:
|
|
57
|
-
@register_chore
|
|
58
|
-
|
|
59
|
-
|
|
43
|
+
@register_chore
|
|
44
|
+
class ClearExpired(Chore):
|
|
45
|
+
def run(self):
|
|
46
|
+
return "Done!"
|
|
60
47
|
"""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
chore = Chore(group=group, func=func)
|
|
64
|
-
chores_registry.register_chore(chore)
|
|
65
|
-
return func
|
|
66
|
-
|
|
67
|
-
return wrapper
|
|
48
|
+
chores_registry.register_chore(cls)
|
|
49
|
+
return cls
|
plain/cli/README.md
CHANGED
|
@@ -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.
|
plain/cli/chores.py
CHANGED
|
@@ -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:
|
plain/cli/server.py
CHANGED
|
@@ -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)
|
plain/server/README.md
CHANGED
|
@@ -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)
|
plain/server/arbiter.py
CHANGED
|
@@ -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:
|
plain/server/config.py
CHANGED
|
@@ -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
|
plain/server/util.py
CHANGED
|
@@ -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
|
|
plain/server/workers/__init__.py
CHANGED
plain/server/workers/base.py
CHANGED
|
@@ -17,6 +17,8 @@ from random import randint
|
|
|
17
17
|
from ssl import SSLError
|
|
18
18
|
from typing import TYPE_CHECKING, Any
|
|
19
19
|
|
|
20
|
+
from plain.internal.reloader import Reloader
|
|
21
|
+
|
|
20
22
|
from .. import util
|
|
21
23
|
from ..http.errors import (
|
|
22
24
|
ConfigurationProblem,
|
|
@@ -32,7 +34,6 @@ from ..http.errors import (
|
|
|
32
34
|
UnsupportedTransferCoding,
|
|
33
35
|
)
|
|
34
36
|
from ..http.wsgi import Response, default_environ
|
|
35
|
-
from ..reloader import reloader_engines
|
|
36
37
|
from .workertmp import WorkerTmp
|
|
37
38
|
|
|
38
39
|
if TYPE_CHECKING:
|
|
@@ -143,16 +144,13 @@ class Worker:
|
|
|
143
144
|
if self.cfg.reload:
|
|
144
145
|
|
|
145
146
|
def changed(fname: str) -> None:
|
|
146
|
-
self.log.
|
|
147
|
+
self.log.debug("Server worker reloading: %s modified", fname)
|
|
147
148
|
self.alive = False
|
|
148
149
|
os.write(self.PIPE[1], b"1")
|
|
149
150
|
time.sleep(0.1)
|
|
150
151
|
sys.exit(0)
|
|
151
152
|
|
|
152
|
-
|
|
153
|
-
self.reloader = reloader_cls(
|
|
154
|
-
extra_files=self.cfg.reload_extra_files, callback=changed
|
|
155
|
-
)
|
|
153
|
+
self.reloader = Reloader(callback=changed, watch_html=True)
|
|
156
154
|
|
|
157
155
|
self.load_wsgi()
|
|
158
156
|
if self.reloader:
|
|
@@ -177,7 +175,6 @@ class Worker:
|
|
|
177
175
|
# delete the traceback after use.
|
|
178
176
|
try:
|
|
179
177
|
_, exc_val, exc_tb = sys.exc_info()
|
|
180
|
-
self.reloader.add_extra_file(exc_val.filename)
|
|
181
178
|
|
|
182
179
|
tb_string = io.StringIO()
|
|
183
180
|
traceback.print_tb(exc_tb, file=tb_string)
|
plain/views/base.py
CHANGED
|
@@ -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,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,5 @@
|
|
|
1
1
|
plain/AGENTS.md,sha256=As6EFSWWHJ9lYIxb2LMRqNRteH45SRs7a_VFslzF53M,1046
|
|
2
|
-
plain/CHANGELOG.md,sha256=
|
|
2
|
+
plain/CHANGELOG.md,sha256=dsVliMKkLQz3IsqxyYeQh4QrFLDkQwyJ-axQ4Jet6aw,27925
|
|
3
3
|
plain/README.md,sha256=VvzhXNvf4I6ddmjBP9AExxxFXxs7RwyoxdgFm-W5dsg,4072
|
|
4
4
|
plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
|
|
5
5
|
plain/debug.py,sha256=C2OnFHtRGMrpCiHSt-P2r58JypgQZ62qzDBpV4mhtFM,855
|
|
@@ -16,14 +16,15 @@ plain/assets/finders.py,sha256=j9sZ2LAJp55hdJ_e1lFevzwicNEMUtv6rLtQgiIkDgY,1589
|
|
|
16
16
|
plain/assets/fingerprints.py,sha256=UPJwLmzzqXBp7FPk9XnMkcTps2UW9SnTAv0hGieUnMo,1458
|
|
17
17
|
plain/assets/urls.py,sha256=Qe0ctXYAQjAlLIKuX6JeLVBOFqzJxggR0RSGkRXmX78,1173
|
|
18
18
|
plain/assets/views.py,sha256=Zs_wyguh-WljOgW3DiPfpspTVOKC6z3bRSu01-SDcoU,9877
|
|
19
|
-
plain/chores/README.md,sha256=
|
|
20
|
-
plain/chores/__init__.py,sha256=
|
|
21
|
-
plain/chores/
|
|
22
|
-
plain/
|
|
19
|
+
plain/chores/README.md,sha256=7Dv5MCyqPaohQxAk74HRzKiTvt_yR-IOhbu7R5bdz6M,2437
|
|
20
|
+
plain/chores/__init__.py,sha256=wEdt-oAKS1kz7Ln-puhcCt8XGNdfCP4S3wHESaPU8nI,100
|
|
21
|
+
plain/chores/core.py,sha256=BxsCSJDQvMjYsAH4QhEoW9ZUEAUIwJgTYyHovPGSLjk,578
|
|
22
|
+
plain/chores/registry.py,sha256=IRpx3f6Z1qlqcEpHTe6O6JocNmaplLu7BVOqGrafSXU,1221
|
|
23
|
+
plain/cli/README.md,sha256=Wn6o0fVL-SRMztTTHO71P4QzOvntgO-MqjbRrsk3WAw,1661
|
|
23
24
|
plain/cli/__init__.py,sha256=6w9T7K2WrPwh6DcaMb2oNt_CWU6Bc57nUTO2Bt1p38Y,63
|
|
24
25
|
plain/cli/build.py,sha256=Jg5LMbmXHhCXZIYj_Gcjou3yGiEWw8wpbOGGsdk-wZw,3203
|
|
25
26
|
plain/cli/changelog.py,sha256=yCY887PT_D2viLz9f-uyu07Znqiv2-NEyCBqquNIukw,3590
|
|
26
|
-
plain/cli/chores.py,sha256=
|
|
27
|
+
plain/cli/chores.py,sha256=FzQat9ofEZTlCiszGvV3deJwx8G0c-fBhn1mI560Iec,2568
|
|
27
28
|
plain/cli/core.py,sha256=nL-a7zPtEBIa_XV-VOd9lqEUqmQAhlzsdHNwEmxNkWE,4285
|
|
28
29
|
plain/cli/docs.py,sha256=PU3v7Z7qgYFG-bClpuDg4JeWwC8uvLYX3ovkQDMseVs,1146
|
|
29
30
|
plain/cli/formatting.py,sha256=e1doTFalAM11bD_Cvqeu6sTap81JrQcB-4kMjZzAHmY,2737
|
|
@@ -34,7 +35,7 @@ plain/cli/print.py,sha256=7kv9ddXpwOHRSWp6FFLfX4wbmhV7neoOBlE0VcXWccw,238
|
|
|
34
35
|
plain/cli/registry.py,sha256=Z52nVE2bC2h_B_SENnXctn3mx3UWB0qYg969DVP7XX8,1106
|
|
35
36
|
plain/cli/runtime.py,sha256=YbGYfwkH0VxfuIMbOCwM9wSWiQKusPn_gVeGod8OFaE,743
|
|
36
37
|
plain/cli/scaffold.py,sha256=AMAVnTYySgR5nz4sVp3mn0gEGfTKE1N8ZlrVrg2SrFU,1364
|
|
37
|
-
plain/cli/server.py,sha256=
|
|
38
|
+
plain/cli/server.py,sha256=ngUMtxB90t5FnG7HQwOIkN-pwY6ltKPZZ4PLSqN2Y3E,3001
|
|
38
39
|
plain/cli/settings.py,sha256=kafbcPzy8khdtzLRyOHRl215va3E7U_h5USOA39UA3k,2008
|
|
39
40
|
plain/cli/shell.py,sha256=urTp24D4UsKmYi9nT7OOdlT4WhXjkpFVrGYfNNVsXEE,1980
|
|
40
41
|
plain/cli/startup.py,sha256=1nxXQucDkBxXriEN4wI2tiwG96PBNFndVrOyfzvJFdI,1061
|
|
@@ -63,6 +64,7 @@ plain/http/multipartparser.py,sha256=3W9osVGV9LshNF3aAUCBp7OBYTgD6hN2jS7T15BIKCs
|
|
|
63
64
|
plain/http/request.py,sha256=ficL1Lh-71tU1SVFKD4beLEJsPk7eesZG0nPPbACMTk,26462
|
|
64
65
|
plain/http/response.py,sha256=efAJ2M_uwK8EYMXchOk-b0Jrx3Hukch_rPOW9nG5AV8,24842
|
|
65
66
|
plain/internal/__init__.py,sha256=n2AgdfNelt_tp8CS9JDzHMy_aiTUMPGZiFFwKmNz2fg,262
|
|
67
|
+
plain/internal/reloader.py,sha256=n7B-F-WeUXp37pAnvzKX9tcEbUxHSlYqa4gItyA_zko,2662
|
|
66
68
|
plain/internal/files/__init__.py,sha256=VctFgox4Q1AWF3klPaoCC5GIw5KeLafYjY5JmN8mAVw,63
|
|
67
69
|
plain/internal/files/base.py,sha256=TiUIAqBSQCslgAmf5vjrwjbCe2px5Pt0wWLlGc66jXw,4683
|
|
68
70
|
plain/internal/files/locks.py,sha256=jvLL9kroOo50kUo8dbuajDiFvgSL5NH6x5hudRPPjiQ,4022
|
|
@@ -105,17 +107,16 @@ plain/runtime/global_settings.py,sha256=Q-bQP3tNnnuJZvfevGai639RIF_jhd7Dszt-DzTT
|
|
|
105
107
|
plain/runtime/user_settings.py,sha256=Tbd0J6bxp18tKmFsQcdlxeFhUQU68PYtsswzQ2IcfNc,11467
|
|
106
108
|
plain/runtime/utils.py,sha256=sHOv9SWCalBtg32GtZofimM2XaQtf_jAyyf6RQuOlGc,851
|
|
107
109
|
plain/server/LICENSE,sha256=Xt_dw4qYwQI9qSi2u8yMZeb4HuMRp5tESRKhtvvJBgA,1707
|
|
108
|
-
plain/server/README.md,sha256=
|
|
110
|
+
plain/server/README.md,sha256=6jXQeZJVDt6Jn-Ff8Q7cZ1Xsh6fYPFfC-rtQ7zSYzag,2661
|
|
109
111
|
plain/server/__init__.py,sha256=DtRgEcr4IxF4mrtCHloIprk_Q4k1oju4F2VHoyvu4ow,212
|
|
110
112
|
plain/server/app.py,sha256=ozaqdb-a_T3ps7T5EJwIPM63F_497J4o7kw9Pbq7Ga0,1229
|
|
111
|
-
plain/server/arbiter.py,sha256=
|
|
112
|
-
plain/server/config.py,sha256
|
|
113
|
+
plain/server/arbiter.py,sha256=89_4CZn6v0kmfF3-h6y5Ax59Tm9vhYiZw7psuoHMe_A,17404
|
|
114
|
+
plain/server/config.py,sha256=-T1w8dbUwwLd898P1HNufe8Kw8VJDYJaeKY-UuKzvTo,3221
|
|
113
115
|
plain/server/errors.py,sha256=sKl_OJ5Uw-a_r_dZ2o4I8JaKeTrjvY_LR12F6B_p4-g,956
|
|
114
116
|
plain/server/glogging.py,sha256=Ab49Btbr9UvGRgBk8KGVrzsJaFKN1uD0ZurmIC0GYFY,9692
|
|
115
117
|
plain/server/pidfile.py,sha256=8Fcl9u7gvUJjY5z01qGAeRsi13_jAM8CRdeyqL3h2i0,2538
|
|
116
|
-
plain/server/reloader.py,sha256=x3Oe1qmlprnkNRa-RQeuANdfmpRQvMSFfdTtBPIA5z0,4517
|
|
117
118
|
plain/server/sock.py,sha256=NFKtlrMstOT3xU2yKI6BLAzv_WE7VEGt3dHDkFPnPSo,6192
|
|
118
|
-
plain/server/util.py,sha256=
|
|
119
|
+
plain/server/util.py,sha256=vlTzH4jk8s-ZxJt0oAz-LaQZq_8lEk86zMDVClluuwY,8758
|
|
119
120
|
plain/server/http/__init__.py,sha256=kQwTk1l3hYJwVrzr1p-XNAbWYe0icsD7l0ZyGRXMbOI,300
|
|
120
121
|
plain/server/http/body.py,sha256=cz18F4C_gm9h5XvsStV0ncanBjZO1-pegbmbmiMpsQA,8352
|
|
121
122
|
plain/server/http/errors.py,sha256=uqrzOzjjdqXfFim64pc58cxPlbsoxRNiLx0WzIJSzkw,3719
|
|
@@ -123,10 +124,10 @@ plain/server/http/message.py,sha256=5UdOA7CMqiXW_xy0c3d_rPHOvj5YQQHhbkx4Hf3Ttbc,
|
|
|
123
124
|
plain/server/http/parser.py,sha256=TLcqdRS9_008n8MpgLORmjomyZKLuKNzdKA8Cej8mII,1681
|
|
124
125
|
plain/server/http/unreader.py,sha256=jD2PGZ574FGmQOZlqWfs1VWzp-ttfIso_GzYaId6KYQ,2238
|
|
125
126
|
plain/server/http/wsgi.py,sha256=D-wVKdgKppDR_ZQd834yXtju60t1Jg5bFc5QEr7Iz38,13897
|
|
126
|
-
plain/server/workers/__init__.py,sha256=
|
|
127
|
-
plain/server/workers/base.py,sha256=
|
|
128
|
-
plain/server/workers/gthread.py,sha256=6F_YfhrlPV3hmxwI8_-jgGq4pCmkQBKVO15Wrg-iQ7Y,13504
|
|
127
|
+
plain/server/workers/__init__.py,sha256=sLq8nrIIf9Wjw5_qQsh6cnHnY7eIqCpzSedKrNmmn7s,145
|
|
128
|
+
plain/server/workers/base.py,sha256=B0aniofq4lT5gYa82W34VTLvEQInOe43QvuG9X14vdE,9602
|
|
129
129
|
plain/server/workers/sync.py,sha256=I28Icl1aKNIOlVaM76UOqQvjtSetkG-tdc5eiAvAmYA,7272
|
|
130
|
+
plain/server/workers/thread.py,sha256=6F_YfhrlPV3hmxwI8_-jgGq4pCmkQBKVO15Wrg-iQ7Y,13504
|
|
130
131
|
plain/server/workers/workertmp.py,sha256=egGReVvldlOBQfQGcpLpjt0zvPwR4C_N-UJKG-U_6w4,1299
|
|
131
132
|
plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
|
|
132
133
|
plain/signals/__init__.py,sha256=eAs0kLqptuP6I31dWXeAqRNji3svplpAV4Ez6ktjwXM,131
|
|
@@ -181,15 +182,15 @@ plain/utils/timezone.py,sha256=M_I5yvs9NsHbtNBPJgHErvWw9vatzx4M96tRQs5gS3g,6823
|
|
|
181
182
|
plain/utils/tree.py,sha256=rj_JpZ2kVD3UExWoKnsRdVCoRjvzkuVOONcHzREjSyw,4766
|
|
182
183
|
plain/views/README.md,sha256=6mcoSQp60n8qgoIMNDQr29WThpi-NCj8EMqxPNwWpiE,7189
|
|
183
184
|
plain/views/__init__.py,sha256=a-N1nkklVohJTtz0yD1MMaS0g66HviEjsKydNVVjvVc,392
|
|
184
|
-
plain/views/base.py,sha256=
|
|
185
|
+
plain/views/base.py,sha256=yWh6S68PsYcH1dvRdibQIanBYkjo2iJ8IAbR2PTWQrk,4419
|
|
185
186
|
plain/views/errors.py,sha256=tHD7MNnZcMyiQ46RMAnX1Ne3Zbbkr1zAiVfJyaaLtSQ,1447
|
|
186
187
|
plain/views/exceptions.py,sha256=-YKH1Jd9Zm_yXiz797PVjJB6VWaPCTXClHIUkG2fq78,198
|
|
187
188
|
plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
|
|
188
189
|
plain/views/objects.py,sha256=5y0PoPPo07dQTTcJ_9kZcx0iI1O7regsooAIK4VqXQ0,5579
|
|
189
190
|
plain/views/redirect.py,sha256=mIpSAFcaEyeLDyv4Fr6g_ektduG4Wfa6s6L-rkdazmM,2154
|
|
190
191
|
plain/views/templates.py,sha256=9LgDMCv4C7JzLiyw8jHF-i4350ukwgixC_9y4faEGu0,1885
|
|
191
|
-
plain-0.
|
|
192
|
-
plain-0.
|
|
193
|
-
plain-0.
|
|
194
|
-
plain-0.
|
|
195
|
-
plain-0.
|
|
192
|
+
plain-0.78.0.dist-info/METADATA,sha256=CKPAeh7DtdabJOMtaIdwFIuLWnAtCovImj6qdX9xl4k,4516
|
|
193
|
+
plain-0.78.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
194
|
+
plain-0.78.0.dist-info/entry_points.txt,sha256=wvMzY-iREvfqRgyLm77htPp4j_8CQslLoZA15_AnNo8,171
|
|
195
|
+
plain-0.78.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
|
196
|
+
plain-0.78.0.dist-info/RECORD,,
|
plain/server/reloader.py
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# This file is part of gunicorn released under the MIT license.
|
|
6
|
-
# See the LICENSE for more information.
|
|
7
|
-
#
|
|
8
|
-
# Vendored and modified for Plain.
|
|
9
|
-
import os
|
|
10
|
-
import os.path
|
|
11
|
-
import re
|
|
12
|
-
import sys
|
|
13
|
-
import threading
|
|
14
|
-
import time
|
|
15
|
-
from collections.abc import Callable, Iterable
|
|
16
|
-
|
|
17
|
-
COMPILED_EXT_RE = re.compile(r"py[co]$")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class Reloader(threading.Thread):
|
|
21
|
-
def __init__(
|
|
22
|
-
self,
|
|
23
|
-
extra_files: Iterable[str] | None = None,
|
|
24
|
-
interval: int = 1,
|
|
25
|
-
callback: Callable[[str], None] | None = None,
|
|
26
|
-
) -> None:
|
|
27
|
-
super().__init__()
|
|
28
|
-
self.daemon = True
|
|
29
|
-
self._extra_files: set[str] = set(extra_files or ())
|
|
30
|
-
self._interval = interval
|
|
31
|
-
self._callback = callback
|
|
32
|
-
|
|
33
|
-
def add_extra_file(self, filename: str) -> None:
|
|
34
|
-
self._extra_files.add(filename)
|
|
35
|
-
|
|
36
|
-
def get_files(self) -> list[str]:
|
|
37
|
-
fnames = [
|
|
38
|
-
COMPILED_EXT_RE.sub("py", module.__file__) # type: ignore[arg-type]
|
|
39
|
-
for module in tuple(sys.modules.values())
|
|
40
|
-
if getattr(module, "__file__", None)
|
|
41
|
-
]
|
|
42
|
-
|
|
43
|
-
fnames.extend(self._extra_files)
|
|
44
|
-
|
|
45
|
-
return fnames
|
|
46
|
-
|
|
47
|
-
def run(self) -> None:
|
|
48
|
-
mtimes: dict[str, float] = {}
|
|
49
|
-
while True:
|
|
50
|
-
for filename in self.get_files():
|
|
51
|
-
try:
|
|
52
|
-
mtime = os.stat(filename).st_mtime
|
|
53
|
-
except OSError:
|
|
54
|
-
continue
|
|
55
|
-
old_time = mtimes.get(filename)
|
|
56
|
-
if old_time is None:
|
|
57
|
-
mtimes[filename] = mtime
|
|
58
|
-
continue
|
|
59
|
-
elif mtime > old_time:
|
|
60
|
-
if self._callback:
|
|
61
|
-
self._callback(filename)
|
|
62
|
-
time.sleep(self._interval)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
has_inotify = False
|
|
66
|
-
if sys.platform.startswith("linux"):
|
|
67
|
-
try:
|
|
68
|
-
import inotify.constants
|
|
69
|
-
from inotify.adapters import Inotify
|
|
70
|
-
|
|
71
|
-
has_inotify = True
|
|
72
|
-
except ImportError:
|
|
73
|
-
pass
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if has_inotify:
|
|
77
|
-
|
|
78
|
-
class InotifyReloader(threading.Thread):
|
|
79
|
-
event_mask = (
|
|
80
|
-
inotify.constants.IN_CREATE
|
|
81
|
-
| inotify.constants.IN_DELETE
|
|
82
|
-
| inotify.constants.IN_DELETE_SELF
|
|
83
|
-
| inotify.constants.IN_MODIFY
|
|
84
|
-
| inotify.constants.IN_MOVE_SELF
|
|
85
|
-
| inotify.constants.IN_MOVED_FROM
|
|
86
|
-
| inotify.constants.IN_MOVED_TO
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
def __init__(
|
|
90
|
-
self,
|
|
91
|
-
extra_files: Iterable[str] | None = None,
|
|
92
|
-
callback: Callable[[str], None] | None = None,
|
|
93
|
-
) -> None:
|
|
94
|
-
super().__init__()
|
|
95
|
-
self.daemon = True
|
|
96
|
-
self._callback = callback
|
|
97
|
-
self._dirs: set[str] = set()
|
|
98
|
-
self._watcher = Inotify()
|
|
99
|
-
|
|
100
|
-
if extra_files:
|
|
101
|
-
for extra_file in extra_files:
|
|
102
|
-
self.add_extra_file(extra_file)
|
|
103
|
-
|
|
104
|
-
def add_extra_file(self, filename: str) -> None:
|
|
105
|
-
dirname = os.path.dirname(filename)
|
|
106
|
-
|
|
107
|
-
if dirname in self._dirs:
|
|
108
|
-
return None
|
|
109
|
-
|
|
110
|
-
self._watcher.add_watch(dirname, mask=self.event_mask)
|
|
111
|
-
self._dirs.add(dirname)
|
|
112
|
-
|
|
113
|
-
def get_dirs(self) -> set[str]:
|
|
114
|
-
fnames = [
|
|
115
|
-
os.path.dirname(
|
|
116
|
-
os.path.abspath(COMPILED_EXT_RE.sub("py", module.__file__)) # type: ignore[arg-type]
|
|
117
|
-
)
|
|
118
|
-
for module in tuple(sys.modules.values())
|
|
119
|
-
if getattr(module, "__file__", None)
|
|
120
|
-
]
|
|
121
|
-
|
|
122
|
-
return set(fnames)
|
|
123
|
-
|
|
124
|
-
def run(self) -> None:
|
|
125
|
-
self._dirs = self.get_dirs()
|
|
126
|
-
|
|
127
|
-
for dirname in self._dirs:
|
|
128
|
-
if os.path.isdir(dirname):
|
|
129
|
-
self._watcher.add_watch(dirname, mask=self.event_mask)
|
|
130
|
-
|
|
131
|
-
for event in self._watcher.event_gen(): # type: ignore[attr-defined]
|
|
132
|
-
if event is None:
|
|
133
|
-
continue
|
|
134
|
-
|
|
135
|
-
filename = event[3] # type: ignore[index]
|
|
136
|
-
|
|
137
|
-
self._callback(filename) # type: ignore[misc]
|
|
138
|
-
|
|
139
|
-
else:
|
|
140
|
-
|
|
141
|
-
class InotifyReloader:
|
|
142
|
-
def __init__(
|
|
143
|
-
self,
|
|
144
|
-
extra_files: Iterable[str] | None = None,
|
|
145
|
-
callback: Callable[[str], None] | None = None,
|
|
146
|
-
) -> None:
|
|
147
|
-
raise ImportError(
|
|
148
|
-
"You must have the inotify module installed to use the inotify reloader"
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
preferred_reloader = InotifyReloader if has_inotify else Reloader
|
|
153
|
-
|
|
154
|
-
reloader_engines = {
|
|
155
|
-
"auto": preferred_reloader,
|
|
156
|
-
"poll": Reloader,
|
|
157
|
-
"inotify": InotifyReloader,
|
|
158
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|