Skip to content

Commit c692c46

Browse files
authored
Adds fallback to newly created environment block for known OS variables. (#432)
Also makes it a hard failure for environment variables in paths to be empty. Fixes #427
1 parent 362122a commit c692c46

7 files changed

Lines changed: 210 additions & 13 deletions

File tree

‎_msbuild.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ class ResourceFile(CSourceFile):
6767
PyFile('__init__.py'),
6868
CPP_SETTINGS,
6969
ItemDefinition("Link",
70-
AdditionalDependencies=Prepend("wintrust.lib;"),
70+
AdditionalDependencies=Prepend("wintrust.lib;userenv.lib;"),
71+
DelayLoadDLLs='userenv.dll',
7172
),
7273
IncludeFile('*.h'),
7374
CSourceFile('*.cpp'),
@@ -100,6 +101,7 @@ class ResourceFile(CSourceFile):
100101
CFunction('read_alias_package'),
101102
CFunction('broadcast_settings_change'),
102103
CFunction('get_processor_architecture'),
104+
CFunction('get_environment_block'),
103105
CFunction('verify_trust'),
104106
source='src/_native',
105107
RootNamespace='_native',

‎_msbuild_test.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
LanguageStandard="stdcpp20",
2525
),
2626
ItemDefinition("Link",
27-
AdditionalDependencies=Prepend("wintrust.lib;"),
27+
AdditionalDependencies=Prepend("wintrust.lib;userenv.lib;"),
2828
),
2929
IncludeFile('*.h'),
3030
CSourceFile('*.cpp'),
@@ -58,6 +58,7 @@
5858
CFunction('read_alias_package'),
5959
CFunction('broadcast_settings_change'),
6060
CFunction('get_processor_architecture'),
61+
CFunction('get_environment_block'),
6162
CFunction('verify_trust'),
6263
source='src/_native',
6364
),

‎src/_native/misc.cpp‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include <Python.h>
22
#include <windows.h>
33
#include <appmodel.h>
4+
#include <userenv.h>
45

56
#include "helpers.h"
67

@@ -253,4 +254,32 @@ PyObject *get_processor_architecture(PyObject *, PyObject *, PyObject *) {
253254
return PyUnicode_FromString("-64");
254255
}
255256

257+
PyObject *get_environment_block(PyObject *, PyObject *, PyObject *) {
258+
HANDLE token = NULL;
259+
wchar_t *env = NULL;
260+
261+
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE, &token)
262+
|| !CreateEnvironmentBlock((LPVOID *)&env, token, FALSE)) {
263+
PyErr_SetFromWindowsErr(0);
264+
if (token) CloseHandle(token);
265+
return NULL;
266+
}
267+
268+
// env is double null terminated, so read each string's length until we find
269+
// the zero-length one. Our caller can do the split.
270+
wchar_t *p = env;
271+
size_t cch = wcslen(p);
272+
while (p && cch) {
273+
p += cch + 1;
274+
cch = wcslen(p);
275+
}
276+
277+
PyObject *r = PyUnicode_FromWideChar(env, p - env);
278+
279+
DestroyEnvironmentBlock(env);
280+
CloseHandle(token);
281+
282+
return r;
283+
}
284+
256285
}

‎src/manage/config.py‎

Lines changed: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,117 @@ def load_registry_config(key_path, schema):
150150
return cfg
151151

152152

