plain 0.75.0__py3-none-any.whl → 0.77.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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
4
+
5
+ ### What's changed
6
+
7
+ - The `plain server --reload` now uses `watchfiles` for improved cross-platform file watching ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
8
+ - Server reloader now watches `.env*` files for changes and triggers automatic reload ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
9
+ - HTML template additions and deletions now trigger automatic server reload when using `--reload` ([f2f31c288b](https://github.com/dropseed/plain/commit/f2f31c288b))
10
+ - Internal server worker type renamed from "gthread" to "thread" for clarity ([6470748e91](https://github.com/dropseed/plain/commit/6470748e91))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - No changes required
15
+
16
+ ## [0.76.0](https://github.com/dropseed/plain/releases/plain@0.76.0) (2025-10-12)
17
+
18
+ ### What's changed
19
+
20
+ - Added new `plain server` command with built-in WSGI server (vendored gunicorn) ([f9dc2867c7](https://github.com/dropseed/plain/commit/f9dc2867c7))
21
+ - The `plain server` command supports `WEB_CONCURRENCY` environment variable for worker processes ([0c3e8c6f32](https://github.com/dropseed/plain/commit/0c3e8c6f32))
22
+ - Simplified server startup logging to use a single consolidated log line ([b1405b71f0](https://github.com/dropseed/plain/commit/b1405b71f0))
23
+ - Removed `gunicorn` as an external dependency - server functionality is now built into plain core ([cb6c2f484d](https://github.com/dropseed/plain/commit/cb6c2f484d))
24
+ - Internal server environment variables renamed from `GUNICORN_*` to `PLAIN_SERVER_*` ([745c073123](https://github.com/dropseed/plain/commit/745c073123))
25
+ - Removed unused server features including hooks, syslog, proxy protocol, user/group dropping, and config file loading ([be0f82d92b](https://github.com/dropseed/plain/commit/be0f82d92b), [10c206875b](https://github.com/dropseed/plain/commit/10c206875b), [ecf327014c](https://github.com/dropseed/plain/commit/ecf327014c), [fb5a10f50b](https://github.com/dropseed/plain/commit/fb5a10f50b))
26
+
27
+ ### Upgrade instructions
28
+
29
+ - Replace any direct usage of `gunicorn` with the new `plain server` command (ex. `gunicorn plain.wsgi:app --workers 4` becomes `plain server --workers 4`)
30
+ - Update any deployment scripts or Procfiles that use `gunicorn` to use `plain server` instead
31
+ - Remove `gunicorn` from your project dependencies if you added it separately (it's now built into plain)
32
+ - For Heroku deployments, the `$PORT` is not automatically detected - update your Procfile to `web: plain server --bind 0.0.0.0:$PORT`
33
+ - If you were using gunicorn configuration files, migrate the settings to `plain server` command-line options (run `plain server --help` to see available options)
34
+
3
35
  ## [0.75.0](https://github.com/dropseed/plain/releases/plain@0.75.0) (2025-10-10)
4
36
 
5
37
  ### What's changed
plain/cli/core.py CHANGED
@@ -19,6 +19,7 @@ from .install import install
19
19
  from .preflight import preflight_cli
20
20
  from .registry import cli_registry
21
21
  from .scaffold import create
22
+ from .server import server
22
23
  from .settings import setting
23
24
  from .shell import run, shell
24
25
  from .upgrade import upgrade
@@ -45,6 +46,7 @@ plain_cli.add_command(shell)
45
46
  plain_cli.add_command(run)
46
47
  plain_cli.add_command(install)
47
48
  plain_cli.add_command(upgrade)
49
+ plain_cli.add_command(server)
48
50
 
49
51
 
50
52
  class CLIRegistryGroup(click.Group):
@@ -68,26 +70,33 @@ class PlainCommandCollection(click.CommandCollection):
68
70
  context_class = PlainContext
69
71
 
70
72
  def __init__(self, *args: Any, **kwargs: Any):
71
- sources = []
73
+ # Start with only built-in commands (no setup needed)
74
+ sources = [plain_cli]
75
+
76
+ super().__init__(*args, **kwargs)
77
+ self.sources = sources
78
+ self._registry_group = None
79
+ self._setup_attempted = False
80
+
81
+ def _ensure_registry_loaded(self) -> None:
82
+ """Lazy load the registry group (requires setup)."""
83
+ if self._registry_group is not None or self._setup_attempted:
84
+ return
85
+
86
+ self._setup_attempted = True
72
87
 
73
88
  try:
74
89
  plain.runtime.setup()
75
-
76
- sources = [
77
- CLIRegistryGroup(),
78
- plain_cli,
79
- ]
90
+ self._registry_group = CLIRegistryGroup()
91
+ # Add registry group to sources
92
+ self.sources.insert(0, self._registry_group)
80
93
  except plain.runtime.AppPathNotFound:
81
- # Allow some commands to work regardless of being in a valid app
94
+ # Allow built-in commands to work regardless of being in a valid app
82
95
  click.secho(
83
96
  "Plain `app` directory not found. Some commands may be missing.",
84
97
  fg="yellow",
85
98
  err=True,
86
99
  )
87
-
88
- sources = [
89
- plain_cli,
90
- ]
91
100
  except ImproperlyConfigured as e:
92
101
  # Show what was configured incorrectly and exit
93
102
  click.secho(
@@ -95,7 +104,6 @@ class PlainCommandCollection(click.CommandCollection):
95
104
  fg="red",
96
105
  err=True,
97
106
  )
98
-
99
107
  exit(1)
100
108
  except Exception as e:
101
109
  # Show the exception and exit
@@ -108,19 +116,29 @@ class PlainCommandCollection(click.CommandCollection):
108
116
  fg="red",
109
117
  err=True,
110
118
  )
111
-
112
119
  exit(1)
113
120
 
114
- super().__init__(*args, **kwargs)
115
-
116
- self.sources = sources
117
-
118
121
  def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
122
+ # Try built-in commands first
119
123
  cmd = super().get_command(ctx, cmd_name)
124
+
125
+ if cmd is None:
126
+ # Command not found in built-ins, try registry (requires setup)
127
+ self._ensure_registry_loaded()
128
+ cmd = super().get_command(ctx, cmd_name)
129
+ elif not getattr(cmd, "without_runtime_setup", False):
130
+ # Command found but needs setup - ensure registry is loaded
131
+ self._ensure_registry_loaded()
132
+
120
133
  if cmd:
121
134
  # Pass the formatting down to subcommands automatically
122
135
  cmd.context_class = self.context_class
123
136
  return cmd
124
137
 
138
+ def list_commands(self, ctx: Context) -> list[str]:
139
+ # For help listing, we need to show registry commands too
140
+ self._ensure_registry_loaded()
141
+ return super().list_commands(ctx)
142
+
125
143
 
126
144
  cli = PlainCommandCollection()
plain/cli/runtime.py ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ CLI runtime utilities.
3
+
4
+ This module provides decorators and utilities for CLI commands.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from typing import TypeVar
9
+
10
+ F = TypeVar("F", bound=Callable)
11
+
12
+
13
+ def without_runtime_setup(f: F) -> F:
14
+ """
15
+ Decorator to mark commands that don't need plain.runtime.setup().
16
+
17
+ Use this for commands that don't access settings or app code,
18
+ particularly for commands that fork (like server) where setup()
19
+ should happen in the worker process, not the parent.
20
+
21
+ Example:
22
+ @without_runtime_setup
23
+ @click.command()
24
+ def server(**options):
25
+ ...
26
+ """
27
+ f.without_runtime_setup = True # type: ignore[attr-defined] # dynamic attribute for decorator
28
+ return f
plain/cli/server.py ADDED
@@ -0,0 +1,135 @@
1
+ import click
2
+
3
+ from plain.cli.runtime import without_runtime_setup
4
+
5
+
6
+ @without_runtime_setup
7
+ @click.command()
8
+ @click.option(
9
+ "--bind",
10
+ "-b",
11
+ multiple=True,
12
+ default=["127.0.0.1:8000"],
13
+ help="Address to bind to (HOST:PORT, can be used multiple times)",
14
+ )
15
+ @click.option(
16
+ "--threads",
17
+ type=int,
18
+ default=1,
19
+ help="Number of threads per worker",
20
+ show_default=True,
21
+ )
22
+ @click.option(
23
+ "--workers",
24
+ "-w",
25
+ type=int,
26
+ default=1,
27
+ envvar="WEB_CONCURRENCY",
28
+ help="Number of worker processes",
29
+ show_default=True,
30
+ )
31
+ @click.option(
32
+ "--timeout",
33
+ "-t",
34
+ type=int,
35
+ default=30,
36
+ help="Worker timeout in seconds",
37
+ show_default=True,
38
+ )
39
+ @click.option(
40
+ "--certfile",
41
+ type=click.Path(exists=True),
42
+ help="SSL certificate file",
43
+ )
44
+ @click.option(
45
+ "--keyfile",
46
+ type=click.Path(exists=True),
47
+ help="SSL key file",
48
+ )
49
+ @click.option(
50
+ "--log-level",
51
+ default="info",
52
+ type=click.Choice(["debug", "info", "warning", "error", "critical"]),
53
+ help="Logging level",
54
+ show_default=True,
55
+ )
56
+ @click.option(
57
+ "--reload",
58
+ is_flag=True,
59
+ help="Restart workers when code changes (dev only)",
60
+ )
61
+ @click.option(
62
+ "--access-log",
63
+ default="-",
64
+ help="Access log file (use '-' for stdout)",
65
+ show_default=True,
66
+ )
67
+ @click.option(
68
+ "--error-log",
69
+ default="-",
70
+ help="Error log file (use '-' for stderr)",
71
+ show_default=True,
72
+ )
73
+ @click.option(
74
+ "--log-format",
75
+ default="%(asctime)s [%(process)d] [%(levelname)s] %(message)s",
76
+ help="Log format string (applies to both error and access logs)",
77
+ show_default=True,
78
+ )
79
+ @click.option(
80
+ "--access-log-format",
81
+ help="Access log format string (HTTP request details)",
82
+ default='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"',
83
+ show_default=True,
84
+ )
85
+ @click.option(
86
+ "--max-requests",
87
+ type=int,
88
+ default=0,
89
+ help="Max requests before worker restart (0=disabled)",
90
+ show_default=True,
91
+ )
92
+ @click.option(
93
+ "--pidfile",
94
+ type=click.Path(),
95
+ help="PID file path",
96
+ )
97
+ def server(
98
+ bind: tuple[str, ...],
99
+ threads: int,
100
+ workers: int,
101
+ timeout: int,
102
+ certfile: str | None,
103
+ keyfile: str | None,
104
+ log_level: str,
105
+ reload: bool,
106
+ access_log: str,
107
+ error_log: str,
108
+ log_format: str,
109
+ access_log_format: str,
110
+ max_requests: int,
111
+ pidfile: str | None,
112
+ ) -> None:
113
+ """
114
+ Run a production-ready WSGI server.
115
+ """
116
+ from plain.server import ServerApplication
117
+ from plain.server.config import Config
118
+
119
+ cfg = Config(
120
+ bind=list(bind),
121
+ threads=threads,
122
+ workers=workers,
123
+ timeout=timeout,
124
+ max_requests=max_requests,
125
+ reload=reload,
126
+ pidfile=pidfile,
127
+ certfile=certfile,
128
+ keyfile=keyfile,
129
+ loglevel=log_level,
130
+ accesslog=access_log,
131
+ errorlog=error_log,
132
+ log_format=log_format,
133
+ access_log_format=access_log_format,
134
+ )
135
+ ServerApplication(cfg=cfg).run()
@@ -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/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ Plain HTTP Server - License and Attribution
2
+ ============================================
3
+
4
+ This module is based on gunicorn (https://gunicorn.org), integrated from
5
+ commit 1dc4ce9d59c3458305d701c4c6d63aa6b1d1b309 (gunicorn 23.0.0, October 2024).
6
+
7
+ The gunicorn code has been integrated into Plain and modified for Plain's
8
+ specific use case. All files should be considered modified from the original.
9
+
10
+ Original repository: https://github.com/benoitc/gunicorn
11
+
12
+ --------------------------------------------------------------------------------
13
+
14
+ MIT License
15
+
16
+ Copyright (c) 2009-2024 Benoît Chesneau <benoitc@gunicorn.org>
17
+ Copyright (c) 2009-2015 Paul J. Davis <paul.joseph.davis@gmail.com>
18
+
19
+ Permission is hereby granted, free of charge, to any person obtaining a copy
20
+ of this software and associated documentation files (the "Software"), to deal
21
+ in the Software without restriction, including without limitation the rights
22
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23
+ copies of the Software, and to permit persons to whom the Software is
24
+ furnished to do so, subject to the following conditions:
25
+
26
+ The above copyright notice and this permission notice shall be included in all
27
+ copies or substantial portions of the Software.
28
+
29
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
34
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35
+ SOFTWARE.
plain/server/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # plain.server
2
+
3
+ **Plain's internal HTTP server based on vendored gunicorn.**
4
+
5
+ ## Overview
6
+
7
+ This module provides a WSGI HTTP server for Plain applications. It is based on [gunicorn](https://gunicorn.org), which has been vendored into Plain's core to provide better integration and control over the HTTP server layer.
8
+
9
+ The server is designed to work seamlessly with Plain's development workflow while still maintaining WSGI compatibility, allowing you to eject to any alternative WSGI server if needed.
10
+
11
+ ## Usage
12
+
13
+ ### Command Line
14
+
15
+ The simplest way to run the server is using the `plain server` command:
16
+
17
+ ```bash
18
+ # Run with defaults (127.0.0.1:8000)
19
+ plain server
20
+
21
+ # Specify host and port
22
+ plain server --bind 0.0.0.0:8080
23
+
24
+ # Run with SSL
25
+ plain server --certfile cert.pem --keyfile key.pem
26
+
27
+ # Enable auto-reload for development
28
+ plain server --reload
29
+
30
+ # Use multiple threads
31
+ plain server --threads 8
32
+ ```
33
+
34
+ ## Configuration Options
35
+
36
+ Common options:
37
+
38
+ - `--bind` / `-b` - Address to bind to (default: `127.0.0.1:8000`)
39
+ - `--workers` / `-w` - Number of worker processes (default: 1, or `$WEB_CONCURRENCY` env var)
40
+ - `--threads` - Number of threads per worker (default: 1)
41
+ - `--timeout` / `-t` - Worker timeout in seconds (default: 30)
42
+ - `--reload` - Enable auto-reload on code changes, including `.env*` files (default: False)
43
+ - `--certfile` - Path to SSL certificate file
44
+ - `--keyfile` - Path to SSL key file
45
+ - `--log-level` - Logging level: debug, info, warning, error, critical (default: info)
46
+ - `--access-log` - Access log file path (default: `-` for stdout)
47
+ - `--error-log` - Error log file path (default: `-` for stderr)
48
+ - `--log-format` - Log format string for error logs
49
+ - `--access-log-format` - Access log format string for HTTP request details
50
+ - `--max-requests` - Max requests before worker restart (default: 0, disabled)
51
+ - `--pidfile` - PID file path
52
+
53
+ ### Environment Variables
54
+
55
+ - `WEB_CONCURRENCY` - Sets the number of worker processes (commonly used by Heroku and other PaaS providers)
56
+ - `SENDFILE` - Enable/disable use of sendfile() syscall (set to `1`, `yes`, `true`, or `y` to enable)
57
+ - `FORWARDED_ALLOW_IPS` - Comma-separated list of trusted proxy IPs for secure headers (default: `127.0.0.1,::1`)
58
+
59
+ For a complete list of options, run `plain server --help`.
60
+
61
+ ## WSGI Ejection Point
62
+
63
+ While Plain includes this built-in server, you can still use any WSGI-compatible server you prefer. Plain's `wsgi.py` module provides a standard WSGI application interface:
64
+
65
+ ```bash
66
+ # Using uvicorn
67
+ uvicorn plain.wsgi:app --port 8000
68
+
69
+ # Using waitress
70
+ waitress-serve --port=8000 plain.wsgi:app
71
+
72
+ # Using gunicorn as an alternative
73
+ gunicorn plain.wsgi:app --workers 4
74
+ ```
@@ -0,0 +1,9 @@
1
+ #
2
+ # This file is part of gunicorn released under the MIT license.
3
+ # See the LICENSE for more information.
4
+ #
5
+ # Vendored and modified for Plain.
6
+
7
+ from .app import ServerApplication
8
+
9
+ __all__ = ["ServerApplication"]
plain/server/app.py ADDED
@@ -0,0 +1,52 @@
1
+ #
2
+ #
3
+ # This file is part of gunicorn released under the MIT license.
4
+ # See the LICENSE for more information.
5
+ #
6
+ # Vendored and modified for Plain.
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from .arbiter import Arbiter
14
+
15
+ if TYPE_CHECKING:
16
+ from .config import Config
17
+
18
+
19
+ class ServerApplication:
20
+ """
21
+ Plain's server application.
22
+
23
+ This class provides the interface for running the WSGI server.
24
+ """
25
+
26
+ def __init__(self, cfg: Config) -> None:
27
+ self.cfg: Config = cfg
28
+ self.callable: Any = None
29
+
30
+ def load(self) -> Any:
31
+ """Load the WSGI application."""
32
+ # Import locally to avoid circular dependencies and allow
33
+ # the WSGI module to handle Plain runtime setup
34
+ from plain.wsgi import app
35
+
36
+ return app
37
+
38
+ def wsgi(self) -> Any:
39
+ """Get the WSGI application."""
40
+ if self.callable is None:
41
+ self.callable = self.load()
42
+ return self.callable
43
+
44
+ def run(self) -> None:
45
+ """Run the server."""
46
+
47
+ try:
48
+ Arbiter(self).run()
49
+ except RuntimeError as e:
50
+ print(f"\nError: {e}\n", file=sys.stderr)
51
+ sys.stderr.flush()
52
+ sys.exit(1)