plain.dev 0.5.0__py3-none-any.whl → 0.7.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.
- plain/dev/cli.py +159 -35
- plain/dev/mkcert.py +96 -0
- plain/dev/poncho/__init__.py +4 -0
- plain/dev/poncho/color.py +28 -0
- plain/dev/poncho/compat.py +47 -0
- plain/dev/poncho/manager.py +201 -0
- plain/dev/poncho/printer.py +85 -0
- plain/dev/poncho/process.py +81 -0
- plain/dev/precommit/cli.py +3 -7
- plain/dev/services.py +1 -5
- plain/dev/utils.py +0 -9
- {plain_dev-0.5.0.dist-info → plain_dev-0.7.0.dist-info}/LICENSE +25 -0
- {plain_dev-0.5.0.dist-info → plain_dev-0.7.0.dist-info}/METADATA +1 -3
- {plain_dev-0.5.0.dist-info → plain_dev-0.7.0.dist-info}/RECORD +16 -9
- {plain_dev-0.5.0.dist-info → plain_dev-0.7.0.dist-info}/WHEEL +0 -0
- {plain_dev-0.5.0.dist-info → plain_dev-0.7.0.dist-info}/entry_points.txt +0 -0
plain/dev/cli.py
CHANGED
@@ -1,24 +1,25 @@
|
|
1
1
|
import json
|
2
2
|
import os
|
3
|
+
import platform
|
3
4
|
import subprocess
|
4
5
|
import sys
|
6
|
+
from importlib.metadata import entry_points
|
5
7
|
from importlib.util import find_spec
|
6
8
|
from pathlib import Path
|
7
9
|
|
8
10
|
import click
|
9
|
-
|
11
|
+
import tomllib
|
10
12
|
|
11
|
-
from plain.runtime import APP_PATH
|
13
|
+
from plain.runtime import APP_PATH, settings
|
12
14
|
|
13
15
|
from .db import cli as db_cli
|
16
|
+
from .mkcert import MkcertManager
|
14
17
|
from .pid import Pid
|
18
|
+
from .poncho.manager import Manager as PonchoManager
|
15
19
|
from .services import Services
|
16
|
-
from .utils import has_pyproject_toml
|
20
|
+
from .utils import has_pyproject_toml
|
17
21
|
|
18
|
-
|
19
|
-
import tomllib
|
20
|
-
except ModuleNotFoundError:
|
21
|
-
import tomli as tomllib
|
22
|
+
ENTRYPOINT_GROUP = "plain.dev"
|
22
23
|
|
23
24
|
|
24
25
|
@click.group(invoke_without_command=True)
|
@@ -26,7 +27,7 @@ except ModuleNotFoundError:
|
|
26
27
|
@click.option(
|
27
28
|
"--port",
|
28
29
|
"-p",
|
29
|
-
default=
|
30
|
+
default=8443,
|
30
31
|
type=int,
|
31
32
|
help="Port to run the web server on",
|
32
33
|
envvar="PORT",
|
@@ -48,9 +49,27 @@ def services():
|
|
48
49
|
Services().run()
|
49
50
|
|
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
|
+
|
51
70
|
class Dev:
|
52
71
|
def __init__(self, *, port):
|
53
|
-
self.
|
72
|
+
self.poncho = PonchoManager()
|
54
73
|
self.port = port
|
55
74
|
self.plain_env = {
|
56
75
|
**os.environ,
|
@@ -61,31 +80,106 @@ class Dev:
|
|
61
80
|
"PORT": str(self.port),
|
62
81
|
"PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
|
63
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
|
64
87
|
|
65
88
|
def run(self):
|
66
89
|
pid = Pid()
|
67
90
|
pid.write()
|
68
91
|
|
69
92
|
try:
|
70
|
-
|
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()
|
71
102
|
self.run_preflight()
|
103
|
+
|
104
|
+
# Processes for poncho to run simultaneously
|
72
105
|
self.add_gunicorn()
|
73
|
-
self.
|
106
|
+
self.add_entrypoints()
|
74
107
|
self.add_pyproject_run()
|
75
108
|
self.add_services()
|
76
109
|
|
77
|
-
|
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()
|
78
118
|
|
79
|
-
return self.
|
119
|
+
return self.poncho.returncode
|
80
120
|
finally:
|
81
121
|
pid.rm()
|
82
122
|
|
83
|
-
def
|
84
|
-
|
85
|
-
|
86
|
-
|
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):
|
87
179
|
csrf_trusted_origins = json.dumps(
|
88
|
-
[
|
180
|
+
[
|
181
|
+
f"https://{self.domain}:{self.port}",
|
182
|
+
]
|
89
183
|
)
|
90
184
|
|
91
185
|
click.secho(
|
@@ -93,10 +187,22 @@ class Dev:
|
|
93
187
|
bold=True,
|
94
188
|
)
|
95
189
|
|
96
|
-
# Set
|
190
|
+
# Set environment variables
|
97
191
|
self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
|
98
192
|
self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
|
99
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
|
+
|
100
206
|
def run_preflight(self):
|
101
207
|
if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
|
102
208
|
click.secho("Preflight check failed!", fg="red")
|
@@ -105,16 +211,34 @@ class Dev:
|
|
105
211
|
def add_gunicorn(self):
|
106
212
|
plain_db_installed = find_spec("plain.models") is not None
|
107
213
|
|
108
|
-
#
|
109
|
-
# could return path from env.load?
|
214
|
+
# Watch .env files for reload
|
110
215
|
extra_watch_files = []
|
111
216
|
for f in os.listdir(APP_PATH.parent):
|
112
217
|
if f.startswith(".env"):
|
113
|
-
# Will include some extra, but good enough for now
|
114
218
|
extra_watch_files.append(f)
|
115
219
|
|
116
220
|
reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
|
117
|
-
|
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)
|
118
242
|
|
119
243
|
if plain_db_installed:
|
120
244
|
runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
|
@@ -122,17 +246,16 @@ class Dev:
|
|
122
246
|
runserver_cmd = gunicorn
|
123
247
|
|
124
248
|
if "WEB_CONCURRENCY" not in self.plain_env:
|
125
|
-
# Default to two workers
|
126
|
-
# likely to get locked up
|
249
|
+
# Default to two workers to prevent lockups
|
127
250
|
self.plain_env["WEB_CONCURRENCY"] = "2"
|
128
251
|
|
129
|
-
self.
|
252
|
+
self.poncho.add_process("plain", runserver_cmd, env=self.plain_env)
|
130
253
|
|
131
|
-
def
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
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
|
+
)
|
136
259
|
|
137
260
|
def add_pyproject_run(self):
|
138
261
|
if not has_pyproject_toml(APP_PATH.parent):
|
@@ -141,14 +264,15 @@ class Dev:
|
|
141
264
|
with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
|
142
265
|
pyproject = tomllib.load(f)
|
143
266
|
|
144
|
-
|
267
|
+
run_commands = (
|
145
268
|
pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
|
146
|
-
)
|
269
|
+
)
|
270
|
+
for name, data in run_commands.items():
|
147
271
|
env = {
|
148
272
|
**self.custom_process_env,
|
149
273
|
**data.get("env", {}),
|
150
274
|
}
|
151
|
-
self.
|
275
|
+
self.poncho.add_process(name, data["cmd"], env=env)
|
152
276
|
|
153
277
|
def add_services(self):
|
154
278
|
services = Services.get_services(APP_PATH.parent)
|
@@ -158,7 +282,7 @@ class Dev:
|
|
158
282
|
"PYTHONUNBUFFERED": "true",
|
159
283
|
**data.get("env", {}),
|
160
284
|
}
|
161
|
-
self.
|
285
|
+
self.poncho.add_process(name, data["cmd"], env=env)
|
162
286
|
|
163
287
|
|
164
288
|
cli.add_command(db_cli)
|
plain/dev/mkcert.py
ADDED
@@ -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)
|
plain/dev/precommit/cli.py
CHANGED
@@ -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")
|
plain/dev/services.py
CHANGED
@@ -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
|
plain/dev/utils.py
CHANGED
@@ -1,14 +1,5 @@
|
|
1
|
-
import importlib
|
2
1
|
from pathlib import Path
|
3
2
|
|
4
3
|
|
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
4
|
def has_pyproject_toml(target_path):
|
14
5
|
return (Path(target_path) / "pyproject.toml").exists()
|
@@ -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,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
|
@@ -1,6 +1,6 @@
|
|
1
1
|
plain/dev/README.md,sha256=BQDaRKfsafIPzx7vtVt-zS-a8l6sxbQThhQTvu7tp3Y,3699
|
2
2
|
plain/dev/__init__.py,sha256=C1JrkNE5XX2DLgBXXLAV_UyhofwVd0ZPL59fPUMbOKo,139
|
3
|
-
plain/dev/cli.py,sha256=
|
3
|
+
plain/dev/cli.py,sha256=TXDbIVixm8E_Am_VuKPRSSdlwaXm_hXQDOiNREiGUc0,9314
|
4
4
|
plain/dev/config.py,sha256=h6o5YZtJhg-cFIWoqIDWuMCC5T09cxEsBaa3BP4Nii0,632
|
5
5
|
plain/dev/contribute/__init__.py,sha256=9ByBOIdM8DebChjNz-RH2atdz4vWe8somlwNEsbhwh4,40
|
6
6
|
plain/dev/contribute/cli.py,sha256=qZ7YmE_upbw-Y5NRpvaHnJTPp9kvn21fPQ7G0n1LAkg,2277
|
@@ -9,17 +9,24 @@ plain/dev/db/cli.py,sha256=058HjRKLGz-FxauQEpwsPoh_LCiy-_NEIpRZl9W1ZKM,2855
|
|
9
9
|
plain/dev/db/container.py,sha256=RlPJU_CCMKA-zN8Kp0sYAu3jabOizxYAj8fSCsjCf60,5147
|
10
10
|
plain/dev/debug.py,sha256=fIrecLfAK_lXSDyn3WmYikzZSse3KY47xcVVbZqJGhk,294
|
11
11
|
plain/dev/default_settings.py,sha256=uXWYORWP_aRDwXIFXdu5kHyiBFUZzARIJdhPeFaX35c,75
|
12
|
+
plain/dev/mkcert.py,sha256=u284XXQeTgwSkKKh0gMrYpnNGdd9OOUJKLQAXcvGnr4,3459
|
12
13
|
plain/dev/pid.py,sha256=gRMBf7aGndrra1TnmKtPghTijnd0i0Xeo63mzfPWp7M,436
|
14
|
+
plain/dev/poncho/__init__.py,sha256=MDOk2rhhoR3V-I-rg6tMHFeX60vTGJuQ14RI-_N6tQY,97
|
15
|
+
plain/dev/poncho/color.py,sha256=Dk77inPR9qNc9vCaZOGk8W9skXfRgoUlxp_E6mhPNns,610
|
16
|
+
plain/dev/poncho/compat.py,sha256=YzioS5fuzUl6j55fzgvhsU6TS_IdegMtv7FUSUU6sBQ,1275
|
17
|
+
plain/dev/poncho/manager.py,sha256=UaC4Qlejy4tVAz_6z5J4Auu5PZBW1pUuo9hUMfIMSFQ,6360
|
18
|
+
plain/dev/poncho/printer.py,sha256=KSkSXx6KiejekJ9e4bzutIAIL8oZPDGimtVHNRJyFbQ,2671
|
19
|
+
plain/dev/poncho/process.py,sha256=JJOKy-C6vMCg7-6JMCtu6C649h7HmOBSJqDP_hnX49I,2637
|
13
20
|
plain/dev/precommit/__init__.py,sha256=9ByBOIdM8DebChjNz-RH2atdz4vWe8somlwNEsbhwh4,40
|
14
|
-
plain/dev/precommit/cli.py,sha256=
|
21
|
+
plain/dev/precommit/cli.py,sha256=UNrQmWRKrkZ6WbzrrcnjZl8VHwNorOVTHGoRQsR4jp8,3422
|
15
22
|
plain/dev/requests.py,sha256=0HyCH7iZ32ne94ypMdE96z5iYb_Qbd705WItVik1SyA,6839
|
16
|
-
plain/dev/services.py,sha256=
|
23
|
+
plain/dev/services.py,sha256=Ypat8YFum0K_CLtkUuU2L6Y9Rg8GJBvJ0OqeVGMVT9g,2061
|
17
24
|
plain/dev/templates/dev/requests.html,sha256=kQKJZq5L77juuL_t8UjcAehEU61U4RXNnKaAET-wAm8,7627
|
18
25
|
plain/dev/urls.py,sha256=b4NL2I6Ok-t7nTPjRnKoz_LQRttE3_mp8l2NlmeYQ9I,146
|
19
|
-
plain/dev/utils.py,sha256=
|
26
|
+
plain/dev/utils.py,sha256=4wMzpvj1Is_c0QxhsTu34_P9wAYlzw4glNPfVtZr_0A,123
|
20
27
|
plain/dev/views.py,sha256=r2Ivk7OXytpRhXq4DZpsb7FXNP9vzmEE3D5kLajYG4w,1073
|
21
|
-
plain_dev-0.
|
22
|
-
plain_dev-0.
|
23
|
-
plain_dev-0.
|
24
|
-
plain_dev-0.
|
25
|
-
plain_dev-0.
|
28
|
+
plain_dev-0.7.0.dist-info/LICENSE,sha256=YDg-l_Rj7LVP5uDXy04eQPGb_DYVXAHAHYd9r3pU1Cg,2713
|
29
|
+
plain_dev-0.7.0.dist-info/METADATA,sha256=l5Rh4GEIR-vNpahtIs6jIrMsng0ekBR-fxVe5xVQAHs,4624
|
30
|
+
plain_dev-0.7.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
31
|
+
plain_dev-0.7.0.dist-info/entry_points.txt,sha256=rBo-S4THn07f55UwHBuUhIbDhlUq3EzTOD8mIb5fGQg,99
|
32
|
+
plain_dev-0.7.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|