Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion _msbuild.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ class ResourceFile(CSourceFile):
PyFile('__init__.py'),
CPP_SETTINGS,
ItemDefinition("Link",
AdditionalDependencies=Prepend("wintrust.lib;"),
AdditionalDependencies=Prepend("wintrust.lib;userenv.lib;"),
DelayLoadDLLs='userenv.dll',
),
IncludeFile('*.h'),
CSourceFile('*.cpp'),
Expand Down Expand Up @@ -100,6 +101,7 @@ class ResourceFile(CSourceFile):
CFunction('read_alias_package'),
CFunction('broadcast_settings_change'),
CFunction('get_processor_architecture'),
CFunction('get_environment_block'),
CFunction('verify_trust'),
source='src/_native',
RootNamespace='_native',
Expand Down
3 changes: 2 additions & 1 deletion _msbuild_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
LanguageStandard="stdcpp20",
),
ItemDefinition("Link",
AdditionalDependencies=Prepend("wintrust.lib;"),
AdditionalDependencies=Prepend("wintrust.lib;userenv.lib;"),
),
IncludeFile('*.h'),
CSourceFile('*.cpp'),
Expand Down Expand Up @@ -58,6 +58,7 @@
CFunction('read_alias_package'),
CFunction('broadcast_settings_change'),
CFunction('get_processor_architecture'),
CFunction('get_environment_block'),
CFunction('verify_trust'),
source='src/_native',
),
Expand Down
28 changes: 28 additions & 0 deletions src/_native/misc.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include <Python.h>
#include <windows.h>
#include <appmodel.h>
#include <userenv.h>

#include "helpers.h"

Expand Down Expand Up @@ -253,4 +254,31 @@ PyObject *get_processor_architecture(PyObject *, PyObject *, PyObject *) {
return PyUnicode_FromString("-64");
}

PyObject *get_environment_block(PyObject *, PyObject *, PyObject *) {
HANDLE token;
wchar_t *env = NULL;

if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY | TOKEN_DUPLICATE, &token)
|| !CreateEnvironmentBlock((LPVOID *)&env, token, FALSE)) {
PyErr_SetFromWindowsErr(0);
return NULL;
}

// env is double null terminated, so read each string's length until we find
// the zero-length one. Our caller can do the split.
wchar_t *p = env;
size_t cch = wcslen(p);
while (p && cch) {
p += cch + 1;
cch = wcslen(p);
}

PyObject *r = PyUnicode_FromWideChar(env, p - env);

DestroyEnvironmentBlock(env);
CloseHandle(token);

return r;
}

}
105 changes: 102 additions & 3 deletions src/manage/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,113 @@ def load_registry_config(key_path, schema):
return cfg


class _EnvironWithFallback(dict):
# Key names must be uppercase
# These are the keys that are filled in from a default environment if they
# are missing in the current process environment. This ensures that a
# broken environment doesn't lead to an invalid loaded configuration. It
# only applies to configuration files, and does not change the environment.
# A launched Python process will probably also fail without some of these,
# at which point the caller still has to fix their setup.
FALLBACK_KEYS = {
"ALLUSERSPROFILE",
"APPDATA",
"COMMONPROGRAMSFILES",
"COMMONPROGRAMSFILES(X86)",
"COMMONPROGRAMW6432",
"COMPUTERNAME",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"LOGONSERVER",
"NUMBER_OF_PROCESSORS",
"OS",
"PATH",
"PATHEXT",
"PROCESSOR_ARCHITECTURE",
"PROCESSOR_IDENTIFIER",
"PROCESSOR_LEVEL",
"PROCESSOR_REVISION",
"PROGRAMDATA",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"PUBLIC",
"SESSIONNAME",
"SYSTEMDRIVE",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDNSDOMAIN",
"USERDOMAIN",
"USERDOMAIN_ROAMINGPROFILE",
"USERNAME",
"USERPROFILE",
"WINDIR",
}

_default = None

@classmethod
def default(cls):
if cls._default is None:
cls._default = cls()
return cls._default

def __init__(self, d=os.environ):
self._fallback = None
for k, v in (d or {}).items():
self[k.upper()] = v

def _get_fallback(self, k):
assert k.isupper()
if k not in self.FALLBACK_KEYS:
return None
if self._fallback is None:
from _native import get_environment_block
self._fallback = {}
for var in get_environment_block().split("\0"):
k2, _, v2 = var.partition("=")
self._fallback[k2.upper()] = v2
return self._fallback[k]

def get(self, key, default=None):
k = key.upper()
try:
return self[k]
except KeyError:
pass

v = self._get_fallback(k)
if v:
LOGGER.warn("Environment variable %%%s%% was not found and so "
"a default was loaded. This usually indicates an "
"issue in your launch conditions and could lead to "
"later failures. Suppress this warning by ensuring "
"system environment variables remain set, or by "
"changing your configuration file to not rely on "
"them.", key)

return v or default



def _expand_vars(v, env):
import re
def _sub(m):
v2 = env.get(m.group(1))
if v2:
return v2 + (m.group(2) or "")
if m.group(2):
# Empty values before a path separator are not allowed
raise LookupError(m.group(1))
return ""
v2 = re.sub(r"%(.*?)%([\\/])?", _sub, v)
return v2


