Env
duho.Env, the prefixed, app-wide environment accessor.
duho.env
Prefixed, app-wide environment accessor.
An :class:Env presents a single typed view over os.environ keys sharing a
common prefix (MYAPP_DEBUG, MYAPP_CMDS_PATH, ...), plus an optional
companion <prefix>env module of defaults an app may ship.
Distinct from the per-field Meta(env=...) default layer: that resolves
one argparse field; this is the app-level accessor a driver reads settings through
(command search paths, DEBUG, import hooks).
__all__ = ['Env']
module-attribute
Env(prefix, autoload=True, **env)
Bases: MutableMapping
A prefixed, mutable view over os.environ with typed accessors.
Env("my-app") reads keys named MY_APP_<KEY> from the process
environment (the prefix is uppercased, - -> _, and a trailing _
is ensured). An empty prefix reads bare environment keys.
On construction, when autoload is true (the default), the accessor tries
to import a companion <prefix-lower>env module (e.g. my_app_env) and
seeds its upper-case, non-underscore module variables as defaults --
genuinely lowest-precedence, consulted only when a key is set neither
explicitly (**env kwargs / a runtime env[k] = v write) nor in the
real process environment. Precedence, highest first:
**env kwargs / runtime writes > real os.environ > the
<prefix>env companion module's shipped defaults. All seeded values --
module and kwargs -- are str()-coerced, so env.bool/env.list
never see a raw non-string.
SECURITY: autoloading imports <prefix-lower>env from anywhere on
sys.path (which normally includes the current working directory), so a
hostile <prefix>env.py in the CWD would run its module body. Pass
autoload=False to disable the import entirely if the prefix is not fully
under your control. Autoload is skipped outright (no import attempted) for
an EMPTY prefix -- Env("") would otherwise autoload a top-level module
literally named env, a very common name for a project's own settings
module -- and for a prefix whose normalised form contains any character
outside [A-Za-z0-9_] (e.g. Env("my.app"), which would otherwise
import the unrelated top-level package my while resolving
my.app_env). Only the companion module's OWN absence is swallowed: an
ImportError/ModuleNotFoundError raised by code inside an existing
companion module (a typo, or a stdlib module missing on the 3.9 floor such
as tomllib) propagates instead of silently dropping every shipped
default.
MutableMapping semantics. del env[key] / env.pop(key) on a
key served from os.environ or the companion defaults records an
in-object tombstone -- it is NEVER written back to the real process
environment -- so the key reads as absent for the rest of this Env's
lifetime (until an explicit env[key] = value write clears the
tombstone again). This keeps the stdlib MutableMapping mixins
(pop/popitem/clear, all built on __delitem__+__iter__)
internally consistent: a key that is in the mapping can always be
pop()-ed, and clear() always empties it.
Source code in src/duho/env.py
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 | |
prefix = prefix
instance-attribute
__delitem__(key)
Source code in src/duho/env.py
152 153 154 155 156 157 158 159 160 161 162 163 164 | |
__getitem__(key)
Source code in src/duho/env.py
136 137 138 139 140 141 142 143 144 | |
__iter__()
Source code in src/duho/env.py
166 167 168 169 170 171 172 173 174 175 176 177 178 179 | |
__len__()
Source code in src/duho/env.py
181 182 183 | |
__setitem__(key, value)
Source code in src/duho/env.py
146 147 148 149 150 | |
bool(key)
Return key interpreted as a boolean.
Truthy values (case-insensitive, whitespace-stripped) are
duho.text.BOOL_TRUE (1, true, yes, y, t,
on); anything else (including a missing key) is False.
The truthy set is the same shared duho.text.BOOL_TRUE the layered
(env/config) converter uses -- but this accessor
stays LENIENT where that one is strict: an unrecognized value here is
False rather than a user error.
Source code in src/duho/env.py
187 188 189 190 191 192 193 194 195 196 197 198 199 | |
list(key, sep=':', ty=str)
Return key split on sep with ty applied to each part.
A missing or empty value yields [] -- an empty list, NOT [ty("")].
The old single-empty-string contract turned a missing <PREFIX>_CMDS_PATH
into [Path("")] == [Path(".")] and glob-imported the whole CWD;
[""] as "one empty path" has no legitimate use.
Source code in src/duho/env.py
201 202 203 204 205 206 207 208 209 210 211 212 213 214 | |
paths(key, ty=str, *, strict=True, on_reject=None)
Return a path-list env var (e.g. CMDS_PATH) split on the OS separator.
Unlike :meth:list (whose sep defaults to ":" for generic lists
like HOSTS), this splits on the platform path-list separator --
os.pathsep (";" on Windows, ":" on POSIX) -- so an absolute
Windows path's drive-letter colon (C:\...) is never mis-split into a
bogus C entry. This app's OWN <PREFIX>PATHSEP key (see
:meth:_resolve_pathsep) overrides the separator when set, so a
caller can still force a separator regardless of platform -- SCOPED
to this app's prefix, never a bare/global PATHSEP read straight
off os.environ: that would let ANY process-wide PATHSEP
(set for a wholly unrelated program) bypass every safety rule below
for every duho app on the system, splitting e.g. C:\...\cmds on
its own drive-letter colon into a bare C: segment (a
security-relevant fix). For an EMPTY prefix (Env("")) there is no
scoped key to read at all -- PATHSEP is always os.pathsep --
since self.get("PATHSEP") would otherwise read that same bare,
unscoped key. <PREFIX>PATHSEP is also validated: it must be
exactly one character and not /, \, or . (a multi-char
value, or one of those three, can itself smuggle the CWD in the same
way an unvalidated separator did -- e.g. splitting an absolute path
on its own directory separator, or "separating" on nothing at all) --
an invalid value is a security-relevant misconfiguration, not a
silent fallback: it is WARNED at :data:logging.WARNING and
os.pathsep is used instead. Missing/empty yields [], exactly
like :meth:list.
An empty or whitespace-only SEGMENT -- from a leading, trailing, or
doubled separator (the common X="$X:/extra" append idiom run while
X was unset) -- is dropped BEFORE ty ever sees it, and is never
treated as the current directory. This is deliberately unlike POSIX
$PATH, where an empty entry means the CWD: here, an empty CMDS_PATH
segment would become ty("") -- Path("") is Path(".") --
which glob-imported and executed every file in the CWD (a security-relevant fix).
A caller who genuinely wants the current directory writes it
explicitly as a "." segment, which IS still honoured.
Three more segment shapes are rejected, all ways an attacker-controlled separator can still smuggle the CWD in even past the empty-segment rule above:
- a bare drive letter (
"C:", matching^[A-Za-z]:$) -- Windows resolves this to "the current directory on drive C", an ambient lookup that is never a legitimate entry on its own (the shape a backslash/colonPATHSEPproduces by splitting an absolute Windows path on its own drive-letter colon). - while
<PREFIX>PATHSEPOVERRIDES the platform default (see above), any segment that is neither absolute nor explicitly relative (spelled".", or starting with"./"/".\") -- an ordinary relative-looking segment ("C","my-tools") is exactly the shape splitting an absolute path on a character that also occurs INSIDE it produces (e.g.PATHSEP=":"splits"C:\...\cmds"into"C"and"\...\cmds"--"C"is neither a bare drive letter, matched above, nor does it resolve to the CWD, checked below: it resolves to a plain SUBDIRECTORY of the CWD, e.g."./C", which is exactly the ambient lookup this rule closes). With the defaultos.pathsepthis never applies, since a real relative entry a caller deliberately wrote ("cmds", without./) is not the product of any mis-split and stays valid. - any OTHER segment that resolves to the current working
directory -- unless it is spelled exactly
"."(the one explicitly honoured way to mean the CWD).
strict (default True) decides how a rejected segment is
reported: strict raises ValueError immediately, naming the
bad segment (the original, whole-value contract -- a direct caller
that wants "tell me right away, and I'll decide" keeps getting
exactly that). Passing strict=False instead skips the bad
segment and keeps going, returning every OTHER valid entry -- for a
caller like :func:duho.runtime.app's CMDS_PATH resolution, one
misconfigured entry among several must not silently drop the whole
list (raising here would propagate past a bare except Exception at
the call site, discarding every valid entry along with the bad one,
unlogged). on_reject, when
given, is called as on_reject(segment, reason) for each skipped
entry -- reason a short human phrase ("bare drive segment" /
"ambiguous relative segment under a custom separator" /
"resolves to the current working directory") -- so the caller can
report it however it likes (e.g. a WARNING naming which env var
it came from); this method itself never logs. Ignored when
strict is True (the raised ValueError already names it).
Note this method does NOT delegate to :meth:list -- list's
generic contract (arbitrary sep/ty, e.g. env.list("PORTS",
ty=int)) is left unchanged, since dropping/rejecting segments here is
a separate, unrelated decision for non-path lists.
Source code in src/duho/env.py
244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 | |