plain.dev 0.5.0__py3-none-any.whl → 0.6.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,32 +1,31 @@
1
1
  import json
2
2
  import os
3
+ import platform
4
+ import shutil
3
5
  import subprocess
4
6
  import sys
7
+ import urllib.request
5
8
  from importlib.util import find_spec
6
9
  from pathlib import Path
7
10
 
8
11
  import click
12
+ import tomllib
9
13
  from honcho.manager import Manager as HonchoManager
10
14
 
11
- from plain.runtime import APP_PATH
15
+ from plain.runtime import APP_PATH, settings
12
16
 
13
17
  from .db import cli as db_cli
14
18
  from .pid import Pid
15
19
  from .services import Services
16
20
  from .utils import has_pyproject_toml, plainpackage_installed
17
21
 
18
- try:
19
- import tomllib
20
- except ModuleNotFoundError:
21
- import tomli as tomllib
22
-
23
22
 
24
23
  @click.group(invoke_without_command=True)
25
24
  @click.pass_context
26
25
  @click.option(
27
26
  "--port",
28
27
  "-p",
29
- default=8000,
28
+ default=8443,
30
29
  type=int,
31
30
  help="Port to run the web server on",
32
31
  envvar="PORT",
@@ -61,31 +60,187 @@ class Dev:
61
60
  "PORT": str(self.port),
62
61
  "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
63
62
  }
63
+ self.project_name = os.path.basename(os.getcwd())
64
+ 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"
64
76
 
65
77
  def run(self):
66
78
  pid = Pid()
67
79
  pid.write()
68
80
 
69
81
  try:
82
+ self.setup_mkcert()
83
+ self.generate_certs()
84
+ self.modify_hosts_file()
70
85
  self.add_csrf_trusted_origins()
86
+ self.add_allowed_hosts()
71
87
  self.run_preflight()
72
88
  self.add_gunicorn()
73
89
  self.add_tailwind()
74
90
  self.add_pyproject_run()
75
91
  self.add_services()
76
92
 
93
+ # Output the clickable link before starting the manager loop
94
+ url = f"https://{self.domain}:{self.port}/"
95
+ click.secho(
96
+ f"\nYour application is running at: {click.style(url, fg='green', underline=True)}\n",
97
+ bold=True,
98
+ )
99
+
77
100
  self.manager.loop()
78
101
 
79
102
  return self.manager.returncode
80
103
  finally:
81
104
  pid.rm()
82
105
 
83
- def add_csrf_trusted_origins(self):
84
- if "PLAIN_CSRF_TRUSTED_ORIGINS" in os.environ:
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():
85
166
  return
86
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
+ def modify_hosts_file(self):
185
+ """Modify the hosts file to map the custom domain to 127.0.0.1."""
186
+ entry_identifier = "# Added by plain"
187
+ hosts_entry = f"127.0.0.1 {self.domain} {entry_identifier}"
188
+
189
+ if platform.system() == "Windows":
190
+ hosts_path = Path(r"C:\Windows\System32\drivers\etc\hosts")
191
+ try:
192
+ with hosts_path.open("r") as f:
193
+ content = f.read()
194
+
195
+ if hosts_entry in content:
196
+ return # Entry already exists; no action needed
197
+
198
+ # Entry does not exist; add it
199
+ with hosts_path.open("a") as f:
200
+ f.write(f"{hosts_entry}\n")
201
+ click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
202
+ except PermissionError:
203
+ click.secho(
204
+ "Permission denied while modifying hosts file. Please run the script as an administrator.",
205
+ fg="red",
206
+ )
207
+ sys.exit(1)
208
+ else:
209
+ # For macOS and Linux
210
+ hosts_path = Path("/etc/hosts")
211
+ try:
212
+ with hosts_path.open("r") as f:
213
+ content = f.read()
214
+
215
+ if hosts_entry in content:
216
+ return # Entry already exists; no action needed
217
+
218
+ # Entry does not exist; append it using sudo
219
+ click.secho(
220
+ "Modifying /etc/hosts file. You may be prompted for your password.",
221
+ bold=True,
222
+ )
223
+ cmd = f"echo '{hosts_entry}' | sudo tee -a {hosts_path} >/dev/null"
224
+ subprocess.run(cmd, shell=True, check=True)
225
+ click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
226
+ except PermissionError:
227
+ click.secho(
228
+ "Permission denied while accessing hosts file.",
229
+ fg="red",
230
+ )
231
+ sys.exit(1)
232
+ except subprocess.CalledProcessError:
233
+ click.secho(
234
+ "Failed to modify hosts file. Please ensure you have sudo privileges.",
235
+ fg="red",
236
+ )
237
+ sys.exit(1)
238
+
239
+ def add_csrf_trusted_origins(self):
87
240
  csrf_trusted_origins = json.dumps(
88
- [f"http://localhost:{self.port}", f"http://127.0.0.1:{self.port}"]
241
+ [
242
+ f"https://{self.domain}:{self.port}",
243
+ ]
89
244
  )
