plain.dev 0.5.0__tar.gz → 0.7.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.
Files changed (33) hide show
  1. {plain_dev-0.5.0 → plain_dev-0.7.0}/LICENSE +25 -0
  2. {plain_dev-0.5.0 → plain_dev-0.7.0}/PKG-INFO +1 -3
  3. plain_dev-0.7.0/plain/dev/cli.py +288 -0
  4. plain_dev-0.7.0/plain/dev/mkcert.py +96 -0
  5. plain_dev-0.7.0/plain/dev/poncho/__init__.py +4 -0
  6. plain_dev-0.7.0/plain/dev/poncho/color.py +28 -0
  7. plain_dev-0.7.0/plain/dev/poncho/compat.py +47 -0
  8. plain_dev-0.7.0/plain/dev/poncho/manager.py +201 -0
  9. plain_dev-0.7.0/plain/dev/poncho/printer.py +85 -0
  10. plain_dev-0.7.0/plain/dev/poncho/process.py +81 -0
  11. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/precommit/cli.py +3 -7
  12. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/services.py +1 -5
  13. plain_dev-0.7.0/plain/dev/utils.py +5 -0
  14. {plain_dev-0.5.0 → plain_dev-0.7.0}/pyproject.toml +1 -4
  15. plain_dev-0.5.0/plain/dev/cli.py +0 -164
  16. plain_dev-0.5.0/plain/dev/utils.py +0 -14
  17. {plain_dev-0.5.0 → plain_dev-0.7.0}/README.md +0 -0
  18. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/README.md +0 -0
  19. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/__init__.py +0 -0
  20. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/config.py +0 -0
  21. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/contribute/__init__.py +0 -0
  22. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/contribute/cli.py +0 -0
  23. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/db/__init__.py +0 -0
  24. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/db/cli.py +0 -0
  25. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/db/container.py +0 -0
  26. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/debug.py +0 -0
  27. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/default_settings.py +0 -0
  28. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/pid.py +0 -0
  29. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/precommit/__init__.py +0 -0
  30. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/requests.py +0 -0
  31. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/templates/dev/requests.html +0 -0
  32. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/urls.py +0 -0
  33. {plain_dev-0.5.0 → plain_dev-0.7.0}/plain/dev/views.py +0 -0
@@ -1,3 +1,5 @@
1
+ ## Plain is released under the BSD 3-Clause License
2
+
1
3
  BSD 3-Clause License
2
4
 
3
5
  Copyright (c) 2023, Dropseed, LLC
@@ -26,3 +28,26 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
28
  CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
29
  OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
30
  OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
+
32
+ ## This package contains code forked from github.com/nickstenning/honcho
33
+
34
+ Copyright (c) 2012-2024 Nick Stenning, https://whiteink.com/
35
+
36
+ Permission is hereby granted, free of charge, to any person obtaining
37
+ a copy of this software and associated documentation files (the
38
+ "Software"), to deal in the Software without restriction, including
39
+ without limitation the rights to use, copy, modify, merge, publish,
40
+ distribute, sublicense, and/or sell copies of the Software, and to
41
+ permit persons to whom the Software is furnished to do so, subject to
42
+ the following conditions:
43
+
44
+ The above copyright notice and this permission notice shall be
45
+ included in all copies or substantial portions of the Software.
46
+
47
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
48
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
49
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
50
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
51
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
52
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
53
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: plain.dev
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Local development tools for Plain.
5
5
  Home-page: https://plainframework.com
6
6
  License: BSD-3-Clause
@@ -14,11 +14,9 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Requires-Dist: click (>=8.0.0)
15
15
  Requires-Dist: debugpy (>=1.6.3,<2.0.0)
16
16
  Requires-Dist: gunicorn (>20)
17
- Requires-Dist: honcho (>=1.1.0,<2.0.0)
18
17
  Requires-Dist: plain (<1.0.0)
19
18
  Requires-Dist: psycopg[binary] (>=3.2.2,<4.0.0)
20
19
  Requires-Dist: requests (>=2.0.0)
21
- Requires-Dist: tomli (>=2.0.1,<3.0.0) ; python_version < "3.11"
22
20
  Project-URL: Documentation, https://plainframework.com/docs/
23
21
  Project-URL: Repository, https://github.com/dropseed/plain
24
22
  Description-Content-Type: text/markdown
