Testing a module loaded from a path and a shell helper

Introduction

Downloaded server logs do not belong in a repository: they are large, they change every day, and some of them hold client IP addresses. The prod-log pipeline keeps them in one external directory, and every script must agree on where that directory is.

The folder container/tools/analyze_logs answers the question twice: in Python, in log_paths.py, and in shell, in log-root.sh. The shell scripts source the second file, the Python tools import the first one.

Its test, container/tests/test_log_paths.py, is three tests long and shows a pattern that most test suites need sooner or later: one parametrized assertion, a module loaded from a file path, and a subprocess call to a shell helper.

Tools used here: Python , importlib , subprocess , pytest .

A parametrized assert

The resolver has four inputs that matter: the home directory, the value of XDG_DATA_HOME, the override AREDEL_LOG_DIR, and nothing else. A pure function with those inputs can be checked without a fixture at all. The parametrize marker turns one test into five, one per row, and pytest prints the row in the test id.

In the file test_log_root.py:

import os import pytest def log_root(home, xdg_data="", override=""): if override: path = home + override[1:] if override.startswith("~/") else override return os.path.normpath(path) base = xdg_data or os.path.join(home, ".local", "share") return os.path.join(base, "aredel", "logs") @pytest.mark.parametrize( ("home", "xdg_data", "override", "expected"), [ ("/home/user", "", "", "/home/user/.local/share/aredel/logs"), ("/home/user", "/data", "", "/data/aredel/logs"), ("/home/user", "", "~/custom-logs", "/home/user/custom-logs"), ("/home/user", "/data", "~/logs", "/home/user/logs"), ("/home/user", "", "/var/log/aredel/", "/var/log/aredel"), ], ) def test_log_root(home, xdg_data, override, expected): assert log_root(home, xdg_data, override) == expected

python -m pytest test_log_root.py -v

============================= test session starts ============================== platform linux -- Python 3.12.16, pytest-9.2.0, pluggy-1.7.0 -- … rootdir: <project> plugins: allure-pytest-2.16.2 collecting ... collected 5 items test_log_root.py::test_log_root[/home/user---/home/user/.local/share/aredel/logs] PASSED [ 20%] test_log_root.py::test_log_root[/home/user-/data--/data/aredel/logs] PASSED [ 40%] test_log_root.py::test_log_root[/home/user--~/custom-logs-/home/user/custom-logs] PASSED [ 60%] test_log_root.py::test_log_root[/home/user-/data-~/logs-/home/user/logs] PASSED [ 80%] test_log_root.py::test_log_root[/home/user--/var/log/aredel/-/var/log/aredel] PASSED [100%] ============================== 5 passed in 0.05s ===============================

The ids are ugly because the values are paths, but they are honest: a failure names the exact row. Two rows carry the behaviour that is easy to get wrong: a trailing slash is removed (/var/log/aredel/ becomes /var/log/aredel), and a leading ~/ is expanded against the given home, not against the home of the machine that runs the test.

The catch of this style is that it tests a copy of the logic. The function above lives in the test file, so the real module can drift away from it without a red test. That is why the rest of the file tests the real module, loaded from its real path.

Load the module from a path

log_paths.py is not an installed package and its folder is not on sys.path. A plain import log_paths would fail; the test loads the file by path instead. The importlib recipe is three calls: build a spec, build a module object from it, run the file in that object.

The module container/tools/analyze_logs/log_paths.py (abridged):

import os def log_root() -> str: override = os.environ.get("AREDEL_LOG_DIR", "").strip() if override: return os.path.abspath(os.path.expanduser(override)) xdg_data = os.environ.get("XDG_DATA_HOME", "").strip() base = os.path.expanduser(xdg_data) if xdg_data else os.path.join(os.path.expanduser("~"), ".local", "share") return os.path.abspath(os.path.join(base, "aredel", "logs")) def prod_dir() -> str: return os.path.join(log_root(), "prod") def prod_access_dir() -> str: return os.path.join(prod_dir(), "access")

The test file test_log_paths.py (first half):

import importlib.util from pathlib import Path ANALYZE_LOGS = Path(__file__).resolve().parent / "analyze_logs" def load_log_paths(): spec = importlib.util.spec_from_file_location("aredel_log_paths", ANALYZE_LOGS / "log_paths.py") assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def test_default_log_root_uses_xdg_data_home(monkeypatch, tmp_path): monkeypatch.delenv("AREDEL_LOG_DIR", raising=False) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) monkeypatch.setenv("HOME", str(tmp_path / "home")) log_paths = load_log_paths() assert log_paths.log_root() == str(tmp_path / "xdg" / "aredel" / "logs") assert log_paths.prod_access_dir() == str(tmp_path / "xdg" / "aredel" / "logs" / "prod" / "access") def test_override_expands_home(monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path / "home")) monkeypatch.setenv("AREDEL_LOG_DIR", "~/custom-logs") log_paths = load_log_paths() assert log_paths.log_root() == str(tmp_path / "home" / "custom-logs")

python -m pytest test_log_paths.py -v

============================= test session starts ============================== platform linux -- Python 3.12.16, pytest-9.2.0, pluggy-1.7.0 -- … rootdir: <project> plugins: allure-pytest-2.16.2 collecting ... collected 2 items test_log_paths.py::test_default_log_root_uses_xdg_data_home PASSED [ 50%] test_log_paths.py::test_override_expands_home PASSED [100%] ============================== 2 passed in 0.06s ===============================

Three details make the loader safe:

Call the shell helper

