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.
@@ -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