From bf20549596e6cb51dbfcc8d57eb5a4677426be4e Mon Sep 17 00:00:00 2001 From: Brett Cannon Date: Sun, 17 Sep 2023 18:32:38 -0700 Subject: [PATCH] Add `parse_config()` Closes #40 --- microvenv/__init__.py | 28 +++++++++++++++++++++++ tests/conftest.py | 10 +++++++++ tests/test_create.py | 37 ++++++++---------------------- tests/test_parse_config.py | 46 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 28 deletions(-) create mode 100644 tests/test_parse_config.py diff --git a/microvenv/__init__.py b/microvenv/__init__.py index cf085a5..d11531f 100644 --- a/microvenv/__init__.py +++ b/microvenv/__init__.py @@ -1,3 +1,4 @@ +import pathlib import sys # Exported as part of the public API. @@ -6,3 +7,30 @@ # https://docs.python.org/3/library/venv.html#how-venvs-work IN_VIRTUAL_ENV = sys.prefix != sys.base_prefix + + +def parse_config(env_dir): + """Parse the pyvenv.cfg file in the specified virtual environment. + + A dict is returned with strings for keys and values. All keys are + lowercased, but otherwise no validation is performed. No changes are made to + the values (e.g., include-system-site-packages is not converted to a boolean + nor lowerased). + + Parsing is done in a way identical to how the 'site' modules does it. This + means that ANY line with an ``=`` sign is considered a line with a key/value + pair on it. As such, all other lines are treated as if they are comments. + But this also means that having a line start with e.g., ``#`` does not + signify a comment either if there is a ``=`` in the line. + """ + config = {} + venv_path = pathlib.Path(env_dir) + with open(venv_path / "pyvenv.cfg", "r", encoding="utf-8") as file: + # This is how `site` parses `pyvenv.cfg`, so it's about as + # official as we can get. + for line in file: + if "=" in line: + key, _, value = line.partition("=") + config[key.strip().lower()] = value.strip() + return config + diff --git a/tests/conftest.py b/tests/conftest.py index 06c1b0d..ac04523 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,19 @@ import pathlib import sys +import venv import pytest @pytest.fixture def executable(): + """Return the current interpreter's path.""" return pathlib.Path(sys.executable) + + +@pytest.fixture(scope="session") +def full_venv(tmp_path_factory): + """Create a virtual environment via venv.""" + venv_path = tmp_path_factory.mktemp("venvs") / "full_venv" + venv.create(venv_path, symlinks=True, with_pip=False, system_site_packages=False) + return venv_path diff --git a/tests/test_create.py b/tests/test_create.py index ec966ef..e180392 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -18,13 +18,6 @@ def base_executable(): return pathlib.Path(sys.executable) -@pytest.fixture(scope="session") -def full_venv(tmp_path_factory): - venv_path = tmp_path_factory.mktemp("venvs") / "full_venv" - venv.create(venv_path, symlinks=True, with_pip=False, system_site_packages=False) - return venv_path - - @pytest.fixture(scope="session") def micro_venv(tmp_path_factory): venv_path = tmp_path_factory.mktemp("venvs") / "micro_venv" @@ -32,18 +25,6 @@ def micro_venv(tmp_path_factory): return venv_path -def pyvenvcfg(venv_path): - config = {} - with open(venv_path / "pyvenv.cfg", "r", encoding="utf-8") as file: - for line in file: - if "=" in line: - # This is how `site` reads a `pyvenv.cfg`, so it's about as - # official as we can get. - key, _, value = line.partition("=") - config[key.strip().lower()] = value.strip() - return config - - def test_code_size(executable, monkeypatch, tmp_path): """Make sure the source code can fit into `argv` for use with `-c`.""" with open(microvenv._create.__file__, "r", encoding="utf-8") as file: @@ -56,7 +37,7 @@ def test_code_size(executable, monkeypatch, tmp_path): # validating the virtual environment details as the CLI tests take care of # that. assert env_path.is_dir() - command = pyvenvcfg(env_path)["command"] + command = microvenv.parse_config(env_path)["command"] assert command.startswith(sys.executable) assert " -c " in command @@ -101,8 +82,8 @@ def test_lib64(full_venv, micro_venv): ["include-system-site-packages", "version"], ) def test_pyvenvcfg_data(full_venv, micro_venv, key): - full_config = pyvenvcfg(full_venv) - micro_config = pyvenvcfg(micro_venv) + full_config = microvenv.parse_config(full_venv) + micro_config = microvenv.parse_config(micro_venv) # Use the full config as source of keys to check as as we may have keys in the micro # venv that too new for the version of Python being tested against. @@ -112,8 +93,8 @@ def test_pyvenvcfg_data(full_venv, micro_venv, key): def test_pyvenvcfg_home(base_executable, full_venv, micro_venv): - full_config = pyvenvcfg(full_venv) - micro_config = pyvenvcfg(micro_venv) + full_config = microvenv.parse_config(full_venv) + micro_config = microvenv.parse_config(micro_venv) assert full_config["home"] == os.fsdecode(base_executable.parent) # Sanity check. assert micro_config["home"] == os.fsdecode(base_executable.parent) @@ -122,19 +103,19 @@ def test_pyvenvcfg_home(base_executable, full_venv, micro_venv): def test_pyvenvcfg_executable(base_executable, full_venv, micro_venv): resolved_base_executable = base_executable.resolve() executable_path = os.fsdecode(resolved_base_executable) - full_config = pyvenvcfg(full_venv) + full_config = microvenv.parse_config(full_venv) if "executable" not in full_config: # Introduced in Python 3.11. pytest.skip("`executable` key not in pyvenv.cfg") - micro_config = pyvenvcfg(micro_venv) + micro_config = microvenv.parse_config(micro_venv) assert full_config["executable"] == executable_path # Sanity check. assert micro_config["executable"] == executable_path def test_pyvenvcfg_command(executable, micro_venv): - config = pyvenvcfg(micro_venv) + config = microvenv.parse_config(micro_venv) script_path = pathlib.Path(microvenv._create.__file__).resolve() assert config["command"] == f"{executable} {script_path} {micro_venv.resolve()}" @@ -144,5 +125,5 @@ def test_pyvenvcfg_command_relative(executable, monkeypatch, tmp_path): venv_path = tmp_path / "venv" microvenv.create(pathlib.Path(venv_path.name)) script_path = pathlib.Path(microvenv._create.__file__).resolve() - config = pyvenvcfg(venv_path) + config = microvenv.parse_config(venv_path) assert config["command"] == f"{executable} {script_path} {venv_path.resolve()}" diff --git a/tests/test_parse_config.py b/tests/test_parse_config.py new file mode 100644 index 0000000..8594067 --- /dev/null +++ b/tests/test_parse_config.py @@ -0,0 +1,46 @@ +import contextlib + +import pytest + +import microvenv + + +@contextlib.contextmanager +def write_config(venv_path, data): + """Context manager to write the pyvenv.cfg and then restore it.""" + config_path = venv_path / "pyvenv.cfg" + original_config = config_path.read_text(encoding="utf-8") + config_path.write_text(data, encoding="utf-8") + try: + yield config_path + finally: + config_path.write_text(original_config, encoding="utf-8") + + +@pytest.mark.parametrize("equals", ["=", " = ", "= ", " ="]) +def test_formatting_around_equals(full_venv, equals): + with write_config(full_venv, f"key{equals}value\n"): + config = microvenv.parse_config(full_venv) + + assert config["key"] == "value" + + +def test_comments(full_venv): + with write_config(full_venv, "# A comment\nkey = value\n"): + config = microvenv.parse_config(full_venv) + + assert config["key"] == "value" + + +def test_multiple_equals(full_venv): + with write_config(full_venv, "key = value=value\n"): + config = microvenv.parse_config(full_venv) + + assert config["key"] == "value=value" + + +def test_lowercase_keys(full_venv): + with write_config(full_venv, "Key = value\n"): + config = microvenv.parse_config(full_venv) + + assert config["key"] == "value"