plain.dev 0.6.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 CHANGED
@@ -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 urllib.request
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, plainpackage_installed
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.manager = HonchoManager()
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
- # Paths for mkcert and certificates
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
- self.setup_mkcert()
83
- self.generate_certs()
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.add_csrf_trusted_origins()
86
- self.add_allowed_hosts()
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.add_tailwind()
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.manager.loop()
117
+ self.poncho.loop()
101
118
 
102
- return self.manager.returncode
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 add_csrf_trusted_origins(self):
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 add_allowed_hosts(self):
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.cert_path),
226
+ str(self.ssl_cert_path),
288
227
  "--keyfile",
289
- str(self.key_path),
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.manager.add_process("plain", runserver_cmd, env=self.plain_env)
252
+ self.poncho.add_process("plain", runserver_cmd, env=self.plain_env)
314
253
 
315
- def add_tailwind(self):
316
- if not plainpackage_installed("tailwind"):
317
- return
318
-
319
- self.manager.add_process("tailwind", "plain tailwind compile --watch")
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.manager.add_process(name, data["cmd"], env=env)
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.manager.add_process(name, data["cmd"], env=env)
285
+ self.poncho.add_process(name, data["cmd"], env=env)
347
286
 
348
287
 
349
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,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)
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.6.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,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=7MyA2-XExzFwYtv6q_TFEUXxelcPt2u3Ou51r9fGcDY,11740
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
21
  plain/dev/precommit/cli.py,sha256=UNrQmWRKrkZ6WbzrrcnjZl8VHwNorOVTHGoRQsR4jp8,3422
15
22
  plain/dev/requests.py,sha256=0HyCH7iZ32ne94ypMdE96z5iYb_Qbd705WItVik1SyA,6839
16
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=mL3C3l3GsKmtI6eF4sRjv7w9n7Y9lLVqJulj81JrqWw,312
26
+ plain/dev/utils.py,sha256=4wMzpvj1Is_c0QxhsTu34_P9wAYlzw4glNPfVtZr_0A,123
20
27
  plain/dev/views.py,sha256=r2Ivk7OXytpRhXq4DZpsb7FXNP9vzmEE3D5kLajYG4w,1073
21
- plain_dev-0.6.0.dist-info/LICENSE,sha256=cvKM3OlqHx3ijD6e34zsSUkPvzl-ya3Dd63A6EHL94U,1500
22
- plain_dev-0.6.0.dist-info/METADATA,sha256=1kaH9Gal5Got6ltGlMJne_GI6Mqjd8Bz4CILkVujmTo,4663
23
- plain_dev-0.6.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
24
- plain_dev-0.6.0.dist-info/entry_points.txt,sha256=rBo-S4THn07f55UwHBuUhIbDhlUq3EzTOD8mIb5fGQg,99
25
- plain_dev-0.6.0.dist-info/RECORD,,
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,,