def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_unknown=False):
def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_unknown=False, environ=None):
for k, v in list(cfg.items()):
if k.startswith("#"):
continue
Expand All @@ -185,10 +280,12 @@ def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_u
if "env" in opts and isinstance(v, str):
try:
orig_v = v
v = _expand_vars(v, os.environ)
v = _expand_vars(v, environ or _EnvironWithFallback.default())
from_env = orig_v != v
except TypeError:
pass
except LookupError as ex:
raise InvalidConfigurationError(source, key_so_far + k, f'%{ex}%') from ex
if not v:
del cfg[k]
continue
Expand All @@ -202,7 +299,9 @@ def resolve_config(cfg, source, relative_to, key_so_far="", schema=None, error_u
if not from_env:
v = relative_to / v
else:
v = type(relative_to)(v).absolute()
v = type(relative_to)(v)
if not v.is_absolute():
v = v.absolute()
if v and "uri" in opts:
if hasattr(v, "as_uri"):
v = v.as_uri()
Expand Down
4 changes: 4 additions & 0 deletions src/manage/pathutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ def __ne__(self, other):
return not _eq(self._p, other._p)
return not _eq(self._p, str(other))

def is_absolute(self):
drive, root, tail = os.path.splitroot(self._p)
return drive and root

def with_name(self, name):
return type(self)(os.path.join(self._parent, name))

Expand Down
65 changes: 58 additions & 7 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import os
import pytest

from pathlib import Path, PurePath

from manage.config import (
_EnvironWithFallback,
_expand_vars,
config_append,
config_bool,
config_dict_merge,
config_split,
config_split_append,
resolve_config,
)


Expand Down Expand Up @@ -88,16 +93,26 @@ def test_expand_vars(value, variables, expected):
assert _expand_vars(value, variables) == expected


def test_expand_vars_removes_missing_variable_and_leading_separator():
actual = _expand_vars("%MISSING%\\Python", {})
def test_expand_vars_fails_on_missing_variable():
with pytest.raises(LookupError):
_expand_vars("%MISSING%\\Python", {})

assert actual == "Python"

def test_expand_vars_fails_on_empty_variable():
with pytest.raises(LookupError):
_expand_vars("%EMPTY%\\Python", {"EMPTY": ""})

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

assert actual == "Python"
def test_environ_with_fallback():
env = _EnvironWithFallback({"AllUsersProfile": "test", "APPDATA": ""})
# Should be the value we provided, despite case
assert env.get("allusersprofile") == "test"
# Should be empty, since it was provided as empty
assert env.get("AppData", "default") == ""
# Should be set, even though it wasn't provided
assert env.get("WinDir", "default") not in (None, "", "default")
# Should be empty, since it's neither set nor has a fallback
assert env.get("PYTHON_MANAGER_DEFAULT", "default") == "default"


@pytest.mark.parametrize(
Expand Down Expand Up @@ -200,4 +215,40 @@ def test_config_split_append_does_not_mutate_existing_list():

assert actual == ["first", ["second", "third"]]
assert existing == ["first"]
assert actual is not existing
assert actual is not existing


@pytest.mark.parametrize(
("cfg", "schema", "expect", "env"),
[
({}, {"a": (int, None), "b": (int, None)}, {}, None),
({"a": "1"}, {"a": (int, None)}, {"a": 1}, None),
({"a": 1}, {"a": (str, None)}, {"a": "1"}, None),
({"a": "abc"}, {"a": (str, None, "path")}, {"a": PurePath("X:/test/abc")}, None),
({"a": "%X%"}, {"a": (str, None, "path")}, {"a": PurePath("X:/test/%X%")}, {"X": "abc"}),
({"a": "%X%"}, {"a": (str, None, "path", "env")}, {"a": PurePath("X:/env/abc")}, {"X": "X:/env/abc"}),
({"a": "%X%"}, {"a": (str, None, "path")}, {"a": PurePath("X:/test/%X%")}, None),
],
ids=[
"empty",
"to_int",
"to_str",
"to_path",
"to_path_without_env",
"to_path_with_env",
"to_path_not_in_env",
],
)
def test_config_resolve(cfg, schema, expect, env, monkeypatch):
resolve_config(cfg, "<test.cfg>", PurePath("X:/test/"), schema=schema, environ=env)
assert cfg == expect


def test_config_resolve_env_fallback(monkeypatch, assert_log):
cfg = {"a": "%AppData%/Python"}
schema = {"a": (str, None, "path", "env")}
expect = {"a": Path(os.environ["AppData"]) / "Python"}
monkeypatch.delitem(os.environ, "AppData")
resolve_config(cfg, "<test.cfg>", Path(), schema=schema)
assert cfg == expect
assert_log(("Environment variable.*not found", ["AppData"]))
7 changes: 7 additions & 0 deletions tests/test_pathutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ def test_path_relative_to():

assert relative_to(p, r"C:\A\B\C\D") is p
assert relative_to(p, None) is p


def test_path_is_absolute():
assert Path(r"C:\A\B\C\python.exe").is_absolute()
assert not Path(r"\A\B\C\python.exe").is_absolute()
assert not Path(r"C:A\B\C\python.exe").is_absolute()
assert not Path(r"A\B\C\python.exe").is_absolute()