plain.dev 0.6.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.
- {plain_dev-0.6.0 → plain_dev-0.7.0}/LICENSE +25 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/PKG-INFO +1 -2
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/cli.py +52 -113
- plain_dev-0.7.0/plain/dev/mkcert.py +96 -0
- plain_dev-0.7.0/plain/dev/poncho/__init__.py +4 -0
- plain_dev-0.7.0/plain/dev/poncho/color.py +28 -0
- plain_dev-0.7.0/plain/dev/poncho/compat.py +47 -0
- plain_dev-0.7.0/plain/dev/poncho/manager.py +201 -0
- plain_dev-0.7.0/plain/dev/poncho/printer.py +85 -0
- plain_dev-0.7.0/plain/dev/poncho/process.py +81 -0
- plain_dev-0.7.0/plain/dev/utils.py +5 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/pyproject.toml +1 -2
- plain_dev-0.6.0/plain/dev/utils.py +0 -14
- {plain_dev-0.6.0 → plain_dev-0.7.0}/README.md +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/README.md +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/__init__.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/config.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/contribute/__init__.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/contribute/cli.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/db/__init__.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/db/cli.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/db/container.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/debug.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/default_settings.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/pid.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/precommit/__init__.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/precommit/cli.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/requests.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/services.py +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/templates/dev/requests.html +0 -0
- {plain_dev-0.6.0 → plain_dev-0.7.0}/plain/dev/urls.py +0 -0
- {plain_dev-0.6.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.
|
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,7 +14,6 @@ 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)
|
@@ -1,23 +1,25 @@
|
|
1
1
|
import json
|
2
2
|
import os
|
3
3
|
import platform
|
4
|
-
import shutil
|
5
4
|
import subprocess
|
6
5
|
import sys
|
7
|
-
import
|
6
|
+
from importlib.metadata import entry_points
|
8
7
|
from importlib.util import find_spec
|
9
8
|
from pathlib import Path
|
10
9
|
|
11
10
|
import click
|
12
11
|
import tomllib
|
13
|
-
from honcho.manager import Manager as HonchoManager
|
14
12
|
|
15
13
|
from plain.runtime import APP_PATH, settings
|
16
14
|
|
17
15
|
from .db import cli as db_cli
|
16
|
+
from .mkcert import MkcertManager
|
18
17
|
from .pid import Pid
|
18
|
+
from .poncho.manager import Manager as PonchoManager
|
19
19
|
from .services import Services
|
20
|
-
from .utils import has_pyproject_toml
|
20
|
+
from .utils import has_pyproject_toml
|
21
|
+
|
22
|
+
ENTRYPOINT_GROUP = "plain.dev"
|
21
23
|
|
22
24
|
|
23
25
|
@click.group(invoke_without_command=True)
|
@@ -47,9 +49,27 @@ def services():
|
|
47
49
|
Services().run()
|
48
50
|
|
49
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
|
+
|
50
70
|
class Dev:
|
51
71
|
def __init__(self, *, port):
|
52
|
-
self.
|
72
|
+
self.poncho = PonchoManager()
|
53
73
|
self.port = port
|
54
74
|
self.plain_env = {
|
55
75
|
**os.environ,
|
@@ -62,31 +82,28 @@ class Dev:
|
|
62
82
|
}
|
63
83
|
self.project_name = os.path.basename(os.getcwd())
|
64
84
|
self.domain = f"{self.project_name}.localhost"
|
65
|
-
|
66
|
-
|
67
|
-
self.mkcert_dir = Path.home() / ".plain" / "dev"
|
68
|
-
self.mkcert_bin = self.mkcert_dir / "mkcert"
|
69
|
-
self.certs_dir = (
|
70
|
-
Path(settings.PLAIN_TEMP_PATH) / "dev" / "certs"
|
71
|
-
) # Local project directory for certs
|
72
|
-
|
73
|
-
# Define certificate and key paths with clear filenames
|
74
|
-
self.cert_path = self.certs_dir / f"{self.domain}-cert.pem"
|
75
|
-
self.key_path = self.certs_dir / f"{self.domain}-key.pem"
|
85
|
+
self.ssl_cert_path = None
|
86
|
+
self.ssl_key_path = None
|
76
87
|
|
77
88
|
def run(self):
|
78
89
|
pid = Pid()
|
79
90
|
pid.write()
|
80
91
|
|
81
92
|
try:
|
82
|
-
|
83
|
-
|
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
|
+
)
|
84
99
|
self.modify_hosts_file()
|
85
|
-
self.
|
86
|
-
self.
|
100
|
+
self.set_csrf_trusted_origins()
|
101
|
+
self.set_allowed_hosts()
|
87
102
|
self.run_preflight()
|
103
|
+
|
104
|
+
# Processes for poncho to run simultaneously
|
88
105
|
self.add_gunicorn()
|
89
|
-
self.
|
106
|
+
self.add_entrypoints()
|
90
107
|
self.add_pyproject_run()
|
91
108
|
self.add_services()
|
92
109
|
|
@@ -97,90 +114,12 @@ class Dev:
|
|
97
114
|
bold=True,
|
98
115
|
)
|
99
116
|
|
100
|
-
self.
|
117
|
+
self.poncho.loop()
|
101
118
|
|
102
|
-
return self.
|
119
|
+
return self.poncho.returncode
|
103
120
|
finally:
|
104
121
|
pid.rm()
|
105
122
|
|
106
|
-
def setup_mkcert(self):
|
107
|
-
"""Set up mkcert by checking if it's installed or downloading the binary and installing the local CA."""
|
108
|
-
if mkcert_path := shutil.which("mkcert"):
|
109
|
-
# mkcert is already installed somewhere
|
110
|
-
self.mkcert_bin = mkcert_path
|
111
|
-
else:
|
112
|
-
self.mkcert_dir.mkdir(parents=True, exist_ok=True)
|
113
|
-
if not self.mkcert_bin.exists():
|
114
|
-
system = platform.system()
|
115
|
-
arch = platform.machine()
|
116
|
-
|
117
|
-
# Map platform.machine() to mkcert's expected architecture strings
|
118
|
-
arch_map = {
|
119
|
-
"x86_64": "amd64",
|
120
|
-
"amd64": "amd64",
|
121
|
-
"AMD64": "amd64",
|
122
|
-
"arm64": "arm64",
|
123
|
-
"aarch64": "arm64",
|
124
|
-
}
|
125
|
-
arch = arch_map.get(
|
126
|
-
arch.lower(), "amd64"
|
127
|
-
) # Default to amd64 if unknown
|
128
|
-
|
129
|
-
if system == "Darwin":
|
130
|
-
os_name = "darwin"
|
131
|
-
elif system == "Linux":
|
132
|
-
os_name = "linux"
|
133
|
-
elif system == "Windows":
|
134
|
-
os_name = "windows"
|
135
|
-
else:
|
136
|
-
click.secho("Unsupported OS", fg="red")
|
137
|
-
sys.exit(1)
|
138
|
-
|
139
|
-
mkcert_url = f"https://dl.filippo.io/mkcert/latest?for={os_name}/{arch}"
|
140
|
-
click.secho(f"Downloading mkcert from {mkcert_url}...", bold=True)
|
141
|
-
urllib.request.urlretrieve(mkcert_url, self.mkcert_bin)
|
142
|
-
self.mkcert_bin.chmod(0o755)
|
143
|
-
self.mkcert_bin = str(self.mkcert_bin) # Convert Path object to string
|
144
|
-
|
145
|
-
if not self.is_mkcert_ca_installed():
|
146
|
-
click.secho(
|
147
|
-
"Installing mkcert local CA. You may be prompted for your password.",
|
148
|
-
bold=True,
|
149
|
-
)
|
150
|
-
subprocess.run([self.mkcert_bin, "-install"], check=True)
|
151
|
-
|
152
|
-
def is_mkcert_ca_installed(self):
|
153
|
-
"""Check if mkcert local CA is already installed using mkcert -check."""
|
154
|
-
try:
|
155
|
-
result = subprocess.run([self.mkcert_bin, "-check"], capture_output=True)
|
156
|
-
output = result.stdout.decode() + result.stderr.decode()
|
157
|
-
if "The local CA is not installed" in output:
|
158
|
-
return False
|
159
|
-
return True
|
160
|
-
except Exception as e:
|
161
|
-
click.secho(f"Error checking mkcert CA installation: {e}", fg="red")
|
162
|
-
return False
|
163
|
-
|
164
|
-
def generate_certs(self):
|
165
|
-
if self.cert_path.exists() and self.key_path.exists():
|
166
|
-
return
|
167
|
-
|
168
|
-
self.certs_dir.mkdir(parents=True, exist_ok=True)
|
169
|
-
|
170
|
-
# Generate SSL certificates using mkcert
|
171
|
-
click.secho(f"Generating SSL certificates for {self.domain}...", bold=True)
|
172
|
-
subprocess.run(
|
173
|
-
[
|
174
|
-
self.mkcert_bin,
|
175
|
-
"-cert-file",
|
176
|
-
str(self.cert_path),
|
177
|
-
"-key-file",
|
178
|
-
str(self.key_path),
|
179
|
-
self.domain,
|
180
|
-
],
|
181
|
-
check=True,
|
182
|
-
)
|
183
|
-
|
184
123
|
def modify_hosts_file(self):
|
185
124
|
"""Modify the hosts file to map the custom domain to 127.0.0.1."""
|
186
125
|
entry_identifier = "# Added by plain"
|
@@ -236,7 +175,7 @@ class Dev:
|
|
236
175
|
)
|
237
176
|
sys.exit(1)
|
238
177
|
|
239
|
-
def
|
178
|
+
def set_csrf_trusted_origins(self):
|
240
179
|
csrf_trusted_origins = json.dumps(
|
241
180
|
[
|
242
181
|
f"https://{self.domain}:{self.port}",
|
@@ -252,7 +191,7 @@ class Dev:
|
|
252
191
|
self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
|
253
192
|
self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
|
254
193
|
|
255
|
-
def
|
194
|
+
def set_allowed_hosts(self):
|
256
195
|
allowed_hosts = json.dumps([self.domain])
|
257
196
|
|
258
197
|
click.secho(
|
@@ -284,9 +223,9 @@ class Dev:
|
|
284
223
|
"--bind",
|
285
224
|
f"{self.domain}:{self.port}",
|
286
225
|
"--certfile",
|
287
|
-
str(self.
|
226
|
+
str(self.ssl_cert_path),
|
288
227
|
"--keyfile",
|
289
|
-
str(self.
|
228
|
+
str(self.ssl_key_path),
|
290
229
|
"--reload",
|
291
230
|
"plain.wsgi:app",
|
292
231
|
"--timeout",
|
@@ -310,13 +249,13 @@ class Dev:
|
|
310
249
|
# Default to two workers to prevent lockups
|
311
250
|
self.plain_env["WEB_CONCURRENCY"] = "2"
|
312
251
|
|
313
|
-
self.
|
252
|
+
self.poncho.add_process("plain", runserver_cmd, env=self.plain_env)
|
314
253
|
|
315
|
-
def
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
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
|
+
)
|
320
259
|
|
321
260
|
def add_pyproject_run(self):
|
322
261
|
if not has_pyproject_toml(APP_PATH.parent):
|
@@ -333,7 +272,7 @@ class Dev:
|
|
333
272
|
**self.custom_process_env,
|
334
273
|
**data.get("env", {}),
|
335
274
|
}
|
336
|
-
self.
|
275
|
+
self.poncho.add_process(name, data["cmd"], env=env)
|
337
276
|
|
338
277
|
def add_services(self):
|
339
278
|
services = Services.get_services(APP_PATH.parent)
|
@@ -343,7 +282,7 @@ class Dev:
|
|
343
282
|
"PYTHONUNBUFFERED": "true",
|
344
283
|
**data.get("env", {}),
|
345
284
|
}
|
346
|
-
self.
|
285
|
+
self.poncho.add_process(name, data["cmd"], env=env)
|
347
286
|
|
348
287
|
|
349
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,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)
|
@@ -5,7 +5,7 @@ packages = [
|
|
5
5
|
{ include = "plain" },
|
6
6
|
]
|
7
7
|
|
8
|
-
version = "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,7 +26,6 @@ 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
|
@@ -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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|