plain.dev 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {plain_dev-0.5.0 → plain_dev-0.6.0}/PKG-INFO +1 -2
  2. plain_dev-0.6.0/plain/dev/cli.py +349 -0
  3. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/precommit/cli.py +3 -7
  4. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/services.py +1 -5
  5. {plain_dev-0.5.0 → plain_dev-0.6.0}/pyproject.toml +1 -3
  6. plain_dev-0.5.0/plain/dev/cli.py +0 -164
  7. {plain_dev-0.5.0 → plain_dev-0.6.0}/LICENSE +0 -0
  8. {plain_dev-0.5.0 → plain_dev-0.6.0}/README.md +0 -0
  9. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/README.md +0 -0
  10. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/__init__.py +0 -0
  11. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/config.py +0 -0
  12. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/contribute/__init__.py +0 -0
  13. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/contribute/cli.py +0 -0
  14. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/db/__init__.py +0 -0
  15. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/db/cli.py +0 -0
  16. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/db/container.py +0 -0
  17. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/debug.py +0 -0
  18. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/default_settings.py +0 -0
  19. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/pid.py +0 -0
  20. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/precommit/__init__.py +0 -0
  21. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/requests.py +0 -0
  22. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/templates/dev/requests.html +0 -0
  23. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/urls.py +0 -0
  24. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/utils.py +0 -0
  25. {plain_dev-0.5.0 → plain_dev-0.6.0}/plain/dev/views.py +0 -0
@@ -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
@@ -0,0 +1,349 @@
1
+ import json
2
+ import os
3
+ import platform
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import urllib.request
8
+ from importlib.util import find_spec
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import tomllib
13
+ from honcho.manager import Manager as HonchoManager
14
+
15
+ from plain.runtime import APP_PATH, settings
16
+
17
+ from .db import cli as db_cli
18
+ from .pid import Pid
19
+ from .services import Services
20
+ from .utils import has_pyproject_toml, plainpackage_installed
21
+
22
+
23
+ @click.group(invoke_without_command=True)
24
+ @click.pass_context
25
+ @click.option(
26
+ "--port",
27
+ "-p",
28
+ default=8443,
29
+ type=int,
30
+ help="Port to run the web server on",
31
+ envvar="PORT",
32
+ )
33
+ def cli(ctx, port):
34
+ """Start local development"""
35
+
36
+ if ctx.invoked_subcommand:
37
+ return
38
+
39
+ returncode = Dev(port=port).run()
40
+ if returncode:
41
+ sys.exit(returncode)
42
+
43
+
44
+ @cli.command()
45
+ def services():
46
+ """Start additional services defined in pyproject.toml"""
47
+ Services().run()
48
+
49
+
50
+ class Dev:
51
+ def __init__(self, *, port):
52
+ self.manager = HonchoManager()
53
+ self.port = port
54
+ self.plain_env = {
55
+ **os.environ,
56
+ "PYTHONUNBUFFERED": "true",
57
+ }
58
+ self.custom_process_env = {
59
+ **self.plain_env,
60
+ "PORT": str(self.port),
61
+ "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
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"
76
+
77
+ def run(self):
78
+ pid = Pid()
79
+ pid.write()
80
+
81
+ try:
82
+ self.setup_mkcert()
83
+ self.generate_certs()
84
+ self.modify_hosts_file()
85
+ self.add_csrf_trusted_origins()
86
+ self.add_allowed_hosts()
87
+ self.run_preflight()
88
+ self.add_gunicorn()
89
+ self.add_tailwind()
90
+ self.add_pyproject_run()
91
+ self.add_services()
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
+
100
+ self.manager.loop()
101
+
102
+ return self.manager.returncode
103
+ finally:
104
+ pid.rm()
105
+
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
+ 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):
240
+ csrf_trusted_origins = json.dumps(
241
+ [
242
+ f"https://{self.domain}:{self.port}",
243
+ ]
244
+ )
245
+
246
+ click.secho(
247
+ f"Automatically set PLAIN_CSRF_TRUSTED_ORIGINS={click.style(csrf_trusted_origins, underline=True)}",
248
+ bold=True,
249
+ )
250
+
251
+ # Set environment variables
252
+ self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
253
+ self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
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
+
267
+ def run_preflight(self):
268
+ if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
269
+ click.secho("Preflight check failed!", fg="red")
270
+ sys.exit(1)
271
+
272
+ def add_gunicorn(self):
273
+ plain_db_installed = find_spec("plain.models") is not None
274
+
275
+ # Watch .env files for reload
276
+ extra_watch_files = []
277
+ for f in os.listdir(APP_PATH.parent):
278
+ if f.startswith(".env"):
279
+ extra_watch_files.append(f)
280
+
281
+ reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
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)
303
+
304
+ if plain_db_installed:
305
+ runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
306
+ else:
307
+ runserver_cmd = gunicorn
308
+
309
+ if "WEB_CONCURRENCY" not in self.plain_env:
310
+ # Default to two workers to prevent lockups
311
+ self.plain_env["WEB_CONCURRENCY"] = "2"
312
+
313
+ self.manager.add_process("plain", runserver_cmd, env=self.plain_env)
314
+
315
+ def add_tailwind(self):
316
+ if not plainpackage_installed("tailwind"):
317
+ return
318
+
319
+ self.manager.add_process("tailwind", "plain tailwind compile --watch")
320
+
321
+ def add_pyproject_run(self):
322
+ if not has_pyproject_toml(APP_PATH.parent):
323
+ return
324
+
325
+ with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
326
+ pyproject = tomllib.load(f)
327
+
328
+ run_commands = (
329
+ pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
330
+ )
331
+ for name, data in run_commands.items():
332
+ env = {
333
+ **self.custom_process_env,
334
+ **data.get("env", {}),
335
+ }
336
+ self.manager.add_process(name, data["cmd"], env=env)
337
+
338
+ def add_services(self):
339
+ services = Services.get_services(APP_PATH.parent)
340
+ for name, data in services.items():
341
+ env = {
342
+ **os.environ,
343
+ "PYTHONUNBUFFERED": "true",
344
+ **data.get("env", {}),
345
+ }
346
+ self.manager.add_process(name, data["cmd"], env=env)
347
+
348
+
349
+ cli.add_command(db_cli)
@@ -4,17 +4,13 @@ import sys
4
4
  from importlib.util import find_spec
