setupdoctor 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zamudiog1221-ops
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: setupdoctor
3
+ Version: 0.1.0
4
+ Summary: Read a repo's setup requirements from its code and check them against your machine.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # setupdoctor
12
+
13
+ `brew doctor` for any repo. It reads what a project needs from its code and config files, checks your machine, and prints the fix for each gap. It never reads the README.
14
+
15
+ ```
16
+ $ setupdoctor
17
+ setupdoctor: 5 checks
18
+
19
+ ✓ Node >=12.0.0 required (package.json engines), found 26.10.0
20
+ ✗ yarn is this repo's package manager but is not installed
21
+ fix: npm install -g yarn
22
+ ✗ Node dependencies not installed (no node_modules folder)
23
+ fix: yarn install
24
+ ✗ No .env file; .env.example defines 12 variable(s)
25
+ fix: cp .env.example .env, then fill in the values
26
+ ✗ MongoDB (docker-compose.yml) is not reachable on localhost:27017
27
+ fix: docker compose up -d
28
+
29
+ 4 failed, 0 warning(s), 1 passed
30
+ ```
31
+
32
+ That is real output from a fresh clone of `hagopj13/node-express-boilerplate`.
33
+
34
+ ## Install
35
+
36
+ Not on PyPI yet. From a copy of this folder:
37
+
38
+ ```
39
+ pip install .
40
+ ```
41
+
42
+ It is one file with no dependencies, so `python setupdoctor.py` also works.
43
+
44
+ ## Use
45
+
46
+ ```
47
+ setupdoctor # check the current folder
48
+ setupdoctor path/to/repo
49
+ setupdoctor --ci # for pipelines, see below
50
+ ```
51
+
52
+ The exit code is 1 if any check failed. A check only reports; it never changes anything on your machine.
53
+
54
+ ## Run it automatically after every clone
55
+
56
+ ```
57
+ setupdoctor --install-hook
58
+ ```
59
+
60
+ Do this once. From then on, every `git clone` prints the checklist for the new repo as soon as the clone finishes. It stays silent on repos with nothing to check and when you switch branches.
61
+
62
+ This is the one command that changes your machine: it adds a `post-checkout` hook to git's template folder and points `init.templateDir` in your global git config at it. Repos you cloned earlier are not affected. Undo it with `setupdoctor --uninstall-hook`.
63
+
64
+ ## What it checks
65
+
66
+ | Check | Read from |
67
+ |---|---|
68
+ | Node version | `.nvmrc`, `.node-version`, `package.json` engines |
69
+ | Python version | `.python-version`, `runtime.txt`, `pyproject.toml` |
70
+ | Package manager is installed | lockfile or `packageManager` (pnpm, yarn, bun, uv, poetry, pipenv) |
71
+ | Dependencies are installed | `node_modules`; `.venv` or `venv` |
72
+ | Env vars are set | `.env.example`, plus reads in the source that break when the variable is unset |
73
+ | Env vars are documented | every env var the source reads, compared with `.env.example` |
74
+ | Services are running | Postgres, Redis, MongoDB and MySQL ports in the compose file |
75
+
76
+ It only fails a check when the repo declares the requirement. For environment variables that means one of:
77
+
78
+ - the variable is in `.env.example` and you have no `.env`
79
+ - Python reads it as `os.environ["X"]` outside any `if` or `try`
80
+ - TypeScript reads it as `process.env.X!`
81
+
82
+ Softer reads such as `os.getenv("X")` or a bare `process.env.X` are treated as settings the app can run without. On real projects they nearly always are, and reporting them buried the real problems under dozens of false alarms. Tests, docs, examples and installed packages are not scanned.
83
+
84
+ ## CI mode
85
+
86
+ `setupdoctor --ci` ignores the machine and fails if the code reads an environment variable that is not in `.env.example`. Run it on every pull request and the example file cannot fall behind the code.
87
+
88
+ ## Limits
89
+
90
+ - Node and Python only.
91
+ - It checks the folder you point it at. In a monorepo, run it in each project folder.
92
+ - Env var names built at runtime, or read through a settings library (Joi, pydantic-settings, django-environ), are not seen by the scan. The `.env.example` check still covers them.
93
+ - A JavaScript project with no `.env.example` gets no env var checks, because nothing in plain JavaScript marks a variable as required.
94
+ - A database is only checked if the compose file publishes its port. One you are expected to install yourself is not detected.
95
+ - Compose files are read by pattern, so ports written as `${VAR:-5432}:5432` are missed.
96
+ - A missing Python virtualenv is a warning, because poetry and pipenv keep theirs outside the repo.
97
+ - Anything that lives outside the code, such as VPN access or an account someone has to create for you, cannot be detected.
98
+
99
+ ## Tests
100
+
101
+ ```
102
+ python test_setupdoctor.py
103
+ ```
@@ -0,0 +1,93 @@
1
+ # setupdoctor
2
+
3
+ `brew doctor` for any repo. It reads what a project needs from its code and config files, checks your machine, and prints the fix for each gap. It never reads the README.
4
+
5
+ ```
6
+ $ setupdoctor
7
+ setupdoctor: 5 checks
8
+
9
+ ✓ Node >=12.0.0 required (package.json engines), found 26.10.0
10
+ ✗ yarn is this repo's package manager but is not installed
11
+ fix: npm install -g yarn
12
+ ✗ Node dependencies not installed (no node_modules folder)
13
+ fix: yarn install
14
+ ✗ No .env file; .env.example defines 12 variable(s)
15
+ fix: cp .env.example .env, then fill in the values
16
+ ✗ MongoDB (docker-compose.yml) is not reachable on localhost:27017
17
+ fix: docker compose up -d
18
+
19
+ 4 failed, 0 warning(s), 1 passed
20
+ ```
21
+
22
+ That is real output from a fresh clone of `hagopj13/node-express-boilerplate`.
23
+
24
+ ## Install
25
+
26
+ Not on PyPI yet. From a copy of this folder:
27
+
28
+ ```
29
+ pip install .
30
+ ```
31
+
32
+ It is one file with no dependencies, so `python setupdoctor.py` also works.
33
+
34
+ ## Use
35
+
36
+ ```
37
+ setupdoctor # check the current folder
38
+ setupdoctor path/to/repo
39
+ setupdoctor --ci # for pipelines, see below
40
+ ```
41
+
42
+ The exit code is 1 if any check failed. A check only reports; it never changes anything on your machine.
43
+
44
+ ## Run it automatically after every clone
45
+
46
+ ```
47
+ setupdoctor --install-hook
48
+ ```
49
+
50
+ Do this once. From then on, every `git clone` prints the checklist for the new repo as soon as the clone finishes. It stays silent on repos with nothing to check and when you switch branches.
51
+
52
+ This is the one command that changes your machine: it adds a `post-checkout` hook to git's template folder and points `init.templateDir` in your global git config at it. Repos you cloned earlier are not affected. Undo it with `setupdoctor --uninstall-hook`.
53
+
54
+ ## What it checks
55
+
56
+ | Check | Read from |
57
+ |---|---|
58
+ | Node version | `.nvmrc`, `.node-version`, `package.json` engines |
59
+ | Python version | `.python-version`, `runtime.txt`, `pyproject.toml` |
60
+ | Package manager is installed | lockfile or `packageManager` (pnpm, yarn, bun, uv, poetry, pipenv) |
61
+ | Dependencies are installed | `node_modules`; `.venv` or `venv` |
62
+ | Env vars are set | `.env.example`, plus reads in the source that break when the variable is unset |
63
+ | Env vars are documented | every env var the source reads, compared with `.env.example` |
64
+ | Services are running | Postgres, Redis, MongoDB and MySQL ports in the compose file |
65
+
66
+ It only fails a check when the repo declares the requirement. For environment variables that means one of:
67
+
68
+ - the variable is in `.env.example` and you have no `.env`
69
+ - Python reads it as `os.environ["X"]` outside any `if` or `try`
70
+ - TypeScript reads it as `process.env.X!`
71
+
72
+ Softer reads such as `os.getenv("X")` or a bare `process.env.X` are treated as settings the app can run without. On real projects they nearly always are, and reporting them buried the real problems under dozens of false alarms. Tests, docs, examples and installed packages are not scanned.
73
+
74
+ ## CI mode
75
+
76
+ `setupdoctor --ci` ignores the machine and fails if the code reads an environment variable that is not in `.env.example`. Run it on every pull request and the example file cannot fall behind the code.
77
+
78
+ ## Limits
79
+
80
+ - Node and Python only.
81
+ - It checks the folder you point it at. In a monorepo, run it in each project folder.
82
+ - Env var names built at runtime, or read through a settings library (Joi, pydantic-settings, django-environ), are not seen by the scan. The `.env.example` check still covers them.
83
+ - A JavaScript project with no `.env.example` gets no env var checks, because nothing in plain JavaScript marks a variable as required.
84
+ - A database is only checked if the compose file publishes its port. One you are expected to install yourself is not detected.
85
+ - Compose files are read by pattern, so ports written as `${VAR:-5432}:5432` are missed.
86
+ - A missing Python virtualenv is a warning, because poetry and pipenv keep theirs outside the repo.
87
+ - Anything that lives outside the code, such as VPN access or an account someone has to create for you, cannot be detected.
88
+
89
+ ## Tests
90
+
91
+ ```
92
+ python test_setupdoctor.py
93
+ ```
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "setupdoctor"
7
+ version = "0.1.0"
8
+ description = "Read a repo's setup requirements from its code and check them against your machine."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+
13
+ [project.scripts]
14
+ setupdoctor = "setupdoctor:main"
15
+
16
+ [tool.setuptools]
17
+ py-modules = ["setupdoctor"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: setupdoctor
3
+ Version: 0.1.0
4
+ Summary: Read a repo's setup requirements from its code and check them against your machine.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # setupdoctor
12
+
13
+ `brew doctor` for any repo. It reads what a project needs from its code and config files, checks your machine, and prints the fix for each gap. It never reads the README.
14
+
15
+ ```
16
+ $ setupdoctor
17
+ setupdoctor: 5 checks
18
+
19
+ ✓ Node >=12.0.0 required (package.json engines), found 26.10.0
20
+ ✗ yarn is this repo's package manager but is not installed
21
+ fix: npm install -g yarn
22
+ ✗ Node dependencies not installed (no node_modules folder)
23
+ fix: yarn install
24
+ ✗ No .env file; .env.example defines 12 variable(s)
25
+ fix: cp .env.example .env, then fill in the values
26
+ ✗ MongoDB (docker-compose.yml) is not reachable on localhost:27017
27
+ fix: docker compose up -d
28
+
29
+ 4 failed, 0 warning(s), 1 passed
30
+ ```
31
+
32
+ That is real output from a fresh clone of `hagopj13/node-express-boilerplate`.
33
+
34
+ ## Install
35
+
36
+ Not on PyPI yet. From a copy of this folder:
37
+
38
+ ```
39
+ pip install .
40
+ ```
41
+
42
+ It is one file with no dependencies, so `python setupdoctor.py` also works.
43
+
44
+ ## Use
45
+
46
+ ```
47
+ setupdoctor # check the current folder
48
+ setupdoctor path/to/repo
49
+ setupdoctor --ci # for pipelines, see below
50
+ ```
51
+
52
+ The exit code is 1 if any check failed. A check only reports; it never changes anything on your machine.
53
+
54
+ ## Run it automatically after every clone
55
+
56
+ ```
57
+ setupdoctor --install-hook
58
+ ```
59
+
60
+ Do this once. From then on, every `git clone` prints the checklist for the new repo as soon as the clone finishes. It stays silent on repos with nothing to check and when you switch branches.
61
+
62
+ This is the one command that changes your machine: it adds a `post-checkout` hook to git's template folder and points `init.templateDir` in your global git config at it. Repos you cloned earlier are not affected. Undo it with `setupdoctor --uninstall-hook`.
63
+
64
+ ## What it checks
65
+
66
+ | Check | Read from |
67
+ |---|---|
68
+ | Node version | `.nvmrc`, `.node-version`, `package.json` engines |
69
+ | Python version | `.python-version`, `runtime.txt`, `pyproject.toml` |
70
+ | Package manager is installed | lockfile or `packageManager` (pnpm, yarn, bun, uv, poetry, pipenv) |
71
+ | Dependencies are installed | `node_modules`; `.venv` or `venv` |
72
+ | Env vars are set | `.env.example`, plus reads in the source that break when the variable is unset |
73
+ | Env vars are documented | every env var the source reads, compared with `.env.example` |
74
+ | Services are running | Postgres, Redis, MongoDB and MySQL ports in the compose file |
75
+
76
+ It only fails a check when the repo declares the requirement. For environment variables that means one of:
77
+
78
+ - the variable is in `.env.example` and you have no `.env`
79
+ - Python reads it as `os.environ["X"]` outside any `if` or `try`
80
+ - TypeScript reads it as `process.env.X!`
81
+
82
+ Softer reads such as `os.getenv("X")` or a bare `process.env.X` are treated as settings the app can run without. On real projects they nearly always are, and reporting them buried the real problems under dozens of false alarms. Tests, docs, examples and installed packages are not scanned.
83
+
84
+ ## CI mode
85
+
86
+ `setupdoctor --ci` ignores the machine and fails if the code reads an environment variable that is not in `.env.example`. Run it on every pull request and the example file cannot fall behind the code.
87
+
88
+ ## Limits
89
+
90
+ - Node and Python only.
91
+ - It checks the folder you point it at. In a monorepo, run it in each project folder.
92
+ - Env var names built at runtime, or read through a settings library (Joi, pydantic-settings, django-environ), are not seen by the scan. The `.env.example` check still covers them.
93
+ - A JavaScript project with no `.env.example` gets no env var checks, because nothing in plain JavaScript marks a variable as required.
94
+ - A database is only checked if the compose file publishes its port. One you are expected to install yourself is not detected.
95
+ - Compose files are read by pattern, so ports written as `${VAR:-5432}:5432` are missed.
96
+ - A missing Python virtualenv is a warning, because poetry and pipenv keep theirs outside the repo.
97
+ - Anything that lives outside the code, such as VPN access or an account someone has to create for you, cannot be detected.
98
+
99
+ ## Tests
100
+
101
+ ```
102
+ python test_setupdoctor.py
103
+ ```
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setupdoctor.py
5
+ setupdoctor.egg-info/PKG-INFO
6
+ setupdoctor.egg-info/SOURCES.txt
7
+ setupdoctor.egg-info/dependency_links.txt
8
+ setupdoctor.egg-info/entry_points.txt
9
+ setupdoctor.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ setupdoctor = setupdoctor:main
@@ -0,0 +1 @@
1
+ setupdoctor
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env python3
2
+ """setupdoctor: read a repo's setup requirements from its code and check them against this machine."""
3
+ import argparse
4
+ import ast
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ import warnings
13
+ from pathlib import Path
14
+
15
+ OK, FAIL, WARN = "ok", "fail", "warn"
16
+ MARK = {OK: ("✓", "32"), FAIL: ("✗", "31"), WARN: ("!", "33")}
17
+
18
+ SKIP_DIRS = {"node_modules", "venv", "env", "dist", "build", "out", "target", "vendor", "coverage",
19
+ "__pycache__", "site-packages", "test", "tests", "__tests__", "e2e", "docs", "examples"}
20
+ SKIP_FILE = re.compile(r"(^test_|^conftest\.|_test\.|\.test\.|\.spec\.|\.min\.|^(playwright|jest|vitest|cypress)\.config\.)")
21
+ JS_EXT = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
22
+
23
+ # Variables the shell, the runtime or the host platform sets; nobody puts these in .env.
24
+ AMBIENT = {"NODE_ENV", "PATH", "HOME", "USER", "PWD", "SHELL", "TERM", "LANG", "TZ", "EDITOR", "CI",
25
+ "TMPDIR", "TEMP", "TMP", "APPDATA", "LOCALAPPDATA", "USERPROFILE", "HOSTNAME", "HOST", "PORT",
26
+ "DEBUG", "PYTHONPATH", "VIRTUAL_ENV", "NO_COLOR", "FORCE_COLOR", "VERCEL", "NETLIFY",
27
+ "MODE", "DEV", "PROD", "SSR", "BASE_URL", "PROGRAMFILES", "SYSTEMROOT", "WINDIR", "COMSPEC", "OS"}
28
+ AMBIENT_PREFIX = ("npm_", "GITHUB_", "VERCEL_", "XDG_", "RUNNER_")
29
+
30
+ # A read is "hard" when the code breaks without the variable: an unconditional os.environ["X"], or
31
+ # TypeScript's process.env.X! assertion. It is "optional" when the code copes: a fallback, a ternary
32
+ # or && test, or an assignment. Everything else (os.getenv("X"), a bare process.env.X, any read inside
33
+ # an if or try) is treated as a setting the app can run without, because on real repos that is what
34
+ # it nearly always is.
35
+ JS_ENV = re.compile(r"""(?:process\.env|import\.meta\.env)(?:\.([A-Z][A-Z0-9_]*)|\[\s*['"](\w+)['"]\s*\])"""
36
+ r"""(!(?!=))?(\s*(?:\|\||\?|&&|=(?!=)))?""")
37
+ JS_DESTRUCT = re.compile(r"\{([^}]*)\}\s*=\s*process\.env\b(?!\s*[.\[])")
38
+
39
+ ENV_FILES = [".env", ".env.local", ".env.development", ".env.development.local"]
40
+ ENV_EXAMPLES = [".env.example", ".env.sample", ".env.template", ".env.dist"]
41
+
42
+ NODE_LOCKS = [("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"), ("bun.lockb", "bun"), ("bun.lock", "bun")]
43
+ PY_LOCKS = [("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile", "pipenv")]
44
+ INSTALL = {"pnpm": "npm install -g pnpm", "yarn": "npm install -g yarn", "bun": "npm install -g bun",
45
+ "uv": "pip install uv", "poetry": "pipx install poetry", "pipenv": "pip install pipenv"}
46
+
47
+ SERVICES = {5432: "Postgres", 6379: "Redis", 27017: "MongoDB", 3306: "MySQL"}
48
+ # ponytail: pattern match, not a YAML parser; misses ports written as ${VAR:-5432}:5432. Parse YAML if that bites.
49
+ COMPOSE_PORT = re.compile(r"""(?m)^\s*-\s*["']?(?:[\d.]+:)?(\d+):(\d+)(?:/tcp)?["']?\s*$""")
50
+
51
+
52
+ def read(path):
53
+ try:
54
+ return Path(path).read_text(encoding="utf-8", errors="replace")
55
+ except OSError:
56
+ return ""
57
+
58
+
59
+ def first_line(path):
60
+ return (read(path).strip().splitlines() or [""])[0].strip()
61
+
62
+
63
+ def version(text):
64
+ m = re.search(r"\d+(?:\.\d+)*", text or "")
65
+ return tuple(int(p) for p in m.group().split(".")) if m else None
66
+
67
+
68
+ def satisfies(found, spec, n):
69
+ """True if version tuple `found` meets `spec`. A bare version pins its first n parts."""
70
+ found = tuple(found) + (0, 0)
71
+
72
+ def part_ok(part):
73
+ req = version(part)
74
+ if not req:
75
+ return True
76
+ op = re.match(r"[<>=^~!]*", part).group()
77
+ if op in (">=", ">"):
78
+ return found >= req
79
+ if op == "<":
80
+ return found[:len(req)] < req
81
+ if op == "<=":
82
+ return found[:len(req)] <= req
83
+ if op == "!=":
84
+ return True
85
+ if op in ("^", "~", "~="): # ponytail: same major and not older; tighter ~ rules not modelled
86
+ return found[0] == req[0] and found >= req
87
+ k = min(n, len(req))
88
+ return found[:k] == req[:k]
89
+
90
+ alts = [re.sub(r"([<>=^~!])\s+", r"\1", a) for a in spec.split("||")]
91
+ return any(all(part_ok(p) for p in re.split(r"[,\s]+", a) if p) for a in alts if version(a))
92
+
93
+
94
+ def run_version(*cmds):
95
+ """Version tuple of the first of `cmds` found on PATH, or None."""
96
+ for cmd in cmds:
97
+ exe = shutil.which(cmd)
98
+ if not exe:
99
+ continue
100
+ try:
101
+ out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=10)
102
+ except (OSError, subprocess.SubprocessError):
103
+ continue
104
+ found = version(out.stdout + out.stderr)
105
+ if found:
106
+ return found
107
+ return None
108
+
109
+
110
+ def on_path(cmd):
111
+ return shutil.which(cmd) is not None
112
+
113
+
114
+ def port_open(port):
115
+ try:
116
+ socket.create_connection(("localhost", port), timeout=0.5).close()
117
+ return True
118
+ except OSError:
119
+ return False
120
+
121
+
122
+ def js_env_uses(text):
123
+ """Yield (name, line number, optional, hard) for each env var a JS/TS file reads."""
124
+ for n, line in enumerate(text.splitlines(), 1):
125
+ # long lines are minified or generated code
126
+ if "env" not in line or len(line) > 2000 or line.lstrip().startswith(("//", "*")):
127
+ continue
128
+ for m in JS_ENV.finditer(line):
129
+ yield m.group(1) or m.group(2), n, bool(m.group(4)), bool(m.group(3))
130
+ for m in JS_DESTRUCT.finditer(line):
131
+ for item in m.group(1).split(","):
132
+ name = re.match(r"\s*(\w+)", item)
133
+ if name:
134
+ yield name.group(1), n, "=" in item, False
135
+
136
+
137
+ def py_env_uses(text):
138
+ """Return (name, line number, optional, hard) for each env var a Python file reads."""
139
+ try:
140
+ with warnings.catch_warnings():
141
+ warnings.simplefilter("ignore")
142
+ tree = ast.parse(text)
143
+ except (SyntaxError, ValueError, RecursionError):
144
+ return [] # a template, or syntax this Python can't read: nothing can be said safely
145
+
146
+ def is_environ(node):
147
+ return (isinstance(node, ast.Attribute) and node.attr == "environ") or \
148
+ (isinstance(node, ast.Name) and node.id == "environ")
149
+
150
+ def text_of(node):
151
+ return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
152
+
153
+ uses, stack = [], [(tree, False, False)]
154
+ while stack:
155
+ node, guarded, in_or = stack.pop()
156
+ if isinstance(node, ast.Subscript) and is_environ(node.value) and text_of(node.slice) \
157
+ and isinstance(node.ctx, ast.Load):
158
+ uses.append((text_of(node.slice), node.lineno, False, not guarded))
159
+ elif isinstance(node, ast.Call) and node.args and text_of(node.args[0]):
160
+ f = node.func
161
+ if (isinstance(f, ast.Name) and f.id == "getenv") or (isinstance(f, ast.Attribute) and (
162
+ f.attr == "getenv" or (f.attr == "get" and is_environ(f.value)))):
163
+ has_default = len(node.args) > 1 or bool(node.keywords) or in_or
164
+ uses.append((text_of(node.args[0]), node.lineno, has_default, False))
165
+ guarded = guarded or isinstance(node, (ast.If, ast.IfExp, ast.Try))
166
+ is_or = isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or)
167
+ stack.extend((child, guarded, is_or) for child in ast.iter_child_nodes(node))
168
+ return sorted(uses, key=lambda use: use[1])
169
+
170
+
171
+ def scan_env(root):
172
+ """Map each env var the source reads without a fallback to ('file:line' of first use, hard)."""
173
+ reads = {}
174
+ for dirpath, dirs, files in os.walk(root):
175
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
176
+ for name in sorted(files):
177
+ ext = os.path.splitext(name)[1]
178
+ if (ext not in JS_EXT and ext != ".py") or SKIP_FILE.search(name):
179
+ continue
180
+ path = Path(dirpath, name)
181
+ try:
182
+ if path.stat().st_size > 1_000_000:
183
+ continue
184
+ except OSError:
185
+ continue
186
+ text = read(path)
187
+ if "env" not in text:
188
+ continue
189
+ for var, n, optional, hard in (py_env_uses if ext == ".py" else js_env_uses)(text):
190
+ if optional or var in AMBIENT or var.startswith(AMBIENT_PREFIX):
191
+ continue
192
+ if var not in reads or (hard and not reads[var][1]):
193
+ reads[var] = (f"{path.relative_to(root).as_posix()}:{n}", hard)
194
+ return reads
195
+
196
+
197
+ def env_keys(root, names, commented=False):
198
+ """Variable names defined in the given env files; `commented` also counts '# KEY=' lines."""
199
+ rx = re.compile(r"^\s*%s(?:export\s+)?(\w+)\s*=" % (r"#?\s*" if commented else ""), re.M)
200
+ return {key for name in names for key in rx.findall(read(root / name))}
201
+
202
+
203
+ def check_version(label, found, spec, source, n, fix):
204
+ need = f"{label} {spec} required ({source})"
205
+ if not found:
206
+ return FAIL, f"{need}, but {label} was not found on PATH", fix
207
+ shown = ".".join(map(str, found))
208
+ if satisfies(found, spec, n):
209
+ return OK, f"{need}, found {shown}", None
210
+ return FAIL, f"{need}, found {shown}", fix
211
+
212
+
213
+ def diagnose(root, ci=False, env=os.environ):
214
+ """Return a list of (status, message, fix) for the repo at `root`."""
215
+ root = Path(root)
216
+ checks = []
217
+
218
+ def add(*check):
219
+ checks.append(check)
220
+
221
+ reads = scan_env(root)
222
+ example = next((n for n in ENV_EXAMPLES if (root / n).exists()), None)
223
+ documented = env_keys(root, ENV_EXAMPLES, commented=True)
224
+ undocumented = sorted(v for v in reads if v not in documented)
225
+
226
+ if ci:
227
+ for var in undocumented:
228
+ add(FAIL, f"{var} is read at {reads[var][0]} but is not in {example or '.env.example'}",
229
+ f"add {var}= to {example or '.env.example'}")
230
+ if reads and not undocumented:
231
+ add(OK, f"All {len(reads)} environment variables are documented in {example}", None)
232
+ return checks
233
+
234
+ try:
235
+ pkg = json.loads(read(root / "package.json") or "{}")
236
+ except ValueError:
237
+ pkg = {}
238
+ if not isinstance(pkg, dict):
239
+ pkg = {}
240
+ pyproject = read(root / "pyproject.toml")
241
+ is_node = (root / "package.json").exists()
242
+ is_py = (any((root / f).exists() for f in ("requirements.txt", "Pipfile", "setup.py", "uv.lock",
243
+ "poetry.lock", ".python-version"))
244
+ or bool(re.search(r"(?m)^\[(project|tool\.poetry)\]", pyproject)))
245
+
246
+ if is_node:
247
+ node = run_version("node")
248
+ engines = pkg.get("engines")
249
+ specs = [(".nvmrc", first_line(root / ".nvmrc")), (".node-version", first_line(root / ".node-version")),
250
+ ("package.json engines", str(engines.get("node", "")) if isinstance(engines, dict) else "")]
251
+ for source, spec in specs:
252
+ if version(spec):
253
+ add(*check_version("Node", node, spec, source, 1, f"nvm install {version(spec)[0]}"))
254
+ break
255
+ else:
256
+ if not node:
257
+ add(FAIL, "Node is needed (package.json) but was not found on PATH",
258
+ "install Node from https://nodejs.org")
259
+ pm = (str(pkg.get("packageManager") or "").split("@")[0]
260
+ or next((p for f, p in NODE_LOCKS if (root / f).exists()), "npm"))
261
+ if pm != "npm" and not on_path(pm):
262
+ add(FAIL, f"{pm} is this repo's package manager but is not installed", INSTALL.get(pm))
263
+ if pkg.get("dependencies") or pkg.get("devDependencies"):
264
+ if (root / "node_modules").is_dir():
265
+ add(OK, "Node dependencies installed (node_modules exists)", None)
266
+ else:
267
+ add(FAIL, "Node dependencies not installed (no node_modules folder)", f"{pm} install")
268
+
269
+ if is_py:
270
+ pm = next((p for f, p in PY_LOCKS if (root / f).exists()), None)
271
+ py_spec = re.search(r"""(?m)^(?:requires-python|python)\s*=\s*["']([^"']+)""", pyproject)
272
+ specs = [(".python-version", first_line(root / ".python-version")),
273
+ ("runtime.txt", first_line(root / "runtime.txt")),
274
+ ("pyproject.toml", py_spec.group(1) if py_spec else "")]
275
+ if pm != "uv": # uv downloads the right Python by itself
276
+ python = run_version("python", "python3") or tuple(sys.version_info[:3])
277
+ for source, spec in specs:
278
+ if version(spec):
279
+ want = ".".join(map(str, version(spec)[:2]))
280
+ add(*check_version("Python", python, spec, source, 2,
281
+ f"install Python {want} (for example: pyenv install {want})"))
282
+ break
283
+ if pm and not on_path(pm):
284
+ add(FAIL, f"{pm} is this repo's package manager but is not installed", INSTALL.get(pm))
285
+ # poetry and pipenv keep virtualenvs outside the repo, so there's nothing to look for.
286
+ if pm in (None, "uv") and not env.get("VIRTUAL_ENV") \
287
+ and not any((root / d).is_dir() for d in (".venv", "venv", "env")):
288
+ install = ("uv sync" if pm == "uv" else "python -m venv .venv, activate it, then pip install "
289
+ + ("-r requirements.txt" if (root / "requirements.txt").exists() else "-e ."))
290
+ add(WARN, "No virtual environment found (.venv or venv), so dependencies are probably not installed",
291
+ install)
292
+
293
+ set_keys = env_keys(root, ENV_FILES)
294
+ required = [v for v, (_, hard) in reads.items() if hard]
295
+ missing = sorted(v for v in required if v not in env and v not in set_keys)
296
+ if required and not missing:
297
+ add(OK, f"All {len(required)} required environment variables are set", None)
298
+ # .env.example is a declared requirement even when the code reads env vars in a way the scan can't see.
299
+ declared_unset = sorted(k for k in env_keys(root, ENV_EXAMPLES) if k not in env and k not in set_keys)
300
+ if declared_unset and not any((root / n).exists() for n in ENV_FILES):
301
+ add(FAIL, f"No .env file; {example} defines {len(declared_unset)} variable(s)",
302
+ f"cp {example} .env, then fill in the values")
303
+ missing = [v for v in missing if v not in documented]
304
+ elif set(declared_unset) - set(missing):
305
+ add(WARN, f"In {example} but not in your .env: " + ", ".join(sorted(set(declared_unset) - set(missing))),
306
+ f"copy them over from {example} if you need them")
307
+ for var in missing:
308
+ add(FAIL, f"{var} is not set (first used in {reads[var][0]})", f"add {var}=... to .env")
309
+ # Only worth a line when the repo keeps an example file and it has fallen behind the code.
310
+ if undocumented and example:
311
+ shown = ", ".join(undocumented[:5]) + (f" and {len(undocumented) - 5} more" if len(undocumented) > 5 else "")
312
+ add(WARN, f"{len(undocumented)} variable(s) the code reads are missing from {example}: {shown}",
313
+ "setupdoctor --ci lists each one with its location")
314
+
315
+ declared = {}
316
+ compose = next((n for n in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
317
+ if (root / n).exists()), None)
318
+ if compose:
319
+ override = compose.replace(".", ".override.") # Docker merges this file in automatically
320
+ for host, container in COMPOSE_PORT.findall(read(root / compose) + "\n" + read(root / override)):
321
+ if int(container) in SERVICES:
322
+ declared[int(host)] = SERVICES[int(container)]
323
+ for port, service in sorted(declared.items()):
324
+ if port_open(port):
325
+ add(OK, f"{service} reachable on localhost:{port}", None)
326
+ else:
327
+ add(FAIL, f"{service} ({compose}) is not reachable on localhost:{port}",
328
+ "docker compose up -d" if on_path("docker") else "install Docker, then: docker compose up -d")
329
+ return checks
330
+
331
+
332
+ # Git copies this into every new clone and runs it after checkout. $1 is the previous HEAD, which is
333
+ # all zeros only on a fresh clone, so switching branches later stays silent.
334
+ HOOK = """#!/bin/sh
335
+ # Installed by setupdoctor. Remove with: setupdoctor --uninstall-hook
336
+ case "$1" in *[!0]*) exit 0 ;; esac
337
+ [ -x "{python}" ] && "{python}" -m setupdoctor --hook
338
+ exit 0
339
+ """
340
+
341
+
342
+ def git_global(*args):
343
+ return subprocess.run(["git", "config", "--global", *args], capture_output=True, text=True).stdout.strip()
344
+
345
+
346
+ def clone_hook(install):
347
+ """Add or remove the git template hook that runs setupdoctor after every `git clone`."""
348
+ own = Path.home() / ".setupdoctor" / "git-template"
349
+ template = Path(git_global("init.templateDir") or own).expanduser() # reuse a template folder you already have
350
+ hook = template / "hooks" / "post-checkout"
351
+ ours = "setupdoctor" in read(hook)
352
+ if not install:
353
+ if ours:
354
+ hook.unlink()
355
+ if template == own:
356
+ git_global("--unset", "init.templateDir")
357
+ return 0, "Removed. New clones no longer run setupdoctor." if ours else "No setupdoctor hook was installed."
358
+ if hook.exists() and not ours:
359
+ return 1, f"{hook} already exists and isn't setupdoctor's, so it was left alone."
360
+ hook.parent.mkdir(parents=True, exist_ok=True)
361
+ hook.write_text(HOOK.format(python=Path(sys.executable).as_posix()), encoding="utf-8", newline="\n")
362
+ hook.chmod(0o755)
363
+ git_global("init.templateDir", template.as_posix())
364
+ return 0, ("Installed. Every `git clone` from now on runs setupdoctor in the new folder.\n"
365
+ "Repos you already cloned are not affected. Undo with: setupdoctor --uninstall-hook")
366
+
367
+
368
+ def main(argv=None):
369
+ ap = argparse.ArgumentParser(prog="setupdoctor", description=__doc__.split(": ", 1)[1])
370
+ ap.add_argument("path", nargs="?", default=".", help="repo to check (default: current folder)")
371
+ ap.add_argument("--ci", action="store_true",
372
+ help="only check that every env var the code reads is in .env.example (for pipelines)")
373
+ ap.add_argument("--install-hook", action="store_true",
374
+ help="run setupdoctor automatically after every git clone (changes your global git config)")
375
+ ap.add_argument("--uninstall-hook", action="store_true", help="undo --install-hook")
376
+ ap.add_argument("--hook", action="store_true", help=argparse.SUPPRESS) # set by the git hook: quiet if nothing to check
377
+ args = ap.parse_args(argv)
378
+ if args.install_hook or args.uninstall_hook:
379
+ if not on_path("git"):
380
+ ap.error("git was not found on PATH")
381
+ code, message = clone_hook(install=args.install_hook)
382
+ print(message)
383
+ return code
384
+ if not Path(args.path).is_dir():
385
+ ap.error(f"not a folder: {args.path}")
386
+ try:
387
+ sys.stdout.reconfigure(encoding="utf-8")
388
+ except (AttributeError, ValueError):
389
+ pass
390
+ color = sys.stdout.isatty() and "NO_COLOR" not in os.environ
391
+ if color and os.name == "nt":
392
+ os.system("") # switches older Windows consoles to ANSI colour mode
393
+
394
+ checks = diagnose(args.path, ci=args.ci)
395
+ if not checks and args.hook:
396
+ return 0
397
+ if not checks:
398
+ print("setupdoctor: " + ("the code reads no environment variables that need documenting" if args.ci
399
+ else "nothing to check here (no Node or Python requirements found)"))
400
+ return 0
401
+ print(f"setupdoctor: {len(checks)} check{'s' * (len(checks) != 1)}\n")
402
+ for status, message, fix in checks:
403
+ mark, code = MARK[status]
404
+ print(f" \033[{code}m{mark}\033[0m {message}" if color else f" {mark} {message}")
405
+ if fix:
406
+ print(f" fix: {fix}")
407
+ count = {s: sum(c[0] == s for c in checks) for s in (FAIL, WARN, OK)}
408
+ print(f"\n{count[FAIL]} failed, {count[WARN]} warning(s), {count[OK]} passed")
409
+ return 1 if count[FAIL] else 0
410
+
411
+
412
+ if __name__ == "__main__":
413
+ sys.exit(main())