90
245
 
91
246
  click.secho(
@@ -93,10 +248,22 @@ class Dev:
93
248
  bold=True,
94
249
  )
95
250
 
96
- # Set BASE_URL for plain and custom processes
251
+ # Set environment variables
97
252
  self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
98
253
  self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
99
254
 
255
+ def add_allowed_hosts(self):
256
+ allowed_hosts = json.dumps([self.domain])
257
+
258
+ click.secho(
259
+ f"Automatically set PLAIN_ALLOWED_HOSTS={click.style(allowed_hosts, underline=True)}",
260
+ bold=True,
261
+ )
262
+
263
+ # Set environment variables
264
+ self.plain_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
265
+ self.custom_process_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
266
+
100
267
  def run_preflight(self):
101
268
  if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
102
269
  click.secho("Preflight check failed!", fg="red")
@@ -105,16 +272,34 @@ class Dev:
105
272
  def add_gunicorn(self):
106
273
  plain_db_installed = find_spec("plain.models") is not None
107
274
 
108
- # TODO not necessarily watching the right .env...
109
- # could return path from env.load?
275
+ # Watch .env files for reload
110
276
  extra_watch_files = []
111
277
  for f in os.listdir(APP_PATH.parent):
112
278
  if f.startswith(".env"):
113
- # Will include some extra, but good enough for now
114
279
  extra_watch_files.append(f)
115
280
 
116
281
  reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
117
- gunicorn = f"gunicorn --bind 127.0.0.1:{self.port} --reload plain.wsgi:app --timeout 60 --access-logfile - --error-logfile - {reload_extra} --access-logformat '\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'"
282
+ gunicorn_cmd = [
283
+ "gunicorn",
284
+ "--bind",
285
+ f"{self.domain}:{self.port}",
286
+ "--certfile",
287
+ str(self.cert_path),
288
+ "--keyfile",
289
+ str(self.key_path),
290
+ "--reload",
291
+ "plain.wsgi:app",
292
+ "--timeout",
293
+ "60",
294
+ "--access-logfile",
295
+ "-",
296
+ "--error-logfile",
297
+ "-",
298
+ *reload_extra.split(),
299
+ "--access-logformat",
300
+ "'\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'",
301
+ ]
302
+ gunicorn = " ".join(gunicorn_cmd)
118
303
 
119
304
  if plain_db_installed:
120
305
  runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
@@ -122,8 +307,7 @@ class Dev:
122
307
  runserver_cmd = gunicorn
123
308
 
124
309
  if "WEB_CONCURRENCY" not in self.plain_env:
125
- # Default to two workers so request log etc are less
126
- # likely to get locked up
310
+ # Default to two workers to prevent lockups
127
311
  self.plain_env["WEB_CONCURRENCY"] = "2"
128
312
 
129
313
  self.manager.add_process("plain", runserver_cmd, env=self.plain_env)
@@ -141,9 +325,10 @@ class Dev:
141
325
  with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
142
326
  pyproject = tomllib.load(f)
143
327
 