153+
class _EnvironWithFallback(dict):
154+
# Key names must be uppercase
155+
# These are the keys that are filled in from a default environment if they
156+
# are missing in the current process environment. This ensures that a
157+
# broken environment doesn't lead to an invalid loaded configuration. It
158+
# only applies to configuration files, and does not change the environment.
159+
# A launched Python process will probably also fail without some of these,
160+
# at which point the caller still has to fix their setup.
161+
FALLBACK_KEYS = {
162+
"ALLUSERSPROFILE",
163+
"APPDATA",
164+
"COMMONPROGRAMFILES",
165+
"COMMONPROGRAMFILES(X86)",
166+
"COMMONPROGRAMW6432",
167+
"COMPUTERNAME",
168+
"COMSPEC",
169+
"HOMEDRIVE",
170+
"HOMEPATH",
171+
"LOCALAPPDATA",
172+
"LOGONSERVER",
173+
"NUMBER_OF_PROCESSORS",
174+
"OS",
175+
"PATH",
176+
"PATHEXT",
177+
"PROCESSOR_ARCHITECTURE",
178+
"PROCESSOR_IDENTIFIER",
179+
"PROCESSOR_LEVEL",
180+
"PROCESSOR_REVISION",
181+
"PROGRAMDATA",
182+
"PROGRAMFILES",
183+
"PROGRAMFILES(X86)",
184+
"PROGRAMW6432",
185+
"PUBLIC",
186+
"SESSIONNAME",
187+
"SYSTEMDRIVE",
188+
"SYSTEMROOT",
189+
"TEMP",
190+
"TMP",
191+
"USERDNSDOMAIN",
192+
"USERDOMAIN",
193+
"USERDOMAIN_ROAMINGPROFILE",
194+
"USERNAME",
195+
"USERPROFILE",
196+
"WINDIR",
197+
}
198+
199+
_default = None
200+
201+
@classmethod
202+
def default(cls):
203+
if cls._default is None:
204+
cls._default = cls()
205+
return cls._default
206+
207+
def __init__(self, d=os.environ):
208+
self._fallback = None
209+
for k, v in (d or {}).items():
210+
self[k.upper()] = v
211+
212+
def _get_fallback(self, k):
213+
assert k.isupper()
214+
if k not in self.FALLBACK_KEYS:
215+
return None
216+
if self._fallback is None:
217+
from _native import get_environment_block
218+
self._fallback = {}
219+
for var in get_environment_block().split("\0"):
220+
k2, sep, v2 = var.partition("=")
221+
if k2 and sep and v2:
222+
self._fallback[k2.upper()] = v2
223+
return self._fallback.get(k)
224+
225+
def get(self, key, default=None):
226+
k = key.upper()
227+
try:
228+
return self[k]
229+
except KeyError:
230+
pass
231+
232+
v = self._get_fallback(k)
233+
if v:
234+
LOGGER.warn("Environment variable %%%s%% was not found and so "
235+
"a default was loaded. This usually indicates an "
236+
"issue in your launch conditions and could lead to "
237+
"later failures. Suppress this warning by ensuring "
238+
"system environment variables remain set, or by "
239+
"changing your configuration file to not rely on "
240+
"them.", key)
241+
242+
return v or default
243+
244+
245+
153246
def _expand_vars(v, env):
154247
import re
155248
def _sub(m):
156249
v2 = env.get(m.group(1))
157250
if v2:
158251
return v2 + (m.group(2) or "")
252+
if m.group(2):
253+
# Empty values before a path separator are not allowed
254+
raise LookupError(m.group(1))
159255
return ""
160256
v2 = re.sub(r"%(.*?)%([\\/])?", _sub, v)
161257
return v2
162258

163259

164-
def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_unknown=False):
260+
def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_unknown=False, environ=None):
261+
if environ is None:
262+
environ = _EnvironWithFallback.default()
263+
165264
for k, v in list(cfg.items()):
166265
if k.startswith("#"):
167266
continue
@@ -177,18 +276,20 @@ def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_u
177276
if isinstance(subschema, dict):
178277
if not isinstance(v, dict):
179278
raise InvalidConfigurationError(source, key_so_far + k, v)
180-
resolve_config(v, source, relative_to, f"{key_so_far}{k}.", subschema)
279+
resolve_config(v, source, relative_to, f"{key_so_far}{k}.", subschema, environ=environ)
181280
continue
182281

183282
kind, merge, *opts = subschema
184283
from_env = False
185284
if "env" in opts and isinstance(v, str):
186285
try:
187286
orig_v = v
188-
v = _expand_vars(v, os.environ)
287+
v = _expand_vars(v, environ)
189288
from_env = orig_v != v
190289
except TypeError:
191290
pass
291+
except LookupError as ex:
292+
raise InvalidConfigurationError(source, key_so_far + k, f'%{ex}%') from ex
192293
if not v:
193294
del cfg[k]
194295
continue
@@ -202,7 +303,9 @@ def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_u
202303
if not from_env:
203304
v = relative_to / v
204305
else:
205-
v = type(relative_to)(v).absolute()
306+
v = type(relative_to)(v)
307+
if not v.is_absolute():
308+
v = v.absolute()
206309
if v and "uri" in opts:
207310
if hasattr(v, "as_uri"):
208311
v = v.as_uri()

‎src/manage/pathutils.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ def __ne__(self, other):
105105
return not _eq(self._p, other._p)
106106
return not _eq(self._p, str(other))
107107

108+
def is_absolute(self):
109+
drive, root, tail = os.path.splitroot(self._p)
110+
return bool(drive and root)
111+
108112
def with_name(self, name):
109113
return type(self)(os.path.join(self._parent, name))
110114

‎tests/test_config.py‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
import os
12
import pytest
23

