django-dev-helpers 0.1.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.
- django_dev_helpers/__init__.py +1 -0
- django_dev_helpers/apps.py +61 -0
- django_dev_helpers/browser.py +90 -0
- django_dev_helpers/conf.py +241 -0
- django_dev_helpers/dotfiles.py +330 -0
- django_dev_helpers/gitignore.py +82 -0
- django_dev_helpers/management/__init__.py +0 -0
- django_dev_helpers/management/commands/__init__.py +0 -0
- django_dev_helpers/management/commands/dev_helpers_check_gitignore.py +30 -0
- django_dev_helpers/management/commands/dev_helpers_doctor.py +458 -0
- django_dev_helpers/management/commands/dev_helpers_print_help.py +19 -0
- django_dev_helpers/management/commands/run_site.py +100 -0
- django_dev_helpers/project_root.py +18 -0
- django_dev_helpers/prompt.py +127 -0
- django_dev_helpers/py.typed +0 -0
- django_dev_helpers/safety.py +81 -0
- django_dev_helpers/sidecar.py +87 -0
- django_dev_helpers/tokens.py +18 -0
- django_dev_helpers/urls.py +14 -0
- django_dev_helpers/views.py +48 -0
- django_dev_helpers-0.1.0.dist-info/METADATA +194 -0
- django_dev_helpers-0.1.0.dist-info/RECORD +24 -0
- django_dev_helpers-0.1.0.dist-info/WHEEL +4 -0
- django_dev_helpers-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from django.apps import AppConfig
|
|
5
|
+
|
|
6
|
+
# Sentinel env vars that survive Django runserver's autoreload (which uses
|
|
7
|
+
# os.execv on the same process — env is preserved, module state is not).
|
|
8
|
+
# Set after each one-shot side effect to avoid retriggering on reload.
|
|
9
|
+
_BROWSER_SENTINEL = "DEV_HELPERS_BROWSER_OPENED"
|
|
10
|
+
_HELP_SENTINEL = "DEV_HELPERS_HELP_PRINTED"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DjangoDevHelpersConfig(AppConfig):
|
|
14
|
+
name = "django_dev_helpers"
|
|
15
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
16
|
+
|
|
17
|
+
def ready(self):
|
|
18
|
+
from .conf import get_config
|
|
19
|
+
|
|
20
|
+
cfg = get_config()
|
|
21
|
+
if not cfg.is_active():
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
from .safety import _is_serving, assert_safe_to_activate, emit_sanity_warnings
|
|
25
|
+
|
|
26
|
+
assert_safe_to_activate(cfg)
|
|
27
|
+
emit_sanity_warnings(cfg)
|
|
28
|
+
|
|
29
|
+
if cfg.autologin.enabled:
|
|
30
|
+
from .tokens import init_token
|
|
31
|
+
|
|
32
|
+
init_token()
|
|
33
|
+
|
|
34
|
+
is_autoreload_parent = os.environ.get("RUN_MAIN") != "true"
|
|
35
|
+
if not _is_serving(cfg):
|
|
36
|
+
return
|
|
37
|
+
if is_autoreload_parent and "runserver" in sys.argv:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if cfg.dotfiles.enabled:
|
|
41
|
+
from .dotfiles import register_cleanup, write_all_dotfiles
|
|
42
|
+
|
|
43
|
+
write_all_dotfiles(cfg)
|
|
44
|
+
register_cleanup(cfg)
|
|
45
|
+
|
|
46
|
+
if cfg.gitignore.mode != "off":
|
|
47
|
+
from .gitignore import check_gitignore
|
|
48
|
+
|
|
49
|
+
check_gitignore(cfg)
|
|
50
|
+
|
|
51
|
+
if cfg.agent_help.auto_print and os.environ.get(_HELP_SENTINEL) != "1":
|
|
52
|
+
from .prompt import register_first_request_print
|
|
53
|
+
|
|
54
|
+
register_first_request_print(cfg)
|
|
55
|
+
os.environ[_HELP_SENTINEL] = "1"
|
|
56
|
+
|
|
57
|
+
if cfg.browser_open.enabled and os.environ.get(_BROWSER_SENTINEL) != "1":
|
|
58
|
+
from .browser import spawn_self_probe_thread
|
|
59
|
+
|
|
60
|
+
spawn_self_probe_thread(cfg)
|
|
61
|
+
os.environ[_BROWSER_SENTINEL] = "1"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
import webbrowser
|
|
11
|
+
|
|
12
|
+
from django_dev_helpers import dotfiles, tokens
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_headless() -> bool:
|
|
18
|
+
"""Heuristic: don't try to open a browser in obviously-headless contexts.
|
|
19
|
+
|
|
20
|
+
Linux without DISPLAY/WAYLAND_DISPLAY is the common case. macOS and
|
|
21
|
+
Windows have system-default browser launchers, so we don't filter
|
|
22
|
+
them here.
|
|
23
|
+
"""
|
|
24
|
+
if sys.platform.startswith("linux"):
|
|
25
|
+
if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
|
|
26
|
+
return True
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_autologin_url(cfg, host: str, port: str) -> str:
|
|
31
|
+
token = tokens.current_token()
|
|
32
|
+
return f"http://{host}:{port}/{cfg.autologin.url_path}?token={token}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def open_browser(cfg) -> None:
|
|
36
|
+
host = dotfiles.discover_bind_host()
|
|
37
|
+
if host in ("0.0.0.0", ""):
|
|
38
|
+
host = "localhost"
|
|
39
|
+
port = dotfiles.discover_port() or "8000"
|
|
40
|
+
|
|
41
|
+
if cfg.browser_open.url_path is None:
|
|
42
|
+
url = _build_autologin_url(cfg, host, port) if cfg.autologin.enabled else f"http://{host}:{port}/"
|
|
43
|
+
else:
|
|
44
|
+
url = f"http://{host}:{port}{cfg.browser_open.url_path}"
|
|
45
|
+
|
|
46
|
+
logger.info("django-dev-helpers: opening browser at %s", url)
|
|
47
|
+
webbrowser.open(url)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wait_for_http(cfg) -> None:
|
|
51
|
+
host = dotfiles.discover_bind_host()
|
|
52
|
+
if host in ("0.0.0.0", ""):
|
|
53
|
+
host = "localhost"
|
|
54
|
+
port = dotfiles.discover_port() or "8000"
|
|
55
|
+
probe_path = cfg.browser_open.probe_path or "/admin/login/"
|
|
56
|
+
url = f"http://{host}:{port}{probe_path}"
|
|
57
|
+
timeout = cfg.browser_open.probe_timeout_seconds or 30.0
|
|
58
|
+
|
|
59
|
+
deadline = time.monotonic() + timeout
|
|
60
|
+
while time.monotonic() < deadline:
|
|
61
|
+
try:
|
|
62
|
+
response = urllib.request.urlopen(url, timeout=2)
|
|
63
|
+
if response.status < 500:
|
|
64
|
+
open_browser(cfg)
|
|
65
|
+
return
|
|
66
|
+
except urllib.error.HTTPError as exc:
|
|
67
|
+
if exc.code < 500:
|
|
68
|
+
open_browser(cfg)
|
|
69
|
+
return
|
|
70
|
+
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError):
|
|
71
|
+
pass
|
|
72
|
+
time.sleep(0.5)
|
|
73
|
+
|
|
74
|
+
logger.warning(
|
|
75
|
+
"django-dev-helpers: self-probe timed out after %ss, not opening browser",
|
|
76
|
+
timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def spawn_self_probe_thread(cfg) -> None:
|
|
81
|
+
if is_headless():
|
|
82
|
+
logger.info("django-dev-helpers: headless environment detected, skipping browser open")
|
|
83
|
+
return
|
|
84
|
+
t = threading.Thread(
|
|
85
|
+
target=wait_for_http,
|
|
86
|
+
args=(cfg,),
|
|
87
|
+
name="dev-helpers-self-probe",
|
|
88
|
+
daemon=True,
|
|
89
|
+
)
|
|
90
|
+
t.start()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import types
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.conf import settings
|
|
9
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
10
|
+
from django.http import Http404
|
|
11
|
+
from django.http.request import split_domain_port
|
|
12
|
+
|
|
13
|
+
_AUTOLOGIN_DEFAULTS: dict[str, Any] = {
|
|
14
|
+
"enabled": True,
|
|
15
|
+
"user_lookup_field": "username",
|
|
16
|
+
"user_lookup_value": "admin",
|
|
17
|
+
"url_path": "__autologin__/",
|
|
18
|
+
"redirect_to": "/",
|
|
19
|
+
"auth_backend": "django.contrib.auth.backends.ModelBackend",
|
|
20
|
+
"flash_message": "",
|
|
21
|
+
"extra_cookies": [],
|
|
22
|
+
"allowed_hosts": [],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_DOTFILES_DEFAULTS: dict[str, Any] = {
|
|
26
|
+
"enabled": True,
|
|
27
|
+
"directory": None,
|
|
28
|
+
"token_filename": ".dev_helpers_token",
|
|
29
|
+
"port_filename": ".dev_helpers_port",
|
|
30
|
+
"pg_port_filename": ".dev_helpers_pg_port",
|
|
31
|
+
"pg_host_filename": ".dev_helpers_pg_host",
|
|
32
|
+
"redis_port_filename": ".dev_helpers_redis_port",
|
|
33
|
+
"redis_host_filename": ".dev_helpers_redis_host",
|
|
34
|
+
"token_chmod": 0o600,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_AGENT_HELP_DEFAULTS: dict[str, Any] = {
|
|
38
|
+
"auto_print": True,
|
|
39
|
+
"template": None,
|
|
40
|
+
"display_host": None,
|
|
41
|
+
"show_db_credentials": True,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_BROWSER_OPEN_DEFAULTS: dict[str, Any] = {
|
|
45
|
+
"enabled": True,
|
|
46
|
+
"url_path": None,
|
|
47
|
+
"probe_path": "/admin/login/",
|
|
48
|
+
"probe_timeout_seconds": 30.0,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_GITIGNORE_DEFAULTS: dict[str, Any] = {
|
|
52
|
+
"mode": "warn",
|
|
53
|
+
"path": None,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_LOOKUP_DEFAULTS: dict[str, Any] = {
|
|
57
|
+
"source": "auto",
|
|
58
|
+
"callable": None,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_SAFETY_DEFAULTS: dict[str, Any] = {
|
|
62
|
+
"non_serving_commands": [],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_CLAUDE_MD_DEFAULTS: dict[str, Any] = {
|
|
66
|
+
"mode": "warn",
|
|
67
|
+
"files": ["CLAUDE.md", "AGENTS.md"],
|
|
68
|
+
"marker": "<!-- django-dev-helpers:agent-help -->",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_DEFAULTS: dict[str, Any] = {
|
|
72
|
+
"enabled": None,
|
|
73
|
+
"autologin": _AUTOLOGIN_DEFAULTS,
|
|
74
|
+
"dotfiles": _DOTFILES_DEFAULTS,
|
|
75
|
+
"agent_help": _AGENT_HELP_DEFAULTS,
|
|
76
|
+
"browser_open": _BROWSER_OPEN_DEFAULTS,
|
|
77
|
+
"gitignore": _GITIGNORE_DEFAULTS,
|
|
78
|
+
"lookup": _LOOKUP_DEFAULTS,
|
|
79
|
+
"safety": _SAFETY_DEFAULTS,
|
|
80
|
+
"claude_md": _CLAUDE_MD_DEFAULTS,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_VALID_GITIGNORE_MODES = {"warn", "auto-add", "error", "off"}
|
|
84
|
+
_VALID_LOOKUP_SOURCES = {"auto", "env", "settings", "sidecar"}
|
|
85
|
+
_VALID_CLAUDE_MD_MODES = {"warn", "off"}
|
|
86
|
+
_KNOWN_TOP_LEVEL_KEYS = set(_DEFAULTS.keys())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _merge(defaults: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
+
merged = dict(defaults)
|
|
91
|
+
for key, value in overrides.items():
|
|
92
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
93
|
+
merged[key] = _merge(merged[key], value)
|
|
94
|
+
else:
|
|
95
|
+
merged[key] = value
|
|
96
|
+
return merged
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dict_to_namespace(d: dict[str, Any]) -> types.SimpleNamespace:
|
|
100
|
+
ns = types.SimpleNamespace()
|
|
101
|
+
for key, value in d.items():
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
setattr(ns, key, _dict_to_namespace(value))
|
|
104
|
+
else:
|
|
105
|
+
setattr(ns, key, value)
|
|
106
|
+
return ns
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate(merged: dict[str, Any], raw: dict[str, Any]) -> None:
|
|
110
|
+
unknown = set(raw.keys()) - _KNOWN_TOP_LEVEL_KEYS
|
|
111
|
+
if unknown:
|
|
112
|
+
raise ImproperlyConfigured(
|
|
113
|
+
f"DJANGO_DEV_HELPERS: unknown top-level keys: {sorted(unknown)}. "
|
|
114
|
+
f"Known keys: {sorted(_KNOWN_TOP_LEVEL_KEYS)}."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
gi_mode = merged["gitignore"]["mode"]
|
|
118
|
+
if gi_mode not in _VALID_GITIGNORE_MODES:
|
|
119
|
+
raise ImproperlyConfigured(
|
|
120
|
+
f"DJANGO_DEV_HELPERS['gitignore']['mode']={gi_mode!r} is not valid. "
|
|
121
|
+
f"Choose one of: {sorted(_VALID_GITIGNORE_MODES)}."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
lk_source = merged["lookup"]["source"]
|
|
125
|
+
if lk_source not in _VALID_LOOKUP_SOURCES:
|
|
126
|
+
raise ImproperlyConfigured(
|
|
127
|
+
f"DJANGO_DEV_HELPERS['lookup']['source']={lk_source!r} is not valid. "
|
|
128
|
+
f"Choose one of: {sorted(_VALID_LOOKUP_SOURCES)}."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
extra_cookies = merged["autologin"]["extra_cookies"]
|
|
132
|
+
if not isinstance(extra_cookies, (list, tuple)):
|
|
133
|
+
raise ImproperlyConfigured("DJANGO_DEV_HELPERS['autologin']['extra_cookies'] must be a list of dicts.")
|
|
134
|
+
for i, cookie in enumerate(extra_cookies):
|
|
135
|
+
if not isinstance(cookie, dict):
|
|
136
|
+
raise ImproperlyConfigured(f"DJANGO_DEV_HELPERS['autologin']['extra_cookies'][{i}] must be a dict.")
|
|
137
|
+
|
|
138
|
+
allowed = merged["autologin"]["allowed_hosts"]
|
|
139
|
+
if not isinstance(allowed, (list, tuple)):
|
|
140
|
+
raise ImproperlyConfigured("DJANGO_DEV_HELPERS['autologin']['allowed_hosts'] must be a list of strings.")
|
|
141
|
+
|
|
142
|
+
non_serving = merged["safety"]["non_serving_commands"]
|
|
143
|
+
if not isinstance(non_serving, (list, tuple, set, frozenset)):
|
|
144
|
+
raise ImproperlyConfigured(
|
|
145
|
+
"DJANGO_DEV_HELPERS['safety']['non_serving_commands'] must be a list/set of strings."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
callable_spec = merged["lookup"]["callable"]
|
|
149
|
+
if callable_spec is not None and not isinstance(callable_spec, str):
|
|
150
|
+
raise ImproperlyConfigured(
|
|
151
|
+
"DJANGO_DEV_HELPERS['lookup']['callable'] must be a 'module.path:attr' string or None."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
claude_md_mode = merged["claude_md"]["mode"]
|
|
155
|
+
if claude_md_mode not in _VALID_CLAUDE_MD_MODES:
|
|
156
|
+
raise ImproperlyConfigured(
|
|
157
|
+
f"DJANGO_DEV_HELPERS['claude_md']['mode']={claude_md_mode!r} is not valid. "
|
|
158
|
+
f"Choose one of: {sorted(_VALID_CLAUDE_MD_MODES)}."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
claude_md_files = merged["claude_md"]["files"]
|
|
162
|
+
if not isinstance(claude_md_files, (list, tuple)) or not all(isinstance(f, str) for f in claude_md_files):
|
|
163
|
+
raise ImproperlyConfigured("DJANGO_DEV_HELPERS['claude_md']['files'] must be a list of strings.")
|
|
164
|
+
|
|
165
|
+
claude_md_marker = merged["claude_md"]["marker"]
|
|
166
|
+
if not isinstance(claude_md_marker, str) or not claude_md_marker:
|
|
167
|
+
raise ImproperlyConfigured("DJANGO_DEV_HELPERS['claude_md']['marker'] must be a non-empty string.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _apply_env_overrides(merged: dict[str, Any], raw: dict[str, Any]) -> None:
|
|
171
|
+
"""Pull a small set of orchestrator-set env vars into merged config.
|
|
172
|
+
|
|
173
|
+
Currently only DEV_HELPERS_AUTOLOGIN_USERNAME, applied as the autologin
|
|
174
|
+
user lookup value when the user has not explicitly set it.
|
|
175
|
+
"""
|
|
176
|
+
raw_autologin = raw.get("autologin") or {}
|
|
177
|
+
if "user_lookup_value" not in raw_autologin:
|
|
178
|
+
env_user = os.environ.get("DEV_HELPERS_AUTOLOGIN_USERNAME")
|
|
179
|
+
if env_user:
|
|
180
|
+
merged["autologin"]["user_lookup_value"] = env_user
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class DevHelpersConfig:
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
raw: dict[str, Any] = getattr(settings, "DJANGO_DEV_HELPERS", {}) or {}
|
|
186
|
+
if not isinstance(raw, dict):
|
|
187
|
+
raise ImproperlyConfigured("DJANGO_DEV_HELPERS must be a dict.")
|
|
188
|
+
merged = _merge(_DEFAULTS, raw)
|
|
189
|
+
_validate(merged, raw)
|
|
190
|
+
_apply_env_overrides(merged, raw)
|
|
191
|
+
self.enabled = merged["enabled"]
|
|
192
|
+
self.autologin = _dict_to_namespace(merged["autologin"])
|
|
193
|
+
self.dotfiles = _dict_to_namespace(merged["dotfiles"])
|
|
194
|
+
self.agent_help = _dict_to_namespace(merged["agent_help"])
|
|
195
|
+
self.browser_open = _dict_to_namespace(merged["browser_open"])
|
|
196
|
+
self.gitignore = _dict_to_namespace(merged["gitignore"])
|
|
197
|
+
self.lookup = _dict_to_namespace(merged["lookup"])
|
|
198
|
+
self.safety = _dict_to_namespace(merged["safety"])
|
|
199
|
+
self.claude_md = _dict_to_namespace(merged["claude_md"])
|
|
200
|
+
|
|
201
|
+
def is_active(self) -> bool:
|
|
202
|
+
if self.enabled is False:
|
|
203
|
+
return False
|
|
204
|
+
if self.enabled is True:
|
|
205
|
+
return True
|
|
206
|
+
return os.environ.get("DJANGO_DEV_HELPERS_ENABLED") == "1"
|
|
207
|
+
|
|
208
|
+
def refuse_if_inactive(self) -> None:
|
|
209
|
+
if not self.is_active():
|
|
210
|
+
raise Http404()
|
|
211
|
+
|
|
212
|
+
def refuse_if_unsafe_host(self, request: Any) -> None:
|
|
213
|
+
hostname, _ = split_domain_port(request.get_host())
|
|
214
|
+
if hostname is None:
|
|
215
|
+
raise Http404()
|
|
216
|
+
hostname = hostname.lower()
|
|
217
|
+
extra_allow = self.autologin.allowed_hosts or []
|
|
218
|
+
if any(fnmatch.fnmatchcase(hostname, pat.lower()) for pat in extra_allow):
|
|
219
|
+
return
|
|
220
|
+
default_allow = {"localhost", "127.0.0.1", "[::1]"}
|
|
221
|
+
if hostname in default_allow:
|
|
222
|
+
loopback_addrs = {"127.0.0.1", "::1", "::ffff:127.0.0.1"}
|
|
223
|
+
remote_addr = (request.META.get("REMOTE_ADDR") or "").strip()
|
|
224
|
+
if remote_addr in loopback_addrs:
|
|
225
|
+
return
|
|
226
|
+
raise Http404()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
_config: DevHelpersConfig | None = None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_config() -> DevHelpersConfig:
|
|
233
|
+
global _config
|
|
234
|
+
if _config is None:
|
|
235
|
+
_config = DevHelpersConfig()
|
|
236
|
+
return _config
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def reset_config() -> None:
|
|
240
|
+
global _config
|
|
241
|
+
_config = None
|