Repository navigation
Expand file tree
/
Copy pathenv_config.py
More file actions
230 lines (195 loc) · 8.1 KB
/
Copy pathenv_config.py
File metadata and controls
230 lines (195 loc) · 8.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
"""
env_config.py
--------------
Reads a `.env` file sitting next to the scripts, so credentials live in one
place instead of being retyped into every command line.
Two reasons this is hand-rolled rather than `python-dotenv`:
1. **No new dependency.** Every engine here already refuses to be a heavy
install, and a credential loader is about thirty lines. If `python-dotenv`
*is* installed it gets used instead, so a project that already depends on it
keeps its own behaviour.
2. **A secret in argv is visible to anything that can run `ps`.** Passing
`--twocaptcha-key sk_live_...` leaks the key to every other process on the
machine and into shell history. Reading it from the environment does not.
Precedence, highest first:
explicit CLI flag > real environment variable > .env file > default
That order matters: a `.env` must never silently override something the caller
typed, and an already-exported variable (CI secret, `direnv`, a shell profile)
must never be clobbered by a file someone forgot to delete.
Nothing here is required. With no `.env` and no environment variables the
scripts behave exactly as before.
"""
import logging
import os
import re
from pathlib import Path
logger = logging.getLogger(__name__)
# Recognised keys, and which CLI destination each one backs.
# Keeping this explicit means a typo in .env is reported rather than ignored.
ENV_KEYS = {
"TWOCAPTCHA_KEY": "twocaptcha_key",
"AMAZON_CDP_ENDPOINT": "cdp_endpoint",
"AMAZON_PROXY": "proxy",
"AMAZON_URL": "url",
}
# Deliberately NOT here: an output prefix. `--out` already carries a non-empty
# default, so `apply()` would never see it as unset and the variable would be
# silently ignored — a setting that looks configurable and is not.
# Values that look like a key but are the placeholder from .env.example.
# A placeholder that reaches the API produces a confusing auth error a long way
# from its cause, so it is caught here instead.
_PLACEHOLDERS = {
"your_2captcha_api_key_here",
"your_api_key_here",
"changeme",
"",
}
# A `{...}` left anywhere in a value is a placeholder too. The two
# credentialled URLs are documented the way the vendor documents them —
# `ws://{login}-zone-...:{password}@cb.2captcha.com:9222` and
# `http://{user}:{password}@...` — and neither literal is in the set above,
# so a value copied from the docs read as CONFIGURED and reached the
# endpoint with `{login}` as its username, coming back as a 401 a long way
# from its cause. Real credentials and URLs never contain braces.
_BRACED_PLACEHOLDER = re.compile(r"\{[^{}\s]*\}")
_loaded_from = None
def _parse_line(line):
"""Return (key, value) or None. Understands `export K=V`, quotes, # comments."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith("export "):
line = line[len("export "):].strip()
if "=" not in line:
return None
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
# Strip one matching pair of quotes; leave inner ones alone.
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
else:
# Unquoted values may carry a trailing comment.
value = value.split(" #")[0].strip()
if not key:
return None
return key, value
def load_env(path=None, override=False):
"""Load `.env` into os.environ. Returns the path used, or None.
`override=False` (the default) means an already-set environment variable
wins over the file.
"""
global _loaded_from
if path is None:
path = Path(__file__).resolve().parent / ".env"
path = Path(path)
if not path.is_file():
return None
# Defer to python-dotenv when the user already has it: their version may
# support syntax this parser does not (multi-line values, interpolation).
try:
from dotenv import load_dotenv # type: ignore
load_dotenv(path, override=override)
_loaded_from = str(path)
logger.debug("Loaded %s via python-dotenv", path)
return str(path)
except ImportError:
pass
count = 0
for raw in path.read_text(encoding="utf-8").splitlines():
parsed = _parse_line(raw)
if not parsed:
continue
key, value = parsed
if key in os.environ and not override:
continue
os.environ[key] = value
count += 1
_loaded_from = str(path)
logger.debug("Loaded %d value(s) from %s", count, path)
return str(path)
def unknown_keys(path=None):
"""Keys present in .env that nothing in this project reads.
Usually a typo — `TWO_CAPTCHA_KEY`, `AMAZON_CDP` — which otherwise fails
silently as "the key just isn't being picked up".
"""
if path is None:
path = Path(__file__).resolve().parent / ".env"
path = Path(path)
if not path.is_file():
return []
found = []
for raw in path.read_text(encoding="utf-8").splitlines():
parsed = _parse_line(raw)
if parsed and parsed[0] not in ENV_KEYS:
found.append(parsed[0])
return found
def env_value(name):
"""Read one recognised variable, treating .env.example placeholders as unset."""
value = os.environ.get(name)
if value is None:
return None
stripped = value.strip()
if not stripped:
# Empty is not the same as "you left the example text in". An unset
# GitHub Actions secret arrives as an empty string, so warning here
# would print "put your real value in .env" on every canary run that
# deliberately has no proxy — and a warning that fires when nothing is
# wrong teaches people to ignore warnings.
return None
if stripped.lower() in _PLACEHOLDERS:
logger.warning(
"%s is still set to the placeholder from .env.example — treating it "
"as unset. Put your real value in .env.", name)
return None
if _BRACED_PLACEHOLDER.search(stripped):
# Name the variable, never quote the value: it may be a URL with a
# real password beside the placeholder.
logger.warning(
"%s still contains a {...} placeholder from the documentation — "
"treating it as unset. Replace every {...} with your real value.",
name)
return None
return stripped
def apply(args, keys=None, quiet=False):
"""Fill unset argparse destinations from the environment.
Call once, right after `parse_args()`. Only fills a destination that the
parser actually defines and that is still falsy, so an explicit flag always
wins.
"""
load_env()
for env_name, dest in (keys or ENV_KEYS).items():
if not hasattr(args, dest):
continue
if getattr(args, dest):
continue
value = env_value(env_name)
if value:
setattr(args, dest, value)
if not quiet:
# Never log the value. Credentials end up in CI logs, pasted
# terminal output and bug reports.
logger.info("Using %s from the environment for --%s",
env_name, dest.replace("_", "-"))
if not quiet:
for key in unknown_keys():
logger.warning("Ignoring unrecognised key in .env: %s", key)
return args
if __name__ == "__main__":
# `python3 env_config.py` — report what is configured, without printing
# any secret. Useful as a first step when a key "isn't being picked up".
logging.basicConfig(level=logging.INFO, format="%(message)s")
where = load_env()
print(f".env file: {where or 'not found (this is fine — env vars still work)'}")
for env_name, dest in ENV_KEYS.items():
value = env_value(env_name)
if value is None:
state = "not set"
elif "KEY" in env_name or "@" in value:
state = f"set ({len(value)} chars, hidden)"
else:
state = f"set ({value})"
print(f" {env_name:<24} -> --{dest.replace('_', '-'):<16} {state}")
extras = unknown_keys()
if extras:
print("\nUnrecognised keys in .env (typo?): " + ", ".join(extras))