144
- for name, data in (
328
+ run_commands = (
145
329
  pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
146
- ).items():
330
+ )
331
+ for name, data in run_commands.items():
147
332
  env = {
148
333
  **self.custom_process_env,
149
334
  **data.get("env", {}),
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: plain.dev
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Local development tools for Plain.
5
5
  Home-page: https://plainframework.com
6
6
  License: BSD-3-Clause
@@ -18,7 +18,6 @@ Requires-Dist: honcho (>=1.1.0,<2.0.0)
18
18
  Requires-Dist: plain (<1.0.0)
19
19
  Requires-Dist: psycopg[binary] (>=3.2.2,<4.0.0)
20
20
  Requires-Dist: requests (>=2.0.0)
21
- Requires-Dist: tomli (>=2.0.1,<3.0.0) ; python_version < "3.11"
22
21
  Project-URL: Documentation, https://plainframework.com/docs/
23
22
  Project-URL: Repository, https://github.com/dropseed/plain
24
23
  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=4JOdOVvL4jjWad286g4lm4L9sdaFt7UJejaUK-TW3PU,4820
3
+ plain/dev/cli.py,sha256=7MyA2-XExzFwYtv6q_TFEUXxelcPt2u3Ou51r9fGcDY,11740
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
@@ -11,15 +11,15 @@ plain/dev/debug.py,sha256=fIrecLfAK_lXSDyn3WmYikzZSse3KY47xcVVbZqJGhk,294
11
11
  plain/dev/default_settings.py,sha256=uXWYORWP_aRDwXIFXdu5kHyiBFUZzARIJdhPeFaX35c,75
12
12
  plain/dev/pid.py,sha256=gRMBf7aGndrra1TnmKtPghTijnd0i0Xeo63mzfPWp7M,436
13
13
  plain/dev/precommit/__init__.py,sha256=9ByBOIdM8DebChjNz-RH2atdz4vWe8somlwNEsbhwh4,40
14
- plain/dev/precommit/cli.py,sha256=gwpFcH267Kbqc2Ci3MHFxxAXsuDDblGxcicVVNi1NdY,3488
14
+ plain/dev/precommit/cli.py,sha256=UNrQmWRKrkZ6WbzrrcnjZl8VHwNorOVTHGoRQsR4jp8,3422
15
15
  plain/dev/requests.py,sha256=0HyCH7iZ32ne94ypMdE96z5iYb_Qbd705WItVik1SyA,6839
16
- plain/dev/services.py,sha256=m4XOcY8qcUVA4u8vsFZmU-wChgQvy5uiu5abOR9wZfg,2127
16
+ plain/dev/services.py,sha256=Ypat8YFum0K_CLtkUuU2L6Y9Rg8GJBvJ0OqeVGMVT9g,2061
17
17
  plain/dev/templates/dev/requests.html,sha256=kQKJZq5L77juuL_t8UjcAehEU61U4RXNnKaAET-wAm8,7627
18
18
  plain/dev/urls.py,sha256=b4NL2I6Ok-t7nTPjRnKoz_LQRttE3_mp8l2NlmeYQ9I,146
19
19
  plain/dev/utils.py,sha256=mL3C3l3GsKmtI6eF4sRjv7w9n7Y9lLVqJulj81JrqWw,312
20
20
  plain/dev/views.py,sha256=r2Ivk7OXytpRhXq4DZpsb7FXNP9vzmEE3D5kLajYG4w,1073
21
- plain_dev-0.5.0.dist-info/LICENSE,sha256=cvKM3OlqHx3ijD6e34zsSUkPvzl-ya3Dd63A6EHL94U,1500
22
- plain_dev-0.5.0.dist-info/METADATA,sha256=BUN9CnXC6gRHWTrI3S1_awfvuPONdIwala0ln9j6M5E,4727
23
- plain_dev-0.5.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
24
- plain_dev-0.5.0.dist-info/entry_points.txt,sha256=rBo-S4THn07f55UwHBuUhIbDhlUq3EzTOD8mIb5fGQg,99
25
- plain_dev-0.5.0.dist-info/RECORD,,
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,,