4+
from pathlib import Path, PurePath
5+
36
from manage.config import (
7+
_EnvironWithFallback,
48
_expand_vars,
59
config_append,
610
config_bool,
711
config_dict_merge,
812
config_split,
913
config_split_append,
14+
resolve_config,
1015
)
1116

1217

@@ -88,16 +93,26 @@ def test_expand_vars(value, variables, expected):
8893
assert _expand_vars(value, variables) == expected
8994

9095

91-
def test_expand_vars_removes_missing_variable_and_leading_separator():
92-
actual = _expand_vars("%MISSING%\\Python", {})
96+
def test_expand_vars_fails_on_missing_variable():
97+
with pytest.raises(LookupError):
98+
_expand_vars("%MISSING%\\Python", {})
9399

94-
assert actual == "Python"
95100

101+
def test_expand_vars_fails_on_empty_variable():
102+
with pytest.raises(LookupError):
103+
_expand_vars("%EMPTY%\\Python", {"EMPTY": ""})
96104

97-
def test_expand_vars_removes_empty_variable_and_leading_separator():
98-
actual = _expand_vars("%EMPTY%\\Python", {"EMPTY": ""})
99105

100-
assert actual == "Python"
106+
def test_environ_with_fallback():
107+
env = _EnvironWithFallback({"AllUsersProfile": "test", "APPDATA": ""})
108+
# Should be the value we provided, despite case
109+
assert env.get("allusersprofile") == "test"
110+
# Should be empty, since it was provided as empty
111+
assert env.get("AppData", "default") == ""
112+
# Should be set, even though it wasn't provided
113+
assert env.get("WinDir", "default") not in (None, "", "default")
114+
# Should be empty, since it's neither set nor has a fallback
115+
assert env.get("PYTHON_MANAGER_DEFAULT", "default") == "default"
101116

102117

103118
@pytest.mark.parametrize(
@@ -200,4 +215,40 @@ def test_config_split_append_does_not_mutate_existing_list():
200215

201216
assert actual == ["first", ["second", "third"]]
202217
assert existing == ["first"]
203-
assert actual is not existing
218+
assert actual is not existing
219+
220+
221+
@pytest.mark.parametrize(
222+
("cfg", "schema", "expect", "env"),
223+
[
224+
({}, {"a": (int, None), "b": (int, None)}, {}, None),
225+
({"a": "1"}, {"a": (int, None)}, {"a": 1}, None),
226+
({"a": 1}, {"a": (str, None)}, {"a": "1"}, None),
227+
({"a": "abc"}, {"a": (str, None, "path")}, {"a": PurePath("X:/test/abc")}, None),
228+
({"a": "%X%"}, {"a": (str, None, "path")}, {"a": PurePath("X:/test/%X%")}, {"X": "abc"}),
229+
({"a": "%X%"}, {"a": (str, None, "path", "env")}, {"a": PurePath("X:/env/abc")}, {"X": "X:/env/abc"}),
230+
({"a": "%X%"}, {"a": (str, None, "path")}, {"a": PurePath("X:/test/%X%")}, None),
231+
],
232+
ids=[
233+
"empty",
234+
"to_int",
235+
"to_str",
236+
"to_path",
237+
"to_path_without_env",
238+
"to_path_with_env",
239+
"to_path_not_in_env",
240+
],
241+
)
242+
def test_config_resolve(cfg, schema, expect, env, monkeypatch):
243+
resolve_config(cfg, "<test.cfg>", PurePath("X:/test/"), schema=schema, environ=env)
244+
assert cfg == expect
245+
246+
247+
def test_config_resolve_env_fallback(assert_log):
248+
cfg = {"a": "%AppData%/Python"}
249+
schema = {"a": (str, None, "path", "env")}
250+
expect = {"a": Path(os.environ["AppData"]) / "Python"}
251+
env = _EnvironWithFallback({k: v for k, v in os.environ.items() if k.lower() != "appdata"})
252+
resolve_config(cfg, "<test.cfg>", Path(), schema=schema, environ=env)
253+
assert cfg == expect
254+
assert_log(("Environment variable.*not found", ["AppData"]))

‎tests/test_pathutils.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,10 @@ def test_path_relative_to():
4949

5050
assert relative_to(p, r"C:\A\B\C\D") is p
5151
assert relative_to(p, None) is p
52+
53+
54+
def test_path_is_absolute():
55+
assert Path(r"C:\A\B\C\python.exe").is_absolute()
56+
assert not Path(r"\A\B\C\python.exe").is_absolute()
57+
assert not Path(r"C:A\B\C\python.exe").is_absolute()
58+
assert not Path(r"A\B\C\python.exe").is_absolute()

0 commit comments

Comments
 (0)