5
5
  from pathlib import Path
6
6
 
7
+ import click
8
+ import tomllib
9
+
7
10
  from plain.cli.print import print_event
8
11
 
9
12
  from ..services import Services
10
13
 
11
- try:
12
- import tomllib
13
- except ModuleNotFoundError:
14
- import tomli as tomllib
15
-
16
- import click
17
-
18
14
 
19
15
  def install_git_hook():
20
16
  hook_path = os.path.join(".git", "hooks", "pre-commit")
@@ -5,6 +5,7 @@ from importlib.util import find_spec
5
5
  from pathlib import Path
6
6
 
7
7
  import click
8
+ import tomllib
8
9
  from honcho.manager import Manager as HonchoManager
9
10
 
10
11
  from plain.runtime import APP_PATH
@@ -12,11 +13,6 @@ from plain.runtime import APP_PATH
12
13
  from .pid import Pid
13
14
  from .utils import has_pyproject_toml
14
15
 
15
- try:
16
- import tomllib
17
- except ModuleNotFoundError:
18
- import tomli as tomllib
19
-
20
16
 
21
17
  class Services:
22
18
  @staticmethod
@@ -5,7 +5,7 @@ packages = [
5
5
  { include = "plain" },
6
6
  ]
7
7
 
8
- version = "0.5.0"
8
+ version = "0.6.0"
9
9
  description = "Local development tools for Plain."
10
10
  authors = ["Dave Gaeddert <dave.gaeddert@dropseed.dev>"]
11
11
  license = "BSD-3-Clause"
@@ -32,8 +32,6 @@ debugpy = "^1.6.3"
32
32
  # For local runserver
33
33
  gunicorn = ">20"
34
34
 
35
- tomli = {version = "^2.0.1", python = "<3.11"}
36
-
37
35
  # db
38
36
  requests = ">=2.0.0"
39
37
  psycopg = {version = "^3.2.2", extras = ["binary"]}
