import os
import shutil
from pathlib import Path
from platformdirs import user_cache_path, user_config_path, user_data_path
# Future-proofing: pyfakefs will be used in TestRootEnvironment
# import pyfakefs.fake_filesystem_unittest as fake_fs
[docs]
class TestHomeEnvironment:
"""
Manages a physical test directory on the real filesystem.
This class provides a sandbox by redirecting the user's HOME and
related environment variables to a specific test directory. This
isolates the developer's actual system from side effects during
test execution.
"""
[docs]
def __init__(self, base_dir: Path):
"""
Initialize the environment paths.
:param base_dir: Path to the directory acting as the test anchor.
"""
self._base_dir = base_dir.resolve()
self._input_dir = self._base_dir / "testinput"
self._output_dir = self._base_dir / "testoutput"
self._doc_inc = self._base_dir / "testdocinc"
self._orig_cwd = Path.cwd()
self._orig_env = {}
self._do_not_clean=False
def __repr__(self) -> str:
return f"{self.__class__.__name__}(base_dir={self._base_dir!r})"
@property
def docinclude(self) -> Path:
return self._doc_inc
@property
def base_dir(self) -> Path:
"""
The root of the test environment **(ro)**.
"""
return self._base_dir
@property
def HOME(self) -> Path:
"""
Alias for base_dir to provide intuitive access to the simulated HOME **(ro)**.
"""
return self.base_dir
@property
def input_dir(self) -> Path:
"""
Read-only directory containing Git-tracked test files **(ro)**.
"""
return self._input_dir
@property
def output_dir(self) -> Path:
"""
Writable directory for test execution **(ro)**.
"""
return self._output_dir
@property
def input_readonly(self) -> bool:
"""
Control the write permissions of the testinput directory **(rw)**.
:param value: Set to True to make the directory read-only, False for writable (Setter).
"""
return not os.access(self.input_dir, os.W_OK)
@input_readonly.setter
def input_readonly(self, value: bool) -> None:
"""
Control the write permissions of the testinput directory **(rw)**.
:param value: Set to True to make the directory read-only, False for writable.
:raises OSError: If the file mode cannot be changed (Setter).
"""
mode = 0o555 if value else 0o755
os.chmod(self.input_dir, mode)
@property
def do_not_clean(self) -> bool:
"""
Toggle the automatic cleaning of the test directory (**rw**).
:param value: The boolean state to enable or disable cleaning.
:returns: The current state of the cleaning lock.
"""
return self._do_not_clean
@do_not_clean.setter
def do_not_clean(self, value: bool) -> None:
"""
Toggle the automatic cleaning of the test directory (**rw**).
:param value: The boolean state to enable or disable cleaning.
:returns: The current state of the cleaning lock.
"""
self._do_not_clean = bool(value)
[docs]
def setup(self, clean_output: bool = True) -> None:
"""
Prepare the environment, redirect HOME, and switch to output_dir.
:param clean_output: If True, existing output files are deleted.
:raises OSError: If directories cannot be created or deleted.
"""
self.base_dir.mkdir(parents=True, exist_ok=True)
self.input_dir.mkdir(parents=True, exist_ok=True)
if clean_output and self.output_dir.exists():
shutil.rmtree(self.output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
env_to_redirect = ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]
env_to_neutralize = [
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME",
]
for var in env_to_redirect + env_to_neutralize:
if var in os.environ:
self._orig_env[var] = os.environ[var]
if var in env_to_redirect:
os.environ[var] = str(self.base_dir)
else:
if var in os.environ:
del os.environ[var]
os.chdir(self.output_dir)
def _copy_to_user_dir(
self, app_name: str, source_name: str, target_name: str, get_path_func
) -> Path:
"""
Internal helper for deploying files from testinput to user directories.
:param app_name: Name of the application.
:param source_name: Filename inside input_dir.
:param target_name: Optional new name at the destination.
:param get_path_func: Function to retrieve the target platform path.
:raises FileNotFoundError: If the source file is missing.
:raises OSError: If the copy operation fails.
:returns: The path to the newly created file.
"""
source_path = self.input_dir / source_name
if not source_path.exists():
raise FileNotFoundError(f"Source file {source_name} not found in {self.input_dir}")
target_dir = get_path_func(app_name)
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / (target_name or source_name)
shutil.copy2(source_path, target_path)
return target_path
[docs]
def copy2config(self, app_name: str, source_name: str, target_name: str = None) -> Path:
"""
Copy a file from testinput to the OS-specific user config directory.
:param app_name: Name of the application.
:param source_name: Filename inside input_dir.
:param target_name: Optional new name at the destination.
:raises FileNotFoundError: If the source file is missing.
:returns: The path to the newly created configuration file.
"""
return self._copy_to_user_dir(app_name, source_name, target_name, user_config_path)
[docs]
def copy2data(self, app_name: str, source_name: str, target_name: str = None) -> Path:
"""
Copy a file from testinput to the OS-specific user data directory.
:param app_name: Name of the application.
:param source_name: Filename inside input_dir.
:param target_name: Optional new name at the destination.
:raises FileNotFoundError: If the source file is missing.
:returns: The path to the newly created data file.
"""
return self._copy_to_user_dir(app_name, source_name, target_name, user_data_path)
[docs]
def copy2cache(self, app_name: str, source_name: str, target_name: str = None) -> Path:
"""
Copy a file from testinput to the OS-specific user cache directory.
:param app_name: Name of the application.
:param source_name: Filename inside input_dir.
:param target_name: Optional new name at the destination.
:raises FileNotFoundError: If the source file is missing.
:returns: The path to the newly created cache file.
"""
return self._copy_to_user_dir(app_name, source_name, target_name, user_cache_path)
[docs]
def copy2cwd(self, source_name: str, target_name: str = None) -> Path:
"""
Copy a file from testinput directly to the current working directory.
As setup() changes the CWD to output_dir, this method places files
directly into the active test sandbox.
:param source_name: Filename inside input_dir.
:param target_name: Optional new name in the current directory.
:raises FileNotFoundError: If the source file is missing.
:returns: The path to the newly created file in the CWD.
"""
source_path = self.input_dir / source_name
if not source_path.exists():
raise FileNotFoundError(f"Source file {source_name} not found in {self.input_dir}")
target_path = Path.cwd() / (target_name or source_name)
shutil.copy2(source_path, target_path)
return target_path
[docs]
def cwd2doc_inc(self, filename: str | Path, target_name: str | None = None) -> Path:
"""
Copies a file from the current working directory (CWD) to the
documentation includes directory (testdocinc).
This allows persisting files generated during tests (like patches
or configurations) for use in Sphinx documentation, even if the
CWD is cleaned up later.
:param filename: Name or path of the source file in the CWD.
:param target_name: Optional new name for the destination file.
:return: The path to the copied file in the 'testdocinc' directory.
:raises FileNotFoundError: If the source file does not exist in the CWD.
"""
source = Path.cwd() / filename
if not source.exists():
raise FileNotFoundError(f"Source file for doc include not found: {source}")
# Ensure the target directory exists
self._doc_inc.mkdir(parents=True, exist_ok=True)
target_filename = target_name if target_name else source.name
target_path = self._doc_inc / target_filename
shutil.copy2(source, target_path)
return target_path
[docs]
def teardown(self) -> None:
"""
Restore the original environment variables and working directory.
"""
os.chdir(self._orig_cwd)
for var, value in self._orig_env.items():
os.environ[var] = value
[docs]
def clean_home(self) -> None:
"""
Remove all files and directories from the simulated HOME except testinput.
This method cleans the sandbox while preserving the static input files
required for further tests. The cleaning process can be suppressed by
setting the property **do_not_clean** to True. If the property is
active, calling this method will have no effect on the file system.
"""
if self.do_not_clean:
return
for item in self.base_dir.iterdir():
if item == self.input_dir or item == self.docinclude:
continue
if item.is_dir() and item != self.output_dir:
shutil.rmtree(item)
elif item == self.output_dir:
pass
else:
item.unlink()
[docs]
class TestRootEnvironment:
"""
Placeholder for future pyfakefs-based system simulation.
"""
[docs]
def __init__(self):
raise NotImplementedError
def __repr__(self) -> str:
return f"{self.__class__.__name__}()"
if __name__ == "__main__": # pragma: no cover
from doctest import testfile, FAIL_FAST # noqa: I001
from pathlib import Path
import sys
# Adds the project's root directory (the module source directory)
# to the beginning of sys.path.
project_root = Path(__file__).resolve().parent.parent
print(project_root)
sys.path.insert(0, str(project_root))
be_verbose = False
be_verbose = True
option_flags = 0
option_flags = FAIL_FAST
testfilesbasedir = Path("../../../doc/source/devel")
test_sum = 0
test_failed = 0
dt_file = str(testfilesbasedir / "get_started_ftw_testinfra.rst")
# dt_file = str(testfilesbasedir / "temp_test.rst")
# dt_file = str(testfilesbasedir / "test_parser_fix.rst")
# dt_file = str(testfilesbasedir / "parser_validation.txt")
print(dt_file)
doctestresult = testfile(
dt_file,
# "../../doc/source/devel/get_started_ftw_patch.rst",
optionflags=option_flags,
verbose=be_verbose,
)
test_failed += doctestresult.failed
test_sum += doctestresult.attempted
# doctestresult = testfile(
# str(testfilesbasedir / "ftw_patch.rst"),
# optionflags=option_flags,
# verbose=be_verbose,
# )
# test_failed += doctestresult.failed
# test_sum += doctestresult.failed
if test_failed == 0:
print(f"DocTests passed without errors, {test_sum} tests.")
else:
print(f"DocTests failed: {test_failed} tests.")