The Python module and the shell file must agree on the same formula. A test of the Python side alone would not notice a change in log-root.sh, and the shell scripts would then write the logs to a different directory than the Python reports read. The test sources the shell file in a fresh bash and asks it for the variable:

import os import subprocess from pathlib import Path ANALYZE_LOGS = Path(__file__).resolve().parent / "analyze_logs" LOG_ROOT_SH = ANALYZE_LOGS / "log-root.sh" def shell_log_root(env): result = subprocess.run( ["bash", "-c", f'source "{LOG_ROOT_SH}"; printf "%s" "$AREDEL_LOG_DIR"'], capture_output=True, check=True, env=env, text=True, ) return result.stdout def test_shell_helper_matches_python_default(tmp_path): env = {**os.environ, "XDG_DATA_HOME": str(tmp_path / "xdg"), "HOME": str(tmp_path / "home")} assert shell_log_root(env) == str(tmp_path / "xdg" / "aredel" / "logs") def test_shell_helper_expands_tilde(tmp_path): env = {**os.environ, "HOME": str(tmp_path / "home"), "AREDEL_LOG_DIR": "~/custom-logs"} assert shell_log_root(env) == str(tmp_path / "home" / "custom-logs")

python -m pytest test_log_root_sh.py -v

============================= test session starts ============================== platform linux -- Python 3.12.16, pytest-9.2.0, pluggy-1.7.0 -- … rootdir: <project> plugins: allure-pytest-2.16.2 collecting ... collected 2 items test_log_root_sh.py::test_shell_helper_matches_python_default PASSED [ 50%] test_log_root_sh.py::test_shell_helper_expands_tilde PASSED [100%] ============================== 2 passed in 0.05s ===============================

The arguments of subprocess.run carry the meaning:

printf is used instead of echo because the value is a path: echo adds a newline, and the assertion would have to strip it.

The real test in the repo

The real file combines the three ideas and adds a few lines that only the repository needs. The path is built as Path(__file__).resolve().parents[1] / "tools" / "analyze_logs", so it points outside the tests folder, and the shell test also compares against the Python module instead of a fixed string, which pins the two implementations to each other.

The test file container/tests/test_log_paths.py:

from __future__ import annotations import importlib.util import os import subprocess from pathlib import Path ANALYZE_LOGS = Path(__file__).resolve().parents[1] / "tools" / "analyze_logs" LOG_ROOT_SH = ANALYZE_LOGS / "log-root.sh" def _load_log_paths(): spec = importlib.util.spec_from_file_location("aredel_log_paths", ANALYZE_LOGS / "log_paths.py") assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def test_default_log_root_uses_xdg_data_home(monkeypatch, tmp_path): monkeypatch.delenv("AREDEL_LOG_DIR", raising=False) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) monkeypatch.setenv("HOME", str(tmp_path / "home")) log_paths = _load_log_paths() assert log_paths.log_root() == str(tmp_path / "xdg" / "aredel" / "logs") assert log_paths.prod_access_dir() == str(tmp_path / "xdg" / "aredel" / "logs" / "prod" / "access") assert log_paths.prod_error_dir() == str(tmp_path / "xdg" / "aredel" / "logs" / "prod" / "error") assert log_paths.devices_dir() == str(tmp_path / "xdg" / "aredel" / "logs" / "devices") assert log_paths.user_profiles_dir() == str(tmp_path / "xdg" / "aredel" / "logs" / "user_profiles") assert log_paths.botlist_path() == str(tmp_path / "xdg" / "aredel" / "logs" / "botlist.csv") def test_override_expands_home(monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path / "home")) monkeypatch.setenv("AREDEL_LOG_DIR", "~/custom-logs") log_paths = _load_log_paths() assert log_paths.log_root() == str(tmp_path / "home" / "custom-logs") def test_shell_helper_matches_python_default(monkeypatch, tmp_path): monkeypatch.delenv("AREDEL_LOG_DIR", raising=False) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg")) monkeypatch.setenv("HOME", str(tmp_path / "home")) log_paths = _load_log_paths() env = {**os.environ, "XDG_DATA_HOME": str(tmp_path / "xdg"), "HOME": str(tmp_path / "home")} result = subprocess.run( ["bash", "-c", f'source "{LOG_ROOT_SH}"; printf "%s" "$AREDEL_LOG_DIR"'], capture_output=True, check=True, env=env, text=True, ) assert result.stdout == log_paths.log_root()

The differences from the examples above each have a reason:

Run the file from the container directory:

./tools/run-tests-venv.sh -- tests/test_log_paths.py -v -p no:cacheprovider

============================= test session starts ============================== platform linux -- Python 3.12.16, pytest-9.2.0, pluggy-1.7.0 -- … rootdir: <project> plugins: allure-pytest-2.16.2 collecting ... collected 3 items test_log_paths.py::test_default_log_root_uses_xdg_data_home PASSED [ 33%] test_log_paths.py::test_override_expands_home PASSED [ 66%] test_log_paths.py::test_shell_helper_matches_python_default PASSED [100%] ============================== 3 passed in 0.11s ===============================

This run was made with pytest 9.2.0 on Python 3.12.16 from a scratch venv with the repository container/tests/requirements.txt installed, which is why the run prints an installed allure-pytest plugin. The -p no:cacheprovider is there because the checkout used for this run was mounted read-only and pytest could not create its cache directory; on the host ./tools/run-tests-venv.sh -- tests/test_log_paths.py -v is enough. The three tests are the three layers: a pure function checked from a table, a module loaded from a file path, and a shell script that must agree with it.

Article author: Arthur Isaev