@@ -1,164 +0,0 @@
1
- import json
2
- import os
3
- import subprocess
4
- import sys
5
- from importlib.util import find_spec
6
- from pathlib import Path
7
-
8
- import click
9
- from honcho.manager import Manager as HonchoManager
10
-
11
- from plain.runtime import APP_PATH
12
-
13
- from .db import cli as db_cli
14
- from .pid import Pid
15
- from .services import Services
16
- from .utils import has_pyproject_toml, plainpackage_installed
17
-
18
- try:
19
- import tomllib
20
- except ModuleNotFoundError:
21
- import tomli as tomllib
22
-
23
-
24
- @click.group(invoke_without_command=True)
25
- @click.pass_context
26
- @click.option(
27
- "--port",
28
- "-p",
29
- default=8000,
30
- type=int,
31
- help="Port to run the web server on",
32
- envvar="PORT",
33
- )
34
- def cli(ctx, port):
35
- """Start local development"""
36
-
37
- if ctx.invoked_subcommand:
38
- return
39
-
40
- returncode = Dev(port=port).run()
41
- if returncode:
42
- sys.exit(returncode)
43
-
44
-
45
- @cli.command()
46
- def services():
47
- """Start additional services defined in pyproject.toml"""
48
- Services().run()
49
-
50
-
51
- class Dev:
52
- def __init__(self, *, port):
53
- self.manager = HonchoManager()
54
- self.port = port
55
- self.plain_env = {
56
- **os.environ,
57
- "PYTHONUNBUFFERED": "true",
58
- }
59
- self.custom_process_env = {
60
- **self.plain_env,
61
- "PORT": str(self.port),
62
- "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
63
- }
64
-
65
- def run(self):
66
- pid = Pid()
67
- pid.write()
68
-
69
- try:
70
- self.add_csrf_trusted_origins()
71
- self.run_preflight()
72
- self.add_gunicorn()
73
- self.add_tailwind()
74
- self.add_pyproject_run()
75
- self.add_services()
76
-
77
- self.manager.loop()
78
-
79
- return self.manager.returncode
80
- finally:
81
- pid.rm()
82
-
83
- def add_csrf_trusted_origins(self):
84
- if "PLAIN_CSRF_TRUSTED_ORIGINS" in os.environ:
85
- return
86
-
87
- csrf_trusted_origins = json.dumps(
88
- [f"http://localhost:{self.port}", f"http://127.0.0.1:{self.port}"]
89
- )
90
-
91
- click.secho(
92
- f"Automatically set PLAIN_CSRF_TRUSTED_ORIGINS={click.style(csrf_trusted_origins, underline=True)}",
93
- bold=True,
94
- )
95
-
96
- # Set BASE_URL for plain and custom processes
97
- self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
98
- self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
99
-
100
- def run_preflight(self):
101
- if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
102
- click.secho("Preflight check failed!", fg="red")
103
- sys.exit(1)
104
-
105
- def add_gunicorn(self):
106
- plain_db_installed = find_spec("plain.models") is not None
107
-
108
- # TODO not necessarily watching the right .env...
109
- # could return path from env.load?
110
- extra_watch_files = []
111
- for f in os.listdir(APP_PATH.parent):
112
- if f.startswith(".env"):
113
- # Will include some extra, but good enough for now
114
- extra_watch_files.append(f)
115
-
116
- reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
117
- gunicorn = f"gunicorn --bind 127.0.0.1:{self.port} --reload plain.wsgi:app --timeout 60 --access-logfile - --error-logfile - {reload_extra} --access-logformat '\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'"
118
-
119
- if plain_db_installed:
120
- runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
121
- else:
122
- runserver_cmd = gunicorn
123
-
124
- if "WEB_CONCURRENCY" not in self.plain_env:
125
- # Default to two workers so request log etc are less
126
- # likely to get locked up
127
- self.plain_env["WEB_CONCURRENCY"] = "2"
128
-
129
- self.manager.add_process("plain", runserver_cmd, env=self.plain_env)
130
-
131
- def add_tailwind(self):
132
- if not plainpackage_installed("tailwind"):
133
- return
134
-
135
- self.manager.add_process("tailwind", "plain tailwind compile --watch")
136
-
137
- def add_pyproject_run(self):
138
- if not has_pyproject_toml(APP_PATH.parent):
139
- return
140
-
141
- with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
142
- pyproject = tomllib.load(f)
143
-
144
- for name, data in (
145
- pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
146
- ).items():
147
- env = {
148
- **self.custom_process_env,
149
- **data.get("env", {}),
150
- }
151
- self.manager.add_process(name, data["cmd"], env=env)
152
-
153
- def add_services(self):
154
- services = Services.get_services(APP_PATH.parent)
155
- for name, data in services.items():
156
- env = {
157
- **os.environ,
158
- "PYTHONUNBUFFERED": "true",
159
- **data.get("env", {}),
160
- }
161
- self.manager.add_process(name, data["cmd"], env=env)
162
-
163
-
164
- cli.add_command(db_cli)
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