functional2: forbid chdir and setting environment

These are a footgun and are not acceptable in functional2 due to thread
safety, effects on other tests, etc.

Co-authored-by: Commentator2.0 <lix@crystal-cavern.systems>

Change-Id: I8d7285061eaa9bab27edd52f3646024c8cf605e5
This commit is contained in:
Jade Lovelace
2025-10-13 18:30:51 +02:00
committed by Commentator2.0
parent 844feb17b5
commit 556012e409
10 changed files with 93 additions and 19 deletions
+6
View File
@@ -62,6 +62,12 @@ For `installcheck` specifically, first run `just install` before running the tes
Finer-grained filtering within a test suite is also possible using the [--gtest_filter](https://google.github.io/googletest/advanced.html#running-a-subset-of-the-tests) command-line option to a test suite executable, or the `GTEST_FILTER` environment variable.
### Inspecting failures
The test suite emits logs in `build/meson-logs/`; the full textual failure logs are in `build/meson-logs/testlog.txt`.
If you want a much nicer experience of viewing the logs in a structured manner, use `xunit-viewer --results build/meson-logs/testlog.junit.xml --server` to view them in a web browser.
### Unit test support libraries
There are headers and code which are not just used to test the library in question, but also downstream libraries.
@@ -1,12 +1,8 @@
import stat
import sys
import pytest
from pathlib import Path
from textwrap import dedent
from functional2.testlib.fixtures.env import ManagedEnv
from functional2.testlib.fixtures.nix import Nix
+35 -2
View File
@@ -102,6 +102,8 @@ task-tags = ["TODO", "FIXME", "XXX"]
# SIM2: double negation in comparasions (`not a == b` instead of `a != b` etc)
# SIM3: yoda conditions use `foo == "Foo"` instead of `"Foo" == foo`
# SIM9: defaults for get and zip
# TID: banning of certain imports
# TID251: ban certain APIs
# TD: enforce TODO comment style
# disabled TD001: allow FIXME and XXX comments
# disabled TD003: todos don't require an explicit issue link for us
@@ -151,7 +153,6 @@ task-tags = ["TODO", "FIXME", "XXX"]
# PYI: things for pyi files
# SLF: accessing of private members
# SLOT: subclassing of builtin-types (str, tuple, namedtuple) related issues
# TID: banning of certain imports
# TC: typechecking imports (typechecking imports required to be inside of a `if TYPECHECKING` block
# FLY: string joins
# I: import sorting; covered by other rulesets
@@ -162,7 +163,7 @@ task-tags = ["TODO", "FIXME", "XXX"]
# PGH: things about pygrep, we don't use
# FURB: covered by other rule sets
# TRY: try and raise related things, not helpful as we only do testing
select = ["E4", "E7", "E9", "F", "ERA", "ASYNC", "ANN0", "ANN2", "A", "C4", "EM", "ISC", "INP", "LOG", "G", "PIE", "T20", "PT", "Q", "RSE", "RET", "SIM", "TD", "ARG", "PTH", "N", "PERF", "PLC", "PLE", "UP", "RUF"]
select = ["E4", "E7", "E9", "F", "ERA", "ASYNC", "ANN0", "ANN2", "A", "C4", "EM", "ISC", "INP", "LOG", "G", "PIE", "T20", "PT", "Q", "RSE", "RET", "SIM", "TID251", "TD", "ARG", "PTH", "N", "PERF", "PLC", "PLE", "UP", "RUF"]
ignore = ["ANN002", "ANN003", "TD001", "TD003", "PLE1", "RUF005"]
[tool.ruff.lint.per-file-ignores]
@@ -191,3 +192,35 @@ suppress-none-returning = true
[tool.ruff.lint.flake8-errmsg]
# Allow raw strings to be up to 10 characters in error messages
max-string-length = 10
[tool.ruff.lint.flake8-tidy-imports]
[tool.ruff.lint.flake8-tidy-imports.banned-api]
# Forbid symbols that are footguns in our codebase due to unsafe global state
"os.environ".msg = """
Environment variables are process wide and are unsafe to set as they interfere with other tests. Instead, use:
- env= in subprocess functions
- functional2.testlib.commands.Command.with_env for starting subprocesses
- functional2.testlib.environ for read-only access
"""
"os.environb".msg = """
Environment variables are process wide and are unsafe to set as they interfere with other tests. Instead, use:
- env= in subprocess functions
- functional2.testlib.commands.Command.with_env for starting subprocesses
- functional2.testlib.environ for read-only access
"""
"os.putenv".msg = """
Environment variables are process wide and are unsafe to set as they interfere with other tests. Instead, use:
- env= in subprocess functions
- functional2.testlib.commands.Command.with_env for starting subprocesses
- functional2.testlib.environ for read-only access
"""
"os.unsetenv".msg = """
Environment variables are process wide and are unsafe to set as they interfere with other tests. Instead, use:
- env= in subprocess functions
- functional2.testlib.commands.Command.with_env for starting subprocesses
- functional2.testlib.environ for read-only access
"""
"os.chdir".msg = "Changing current directory is process wide and may interfere with other tests. Use cwd= while spawning a process instead."
"os.fchdir".msg = "Changing current directory is process wide and may interfere with other tests. Use cwd= while spawning a process instead."
"contextlib.chdir".msg = "Changing current directory is process wide and may interfere with other tests. Use cwd= while spawning a process instead."
+40
View File
@@ -0,0 +1,40 @@
"""
Safe (read-only) environment access. This exists entirely so that we can ban
os.environ in our codebase.
"""
import collections.abc
import os
from typing import TypeVar, overload
from collections.abc import Iterator
_T = TypeVar("_T")
class ReadonlyDict(collections.abc.Mapping[str, str]):
def __init__(self, inner: os._Environ[str]):
self.inner = inner
def __getitem__(self, key: str) -> str:
return self.inner[key]
def __iter__(self) -> Iterator[str]:
return iter(self.inner)
def __len__(self) -> int:
return len(self.inner)
@overload
def get(self, key: str, /) -> str | None: ...
@overload
def get(self, key: str, /, default: str | _T) -> str | _T: ...
def get(self, key: str, /, default: str | _T = None) -> str | _T:
return self.inner.get(key, default=default)
def copy(self) -> dict[str, str]:
return self.inner.copy()
# SAFETY: wrapped to not allow mutation
environ = ReadonlyDict(os.environ) # noqa: TID251
@@ -11,7 +11,6 @@ import pytest
from functional2.testlib.fixtures.env import ManagedEnv
from functional2.testlib.terminal_code_eater import eat_terminal_codes
logger = logging.getLogger(__name__)
+5 -4
View File
@@ -1,10 +1,11 @@
import dataclasses
import logging
import os
import platform
import shutil
from pathlib import Path
from functional2.testlib.environ import environ
import pytest
SLASHES_IN_STORE_PATH_UNTIL_PACKAGE = "/nix/store/hash-program_name/".count("/")
@@ -149,15 +150,15 @@ class _Dirs:
class ManagedEnv:
def __init__(self, tmp_path: Path):
# Things fetched from the global env
build_shell = os.environ.get("BUILD_TEST_SHELL")
global_path = os.environ.get("PATH")
build_shell = environ.get("BUILD_TEST_SHELL")
global_path = environ.get("PATH")
# `NIX_BIN_DIR` either propagated from us or set by meson
# Set to the codebase internal output if started standalone
# This is where the current lix binaries are located.
# local import to avoid cyclic dependencies
from functional2.testlib.utils import lix_base_folder # noqa: PLC0415
lix_bin = Path(os.environ.get("NIX_BIN_DIR", lix_base_folder / "outputs/out/bin"))
lix_bin = Path(environ.get("NIX_BIN_DIR", lix_base_folder / "outputs/out/bin"))
self._env = {}
self.path = _ManagedPath(build_shell)
@@ -1,6 +1,5 @@
import pytest
from _pytest.fixtures import FixtureRequest
from functional2.testlib.fixtures.command import Command
from functional2.testlib.fixtures.env import ManagedEnv
@@ -1,4 +1,3 @@
import os
from collections.abc import Callable
from logging import Logger
from pathlib import Path
@@ -9,6 +8,8 @@ import pytest
from _pytest.config import Config
from _pytest.fixtures import FixtureRequest
from functional2.testlib.environ import environ
def pytest_addoption(parser: pytest.Parser) -> None:
"""
@@ -26,9 +27,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
@pytest.fixture(scope="session")
def do_snapshot_update(request: FixtureRequest) -> bool:
return request.config.getoption("accept-tests") or (
os.environ.get("_NIX_TEST_ACCEPT") is not None
)
return request.config.getoption("accept-tests") or (environ.get("_NIX_TEST_ACCEPT") is not None)
class Snapshot:
@@ -25,6 +25,7 @@ def _get_f2_snapshot_files(additional_files: FileDeclaration) -> FileDeclaration
"functional2": {
"testlib": {
"__init__.py": CopyFile("../__init__.py"),
"environ.py": CopyFile("../environ.py"),
"fixtures": {
"__init__.py": CopyFile("__init__.py"),
"snapshot.py": CopyFile("snapshot.py"),
+3 -3
View File
@@ -1,11 +1,11 @@
import builtins
import os
import types
import typing
from pathlib import Path
from types import UnionType
from typing import Any, Literal, get_args, get_origin
from functional2.testlib.environ import environ
from functional2.testlib.fixtures.file_helper import (
CopyFile,
CopyTree,
@@ -141,9 +141,9 @@ def get_global_asset(name: str) -> Fileish:
return CopyTemplate(
functional2_base_folder / "testlib" / "global_assets" / "config.nix.template",
{
"system": os.environ.get("system"), # noqa: SIM112 # system is actually lowercase here
"system": environ.get("system"),
# Either just the build shell or entire global path if we are darwin
"path": os.environ.get("BUILD_TEST_SHELL") or os.environ.get("PATH"),
"path": environ.get("BUILD_TEST_SHELL") or environ.get("PATH"),
},
)
return CopyFile(functional2_base_folder / "testlib" / "global_assets" / name)