@@ -0,0 +1,288 @@
1
+ import json
2
+ import os
3
+ import platform
4
+ import subprocess
5
+ import sys
6
+ from importlib.metadata import entry_points
7
+ from importlib.util import find_spec
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import tomllib
12
+
13
+ from plain.runtime import APP_PATH, settings
14
+
15
+ from .db import cli as db_cli
16
+ from .mkcert import MkcertManager
17
+ from .pid import Pid
18
+ from .poncho.manager import Manager as PonchoManager
19
+ from .services import Services
20
+ from .utils import has_pyproject_toml
21
+
22
+ ENTRYPOINT_GROUP = "plain.dev"
23
+
24
+
25
+ @click.group(invoke_without_command=True)
26
+ @click.pass_context
27
+ @click.option(
28
+ "--port",
29
+ "-p",
30
+ default=8443,
31
+ type=int,
32
+ help="Port to run the web server on",
33
+ envvar="PORT",
34
+ )
35
+ def cli(ctx, port):
36
+ """Start local development"""
37
+
38
+ if ctx.invoked_subcommand:
39
+ return
40
+
41
+ returncode = Dev(port=port).run()
42
+ if returncode:
43
+ sys.exit(returncode)
44
+
45
+
46
+ @cli.command()
47
+ def services():
48
+ """Start additional services defined in pyproject.toml"""
49
+ Services().run()
50
+
51
+
52
+ @cli.command()
53
+ @click.option(
54
+ "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
55
+ )
56
+ @click.argument("entrypoint", required=False)
57
+ def entrypoint(show_list, entrypoint):
58
+ f"""Entrypoints registered under {ENTRYPOINT_GROUP}"""
59
+ if not show_list and not entrypoint:
60
+ click.secho("Please provide an entrypoint name or use --list", fg="red")
61
+ sys.exit(1)
62
+
63
+ for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
64
+ if show_list:
65
+ click.echo(entry_point.name)
66
+ elif entrypoint == entry_point.name:
67
+ entry_point.load()()
68
+
69
+
70
+ class Dev:
71
+ def __init__(self, *, port):
72
+ self.poncho = PonchoManager()
73
+ self.port = port
74
+ self.plain_env = {
75
+ **os.environ,
76
+ "PYTHONUNBUFFERED": "true",
77
+ }
78
+ self.custom_process_env = {
79
+ **self.plain_env,
80
+ "PORT": str(self.port),
81
+ "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
82
+ }
83
+ self.project_name = os.path.basename(os.getcwd())
84
+ self.domain = f"{self.project_name}.localhost"
85
+ self.ssl_cert_path = None
86
+ self.ssl_key_path = None
87
+
88
+ def run(self):
89
+ pid = Pid()
90
+ pid.write()
91
+
92
+ try:
93
+ mkcert_manager = MkcertManager()
94
+ mkcert_manager.setup_mkcert(install_path=Path.home() / ".plain" / "dev")
95
+ self.ssl_cert_path, self.ssl_key_path = mkcert_manager.generate_certs(
96
+ domain=self.domain,
97
+ storage_path=Path(settings.PLAIN_TEMP_PATH) / "dev" / "certs",
98
+ )
99
+ self.modify_hosts_file()
100
+ self.set_csrf_trusted_origins()
101
+ self.set_allowed_hosts()
102
+ self.run_preflight()
103
+
104
+ # Processes for poncho to run simultaneously
105
+ self.add_gunicorn()
106
+ self.add_entrypoints()
107
+ self.add_pyproject_run()
108
+ self.add_services()
109
+
110
+ # Output the clickable link before starting the manager loop
111
+ url = f"https://{self.domain}:{self.port}/"
112
+ click.secho(
113
+ f"\nYour application is running at: {click.style(url, fg='green', underline=True)}\n",
114
+ bold=True,
115
+ )
116
+
117
+ self.poncho.loop()
118
+
119
+ return self.poncho.returncode
120
+ finally:
121
+ pid.rm()
122
+
123
+ def modify_hosts_file(self):
124
+ """Modify the hosts file to map the custom domain to 127.0.0.1."""
125
+ entry_identifier = "# Added by plain"
126
+ hosts_entry = f"127.0.0.1 {self.domain} {entry_identifier}"
127
+
128
+ if platform.system() == "Windows":
129
+ hosts_path = Path(r"C:\Windows\System32\drivers\etc\hosts")
130
+ try:
131
+ with hosts_path.open("r") as f:
132
+ content = f.read()
133
+
134
+ if hosts_entry in content:
135
+ return # Entry already exists; no action needed
136
+
137
+ # Entry does not exist; add it
138
+ with hosts_path.open("a") as f:
139
+ f.write(f"{hosts_entry}\n")
140
+ click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
141
+ except PermissionError:
142
+ click.secho(
143
+ "Permission denied while modifying hosts file. Please run the script as an administrator.",
144
+ fg="red",
145
+ )
146
+ sys.exit(1)
147
+ else:
148
+ # For macOS and Linux
149
+ hosts_path = Path("/etc/hosts")
150
+ try:
151
+ with hosts_path.open("r") as f:
152
+ content = f.read()
153
+
154
+ if hosts_entry in content:
155
+ return # Entry already exists; no action needed
156
+
157
+ # Entry does not exist; append it using sudo
158
+ click.secho(
159
+ "Modifying /etc/hosts file. You may be prompted for your password.",
160
+ bold=True,
161
+ )
162
+ cmd = f"echo '{hosts_entry}' | sudo tee -a {hosts_path} >/dev/null"
163
+ subprocess.run(cmd, shell=True, check=True)
164
+ click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
165
+ except PermissionError:
166
+ click.secho(
167
+ "Permission denied while accessing hosts file.",
168
+ fg="red",
169
+ )
170
+ sys.exit(1)
171
+ except subprocess.CalledProcessError:
172
+ click.secho(
173
+ "Failed to modify hosts file. Please ensure you have sudo privileges.",
174
+ fg="red",
175
+ )
176
+ sys.exit(1)
177
+
178
+ def set_csrf_trusted_origins(self):
179
+ csrf_trusted_origins = json.dumps(
180
+ [
181
+ f"https://{self.domain}:{self.port}",
182
+ ]
183
+ )
184
+
185
+ click.secho(
186
+ f"Automatically set PLAIN_CSRF_TRUSTED_ORIGINS={click.style(csrf_trusted_origins, underline=True)}",
187
+ bold=True,
188
+ )
189
+
190
+ # Set environment variables
191
+ self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
192
+ self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
193
+
194
+ def set_allowed_hosts(self):
195
+ allowed_hosts = json.dumps([self.domain])
196
+
197
+ click.secho(
198
+ f"Automatically set PLAIN_ALLOWED_HOSTS={click.style(allowed_hosts, underline=True)}",
199
+ bold=True,
200
+ )
201
+
202
+ # Set environment variables
203
+ self.plain_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
204
+ self.custom_process_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
205
+
206
+ def run_preflight(self):
207
+ if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
208
+ click.secho("Preflight check failed!", fg="red")
209
+ sys.exit(1)
210
+
211
+ def add_gunicorn(self):
212
+ plain_db_installed = find_spec("plain.models") is not None
213
+
214
+ # Watch .env files for reload
215
+ extra_watch_files = []
216
+ for f in os.listdir(APP_PATH.parent):
217
+ if f.startswith(".env"):
218
+ extra_watch_files.append(f)
219
+
220
+ reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
221
+ gunicorn_cmd = [
222
+ "gunicorn",
223
+ "--bind",
224
+ f"{self.domain}:{self.port}",
225
+ "--certfile",
226
+ str(self.ssl_cert_path),
227
+ "--keyfile",
228
+ str(self.ssl_key_path),
229
+ "--reload",
230
+ "plain.wsgi:app",
231
+ "--timeout",
232
+ "60",
233
+ "--access-logfile",
234
+ "-",
235
+ "--error-logfile",
236
+ "-",
237
+ *reload_extra.split(),
238
+ "--access-logformat",
239
+ "'\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'",
240
+ ]
241
+ gunicorn = " ".join(gunicorn_cmd)
242
+
243
+ if plain_db_installed:
244
+ runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
245
+ else:
246
+ runserver_cmd = gunicorn
247
+
248
+ if "WEB_CONCURRENCY" not in self.plain_env:
249
+ # Default to two workers to prevent lockups
250
+ self.plain_env["WEB_CONCURRENCY"] = "2"
251
+
252
+ self.poncho.add_process("plain", runserver_cmd, env=self.plain_env)
253
+
254
+ def add_entrypoints(self):
255
+ for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
256
+ self.poncho.add_process(
257
+ f"plain dev entrypoint {entry_point.name}", env=self.plain_env
258
+ )
259
+
260
+ def add_pyproject_run(self):
261
+ if not has_pyproject_toml(APP_PATH.parent):
262
+ return
263
+
264
+ with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
265
+ pyproject = tomllib.load(f)
266
+
267
+ run_commands = (
268
+ pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
269
+ )
270
+ for name, data in run_commands.items():
271
+ env = {
272
+ **self.custom_process_env,
273
+ **data.get("env", {}),
274
+ }
275
+ self.poncho.add_process(name, data["cmd"], env=env)
276
+
277
+ def add_services(self):
278
+ services = Services.get_services(APP_PATH.parent)
279
+ for name, data in services.items():
280
+ env = {
281
+ **os.environ,
282
+ "PYTHONUNBUFFERED": "true",
283
+ **data.get("env", {}),
284
+ }
285
+ self.poncho.add_process(name, data["cmd"], env=env)
286
+
287
+
288
+ cli.add_command(db_cli)
@@ -0,0 +1,96 @@
1
+ import platform
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ import urllib.request
6
+
7
+ import click
8
+
9
+
10
+ class MkcertManager:
11
+ def __init__(self):
12
+ self.mkcert_bin = None
13
+
14
+ def setup_mkcert(self, install_path):
15
+ """Set up mkcert by checking if it's installed or downloading the binary and installing the local CA."""
16
+ if mkcert_path := shutil.which("mkcert"):
17
+ # mkcert is already installed somewhere
18
+ self.mkcert_bin = mkcert_path
19
+ else:
20
+ self.mkcert_bin = install_path / "mkcert"
21
+ install_path.mkdir(parents=True, exist_ok=True)
22
+ if not self.mkcert_bin.exists():
23
+ system = platform.system()
24
+ arch = platform.machine()
25
+
26
+ # Map platform.machine() to mkcert's expected architecture strings
27
+ arch_map = {
28
+ "x86_64": "amd64",
29
+ "amd64": "amd64",
30
+ "AMD64": "amd64",
31
+ "arm64": "arm64",
32
+ "aarch64": "arm64",
33
+ }
34
+ arch = arch_map.get(
35
+ arch.lower(), "amd64"
36
+ ) # Default to amd64 if unknown
37
+
38
+ if system == "Darwin":
39
+ os_name = "darwin"
40
+ elif system == "Linux":
41
+ os_name = "linux"
42
+ elif system == "Windows":
43
+ os_name = "windows"
44
+ else:
45
+ click.secho("Unsupported OS", fg="red")
46
+ sys.exit(1)
47
+
48
+ mkcert_url = f"https://dl.filippo.io/mkcert/latest?for={os_name}/{arch}"
49
+ click.secho(f"Downloading mkcert from {mkcert_url}...", bold=True)
50
+ urllib.request.urlretrieve(mkcert_url, self.mkcert_bin)
51
+ self.mkcert_bin.chmod(0o755)
52
+ self.mkcert_bin = str(self.mkcert_bin) # Convert Path object to string
53
+
54
+ if not self.is_mkcert_ca_installed():
55
+ click.secho(
56
+ "Installing mkcert local CA. You may be prompted for your password.",
57
+ bold=True,
58
+ )
59
+ subprocess.run([self.mkcert_bin, "-install"], check=True)
60
+
61
+ def is_mkcert_ca_installed(self):
62
+ """Check if mkcert local CA is already installed using mkcert -check."""
63
+ try:
64
+ result = subprocess.run([self.mkcert_bin, "-check"], capture_output=True)
65
+ output = result.stdout.decode() + result.stderr.decode()
66
+ if "The local CA is not installed" in output:
67
+ return False
68
+ return True
69
+ except Exception as e:
70
+ click.secho(f"Error checking mkcert CA installation: {e}", fg="red")
71
+ return False
72
+
73
+ def generate_certs(self, domain, storage_path):
74
+ cert_path = storage_path / f"{domain}-cert.pem"
75
+ key_path = storage_path / f"{domain}-key.pem"
76
+
77
+ if cert_path.exists() and key_path.exists():
78
+ return cert_path, key_path
79
+
80
+ storage_path.mkdir(parents=True, exist_ok=True)
81
+
82
+ # Generate SSL certificates using mkcert
83
+ click.secho(f"Generating SSL certificates for {domain}...", bold=True)
84
+ subprocess.run(
85
+ [
86
+ self.mkcert_bin,
87
+ "-cert-file",
88
+ str(cert_path),
89
+ "-key-file",
90
+ str(key_path),
91
+ domain,
92
+ ],
93
+ check=True,
94
+ )
95
+
96
+ return cert_path, key_path
@@ -0,0 +1,4 @@
1
+ try:
2
+ from ._version import __version__
3
+ except ImportError:
4
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,28 @@
1
+ ANSI_COLOURS = ["grey", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
2
+
3
+ for i, name in enumerate(ANSI_COLOURS):
4
+ globals()[name] = str(30 + i)
5
+ globals()["intense_" + name] = str(30 + i) + ";1"
6
+
7
+
8
+ def get_colors():
9
+ cs = [
10
+ "cyan",
11
+ "yellow",
12
+ "green",
13
+ "magenta",
14
+ "red",
15
+ "blue",
16
+ "intense_cyan",
17
+ "intense_yellow",
18
+ "intense_green",
19
+ "intense_magenta",
20
+ "intense_red",
21
+ "intense_blue",
22
+ ]
23
+ cs = [globals()[c] for c in cs]
24
+
25
+ i = 0
26
+ while True:
27
+ yield cs[i % len(cs)]
28
+ i += 1
@@ -0,0 +1,47 @@
1
+ """
2
+ Compatibility layer and utilities, mostly for proper Windows and Python 3
3
+ support.
4
+ """
5
+ import errno
6
+ import os
7
+ import signal
8
+ import sys
9
+
10
+ # This works for both 32 and 64 bit Windows
11
+ ON_WINDOWS = "win32" in str(sys.platform).lower()
12
+
13
+ if ON_WINDOWS:
14
+ import ctypes
15
+
16
+
17
+ class ProcessManager:
18
+ if ON_WINDOWS:
19
+
20
+ def terminate(self, pid):
21
+ # The first argument to OpenProcess represents the desired access
22
+ # to the process. 1 represents the PROCESS_TERMINATE access right.
23
+ handle = ctypes.windll.kernel32.OpenProcess(1, False, pid)
24
+ ctypes.windll.kernel32.TerminateProcess(handle, -1)
25
+ ctypes.windll.kernel32.CloseHandle(handle)
26
+ else:
27
+
28
+ def terminate(self, pid):
29
+ try:
30
+ os.killpg(pid, signal.SIGTERM)
31
+ except OSError as e:
32
+ if e.errno not in [errno.EPERM, errno.ESRCH]:
33
+ raise
34
+
35
+ if ON_WINDOWS:
36
+
37
+ def kill(self, pid):
38
+ # There's no SIGKILL on Win32...
39
+ self.terminate(pid)
40
+ else:
41
+
42
+ def kill(self, pid):
43
+ try:
44
+ os.killpg(pid, signal.SIGKILL)
45
+ except OSError as e:
46
+ if e.errno not in [errno.EPERM, errno.ESRCH]:
47
+ raise
@@ -0,0 +1,201 @@
1
+ import datetime
2
+ import multiprocessing
3
+ import queue
4
+ import signal
5
+ import sys
6
+
7
+ from .color import get_colors
8
+ from .compat import ProcessManager
9
+ from .printer import Message, Printer
10
+ from .process import Process
11
+
12
+ KILL_WAIT = 5
13
+ SIGNALS = {
14
+ signal.SIGINT: {
15
+ "name": "SIGINT",
16
+ "rc": 130,
17
+ },
18
+ signal.SIGTERM: {
19
+ "name": "SIGTERM",
20
+ "rc": 143,
21
+ },
22
+ }
23
+ SYSTEM_PRINTER_NAME = "system"
24
+
25
+
26
+ class Manager:
27
+ """
28
+ Manager is responsible for running multiple external processes in parallel
29
+ managing the events that result (starting, stopping, printing). By default
30
+ it relays printed lines to a printer that prints to STDOUT.
31
+
32
+ Example::
33
+
34
+ import sys
35
+ from poncho.manager import Manager
36
+
37
+ m = Manager()
38
+ m.add_process('server', 'ruby server.rb')
39
+ m.add_process('worker', 'python worker.py')
40
+ m.loop()
41
+
42
+ sys.exit(m.returncode)
43
+ """
44
+
45
+ #: After :func:`~poncho.manager.Manager.loop` finishes,
46
+ #: this will contain a return code that can be used with `sys.exit`.
47
+ returncode = None
48
+
49
+ def __init__(self, printer=None):
50
+ self.events = multiprocessing.Queue()
51
+ self.returncode = None
52
+
53
+ self._colors = get_colors()
54
+ self._clock = datetime.datetime
55
+ self._procmgr = ProcessManager()
56
+
57
+ self._printer = printer if printer is not None else Printer(sys.stdout)
58
+ self._printer.width = len(SYSTEM_PRINTER_NAME)
59
+
60
+ self._process_ctor = Process
61
+ self._processes = {}
62
+
63
+ self._terminating = False
64
+
65
+ def add_process(self, name, cmd, quiet=False, env=None, cwd=None):
66
+ """
67
+ Add a process to this manager instance. The process will not be started
68
+ until :func:`~poncho.manager.Manager.loop` is called.
69
+ """
70
+ assert name not in self._processes, "process names must be unique"
71
+ proc = self._process_ctor(
72
+ cmd, name=name, quiet=quiet, color=next(self._colors), env=env, cwd=cwd
73
+ )
74
+ self._processes[name] = {}
75
+ self._processes[name]["obj"] = proc
76
+
77
+ # Update printer width to accommodate this process name
78
+ self._printer.width = max(self._printer.width, len(name))
79
+
80
+ return proc
81
+
82
+ def loop(self):
83
+ """
84
+ Start all the added processes and multiplex their output onto the bound
85
+ printer (which by default will print to STDOUT).
86
+
87
+ If one process terminates, all the others will be terminated by
88
+ Poncho, and :func:`~poncho.manager.Manager.loop` will return.
89
+
90
+ This method will block until all the processes have terminated.
91
+ """
92
+
93
+ def _terminate(signum, frame):
94
+ self._system_print("%s received\n" % SIGNALS[signum]["name"])
95
+ self.returncode = SIGNALS[signum]["rc"]
96
+ self.terminate()
97
+
98
+ signal.signal(signal.SIGTERM, _terminate)
99
+ signal.signal(signal.SIGINT, _terminate)
100
+
101
+ self._start()
102
+
103
+ exit = False
104
+ exit_start = None
105
+
106
+ while 1:
107
+ try:
108
+ msg = self.events.get(timeout=0.1)
109
+ except queue.Empty:
110
+ if exit:
111
+ break
112
+ else:
113
+ if msg.type == "line":
114
+ self._printer.write(msg)
115
+ elif msg.type == "start":
116
+ self._processes[msg.name]["pid"] = msg.data["pid"]
117
+ self._system_print(
118
+ "{} started (pid={})\n".format(msg.name, msg.data["pid"])
119
+ )
120
+ elif msg.type == "stop":
121
+ self._processes[msg.name]["returncode"] = msg.data["returncode"]
122
+ self._system_print(
123
+ "{} stopped (rc={})\n".format(msg.name, msg.data["returncode"])
124
+ )
125
+ if self.returncode is None:
126
+ self.returncode = msg.data["returncode"]
127
+
128
+ if self._all_started() and self._all_stopped():
129
+ exit = True
130
+
131
+ if exit_start is None and self._all_started() and self._any_stopped():
132
+ exit_start = self._clock.now()
133
+ self.terminate()
134
+
135
+ if exit_start is not None:
136
+ # If we've been in this loop for more than KILL_WAIT seconds,
137
+ # it's time to kill all remaining children.
138
+ waiting = self._clock.now() - exit_start
139
+ if waiting > datetime.timedelta(seconds=KILL_WAIT):
140
+ self.kill()
141
+
142
+ def terminate(self):
143
+ """
144
+ Terminate all processes managed by this ProcessManager.
145
+ """
146
+ if self._terminating:
147
+ return
148
+ self._terminating = True
149
+ self._killall()
150
+
151
+ def kill(self):
152
+ """
153
+ Kill all processes managed by this ProcessManager.
154
+ """
155
+ self._killall(force=True)
156
+
157
+ def _killall(self, force=False):
158
+ """Kill all remaining processes, forcefully if requested."""
159
+ for_termination = []
160
+
161
+ for n, p in self._processes.items():
162
+ if "returncode" not in p:
163
+ for_termination.append(n)
164
+
165
+ for n in for_termination:
166
+ p = self._processes[n]
167
+ signame = "SIGKILL" if force else "SIGTERM"
168
+ self._system_print(
169
+ "sending {} to {} (pid {})\n".format(signame, n, p["pid"])
170
+ )
171
+ if force:
172
+ self._procmgr.kill(p["pid"])
173
+ else:
174
+ self._procmgr.terminate(p["pid"])
175
+
176
+ def _start(self):
177
+ for name, p in self._processes.items():
178
+ p["process"] = multiprocessing.Process(
179
+ name=name, target=p["obj"].run, args=(self.events, True)
180
+ )
181
+ p["process"].start()
182
+
183
+ def _all_started(self):
184
+ return all(p.get("pid") is not None for _, p in self._processes.items())
185
+
186
+ def _all_stopped(self):
187
+ return all(p.get("returncode") is not None for _, p in self._processes.items())
188
+
189
+ def _any_stopped(self):
190
+ return any(p.get("returncode") is not None for _, p in self._processes.items())
191
+
192
+ def _system_print(self, data):
193
+ self._printer.write(
194
+ Message(
195
+ type="line",
196
+ data=data,
197
+ time=self._clock.now(),
198
+ name=SYSTEM_PRINTER_NAME,
199
+ color=None,
200
+ )
201
+ )
@@ -0,0 +1,85 @@
1
+ import sys
2
+ from collections import namedtuple
3
+
4
+ from .compat import ON_WINDOWS
5
+
6
+ Message = namedtuple("Message", "type data time name color")
7
+
8
+
9
+ class Printer:
10
+ """
11
+ Printer is where Poncho's user-visible output is defined. A Printer
12
+ instance receives typed messages and prints them to its output (usually
13
+ STDOUT) in the Poncho format.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ output=sys.stdout,
19
+ time_format="%H:%M:%S",
20
+ width=0,
21
+ color=True,
22
+ prefix=True,
23
+ ):
24
+ self.output = output
25
+ self.time_format = time_format
26
+ self.width = width
27
+ self.color = color
28
+ self.prefix = prefix
29
+
30
+ try:
31
+ # We only want to print colored messages if the given output supports
32
+ # ANSI escape sequences. Usually, testing if it is a TTY is safe enough.
33
+ self._colors_supported = self.output.isatty()
34
+ except AttributeError:
35
+ # If the given output does not implement isatty(), we assume that it
36
+ # is not able to handle ANSI escape sequences.
37
+ self._colors_supported = False
38
+
39
+ def write(self, message):
40
+ if message.type != "line":
41
+ raise RuntimeError('Printer can only process messages of type "line"')
42
+
43
+ name = message.name if message.name is not None else ""
44
+ name = name.ljust(self.width)
45
+ if name:
46
+ name += " "
47
+
48
+ # When encountering data that cannot be interpreted as UTF-8 encoded
49
+ # Unicode, Printer will replace the unrecognisable bytes with the
50
+ # Unicode replacement character (U+FFFD).
51
+ if isinstance(message.data, bytes):
52
+ string = message.data.decode("utf-8", "replace")
53
+ else:
54
+ string = message.data
55
+
56
+ for line in string.splitlines():
57
+ prefix = ""
58
+ if self.prefix:
59
+ time_formatted = message.time.strftime(self.time_format)
60
+ prefix = f"{time_formatted} {name}| "
61
+ if self.color and self._colors_supported and message.color:
62
+ prefix = _color_string(message.color, prefix)
63
+ print(prefix + line, file=self.output, flush=True)
64
+
65
+
66
+ def _ansi(code):
67
+ return f"\033[{code}m"
68
+
69
+
70
+ def _color_string(color, s):
71
+ return f"{_ansi(0)}{_ansi(color)}{s}{_ansi(0)}"
72
+
73
+
74
+ if ON_WINDOWS:
75
+ # The colorama package provides transparent support for ANSI color codes
76
+ # on Win32 platforms. We try and import and configure that, but fall back
77
+ # to no color if we fail.
78
+ try:
79
+ import colorama
80
+ except ImportError:
81
+
82
+ def _color_string(color, s):
83
+ return s
84
+ else:
85
+ colorama.init()
@@ -0,0 +1,81 @@
1
+ import datetime
2
+ import os
3
+ import signal
4
+ import subprocess
5
+
6
+ from .compat import ON_WINDOWS
7
+ from .printer import Message
8
+
9
+
10
+ class Process:
11
+ """
12
+ A simple utility wrapper around a subprocess.Popen that stores
13
+ a number of attributes needed by Poncho and supports forwarding process
14
+ lifecycle events and output to a queue.
15
+ """
16
+
17
+ def __init__(self, cmd, name=None, color=None, quiet=False, env=None, cwd=None):
18
+ self.cmd = cmd
19
+ self.color = color
20
+ self.quiet = quiet
21
+ self.name = name
22
+ self.env = os.environ.copy() if env is None else env
23
+ self.cwd = cwd
24
+
25
+ self._clock = datetime.datetime
26
+ self._child = None
27
+ self._child_ctor = Popen
28
+
29
+ def run(self, events=None, ignore_signals=False):
30
+ self._events = events
31
+ self._child = self._child_ctor(self.cmd, env=self.env, cwd=self.cwd)
32
+ self._send_message({"pid": self._child.pid}, type="start")
33
+
34
+ # Don't pay attention to SIGINT/SIGTERM. The process itself is
35
+ # considered unkillable, and will only exit when its child (the shell
36
+ # running the Procfile process) exits.
37
+ if ignore_signals:
38
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
39
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
40
+
41
+ for line in iter(self._child.stdout.readline, b""):
42
+ if not self.quiet:
43
+ self._send_message(line)
44
+ self._child.stdout.close()
45
+ self._child.wait()
46
+
47
+ self._send_message({"returncode": self._child.returncode}, type="stop")
48
+
49
+ def _send_message(self, data, type="line"):
50
+ if self._events is not None:
51
+ self._events.put(
52
+ Message(
53
+ type=type,
54
+ data=data,
55
+ time=self._clock.now(),
56
+ name=self.name,
57
+ color=self.color,
58
+ )
59
+ )
60
+
61
+
62
+ class Popen(subprocess.Popen):
63
+ def __init__(self, cmd, **kwargs):
64
+ start_new_session = kwargs.pop("start_new_session", True)
65
+ options = {
66
+ "stdout": subprocess.PIPE,
67
+ "stderr": subprocess.STDOUT,
68
+ "shell": True,
69
+ "close_fds": not ON_WINDOWS,
70
+ }
71
+ options.update(**kwargs)
72
+
73
+ if ON_WINDOWS:
74
+ # MSDN reference:
75
+ # http://msdn.microsoft.com/en-us/library/windows/desktop/ms684863%28v=vs.85%29.aspx
76
+ create_no_window = 0x08000000
77
+ options.update(creationflags=create_no_window)
78
+ elif start_new_session:
79
+ options.update(start_new_session=True)
80
+
81
+ super().__init__(cmd, **options)
@@ -4,17 +4,13 @@ import sys
4
4
  from importlib.util import find_spec
5
5
  from pathlib import Path
6
6
 
7
+ import click
8
+ import tomllib
9
+
7
10
  from plain.cli.print import print_event
8
11
 
9
12
  from ..services import Services
10
13
 
11
- try:
12
- import tomllib
13
- except ModuleNotFoundError:
14
- import tomli as tomllib
15
-
16
- import click
17
-
18
14
 
19
15
  def install_git_hook():
20
16
  hook_path = os.path.join(".git", "hooks", "pre-commit")
@@ -5,6 +5,7 @@ from importlib.util import find_spec
5
5
  from pathlib import Path
6
6
 
7
7
  import click
8
+ import tomllib
8
9
  from honcho.manager import Manager as HonchoManager
9
10
 
10
11
  from plain.runtime import APP_PATH
@@ -12,11 +13,6 @@ from plain.runtime import APP_PATH
12
13
  from .pid import Pid
13
14
  from .utils import has_pyproject_toml
14
15
 
15
- try:
16
- import tomllib
17
- except ModuleNotFoundError:
18
- import tomli as tomllib
19
-
20
16
 
21
17
  class Services:
22
18
  @staticmethod
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def has_pyproject_toml(target_path):
5
+ return (Path(target_path) / "pyproject.toml").exists()
@@ -5,7 +5,7 @@ packages = [
5
5
  { include = "plain" },
6
6
  ]
7
7
 
8
- version = "0.5.0"
8
+ version = "0.7.0"
9
9
  description = "Local development tools for Plain."
10
10
  authors = ["Dave Gaeddert <dave.gaeddert@dropseed.dev>"]
11
11
  license = "BSD-3-Clause"
@@ -26,14 +26,11 @@ python = "^3.11"
26
26
  plain = "<1.0.0"
27
27
 
28
28
  click = ">=8.0.0"
29
- honcho = "^1.1.0"
30
29
  debugpy = "^1.6.3"
31
30
 
32
31
  # For local runserver
33
32
  gunicorn = ">20"
34
33
 
35
- tomli = {version = "^2.0.1", python = "<3.11"}
36
-
37
34
  # db
38
35
  requests = ">=2.0.0"
39
36
  psycopg = {version = "^3.2.2", extras = ["binary"]}
@@ -1,164 +0,0 @@
1
- import json
2
- import os
3
- import subprocess
4
- import sys
5
- from importlib.util import find_spec
6
- from pathlib import Path
7
-
8
- import click
9
- from honcho.manager import Manager as HonchoManager
10
-
11
- from plain.runtime import APP_PATH
12
-
13
- from .db import cli as db_cli
14
- from .pid import Pid
15
- from .services import Services
16
- from .utils import has_pyproject_toml, plainpackage_installed
17
-
18
- try:
19
- import tomllib
20
- except ModuleNotFoundError:
21
- import tomli as tomllib
22
-
23
-
24
- @click.group(invoke_without_command=True)
25
- @click.pass_context
26
- @click.option(
27
- "--port",
28
- "-p",
29
- default=8000,
30
- type=int,
31
- help="Port to run the web server on",
32
- envvar="PORT",
33
- )
34
- def cli(ctx, port):
35
- """Start local development"""
36
-
37
- if ctx.invoked_subcommand:
38
- return
39
-
40
- returncode = Dev(port=port).run()
41
- if returncode:
42
- sys.exit(returncode)
43
-
44
-
45
- @cli.command()
46
- def services():
47
- """Start additional services defined in pyproject.toml"""
48
- Services().run()
49
-
50
-
51
- class Dev:
52
- def __init__(self, *, port):
53
- self.manager = HonchoManager()
54
- self.port = port
55
- self.plain_env = {
56
- **os.environ,
57
- "PYTHONUNBUFFERED": "true",
58
- }
59
- self.custom_process_env = {
60
- **self.plain_env,
61
- "PORT": str(self.port),
62
- "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
63
- }
64
-
65
- def run(self):
66
- pid = Pid()
67
- pid.write()
68
-
69
- try:
70
- self.add_csrf_trusted_origins()
71
- self.run_preflight()
72
- self.add_gunicorn()
73
- self.add_tailwind()
74
- self.add_pyproject_run()
75
- self.add_services()
76
-
77
- self.manager.loop()
78
-
79
- return self.manager.returncode
80
- finally:
81
- pid.rm()
82
-
83
- def add_csrf_trusted_origins(self):
84
- if "PLAIN_CSRF_TRUSTED_ORIGINS" in os.environ:
85
- return
86
-
87
- csrf_trusted_origins = json.dumps(
88
- [f"http://localhost:{self.port}", f"http://127.0.0.1:{self.port}"]
89
- )
90
-
91
- click.secho(
92
- f"Automatically set PLAIN_CSRF_TRUSTED_ORIGINS={click.style(csrf_trusted_origins, underline=True)}",
93
- bold=True,
94
- )
95
-
96
- # Set BASE_URL for plain and custom processes
97
- self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
98
- self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
99
-
100
- def run_preflight(self):
101
- if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
102
- click.secho("Preflight check failed!", fg="red")
103
- sys.exit(1)
104
-
105
- def add_gunicorn(self):
106
- plain_db_installed = find_spec("plain.models") is not None
107
-
108
- # TODO not necessarily watching the right .env...
109
- # could return path from env.load?
110
- extra_watch_files = []
111
- for f in os.listdir(APP_PATH.parent):
112
- if f.startswith(".env"):
113
- # Will include some extra, but good enough for now
114
- extra_watch_files.append(f)
115
-
116
- reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
117
- gunicorn = f"gunicorn --bind 127.0.0.1:{self.port} --reload plain.wsgi:app --timeout 60 --access-logfile - --error-logfile - {reload_extra} --access-logformat '\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'"
118
-
119
- if plain_db_installed:
120
- runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
121
- else:
122
- runserver_cmd = gunicorn
123
-
124
- if "WEB_CONCURRENCY" not in self.plain_env:
125
- # Default to two workers so request log etc are less
126
- # likely to get locked up
127
- self.plain_env["WEB_CONCURRENCY"] = "2"
128
-
129
- self.manager.add_process("plain", runserver_cmd, env=self.plain_env)
130
-
131
- def add_tailwind(self):
132
- if not plainpackage_installed("tailwind"):
133
- return
134
-
135
- self.manager.add_process("tailwind", "plain tailwind compile --watch")
136
-
137
- def add_pyproject_run(self):
138
- if not has_pyproject_toml(APP_PATH.parent):
139
- return
140
-
141
- with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
142
- pyproject = tomllib.load(f)
143
-
144
- for name, data in (
145
- pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
146
- ).items():
147
- env = {
148
- **self.custom_process_env,
149
- **data.get("env", {}),
150
- }
151
- self.manager.add_process(name, data["cmd"], env=env)
152
-
153
- def add_services(self):
154
- services = Services.get_services(APP_PATH.parent)
155
- for name, data in services.items():
156
- env = {
157
- **os.environ,
158
- "PYTHONUNBUFFERED": "true",
159
- **data.get("env", {}),
160
- }
161
- self.manager.add_process(name, data["cmd"], env=env)
162
-
163
-
164
- cli.add_command(db_cli)
@@ -1,14 +0,0 @@
1
- import importlib
2
- from pathlib import Path
3
-
4
-
5
- def plainpackage_installed(name: str) -> bool:
6
- try:
7
- importlib.import_module(f"plain.{name}")
8
- return True
9
- except ImportError:
10
- return False
11
-
12
-
13
- def has_pyproject_toml(target_path):
14
- return (Path(target_path) / "pyproject.toml").exists()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes