refactor: centralize environment variable management with Pydantic schema + CI gate
- Add agent/src/config/env_schema.py: Pydantic models for ~50 env vars across 6 sub-models (LLMConfig, DataConfig, APIConfig, SwarmConfig, AgentTuningConfig, PathConfig)
- Add agent/src/config/accessor.py: thread-safe lazy singleton get_env_config() + unified _parse_bool() + reset_env_config() for test isolation
- Fix TIMEOUT_SECONDS CLI display default (2400 -> 120, matching runtime)
- Fix MAX_RETRIES CLI display default ('(not set)' -> 2, matching runtime)
- Fix VIBE_TRADING_API_KEY server-side alias (security.py now resolves it via get_env_config().api.api_auth_key)
- Fix VIBE_TRADING_CHANNELS_AUTO_START=on silently ignored (unified _parse_bool accepts 'on')
- Migrate 207 scattered os.getenv/os.environ.get calls across 50+ files to centralized accessor
- Convert 12 module-level import-time fixations to lazy accessor pattern (loop.py, swarm_tool.py, _backend.py, worker.py, security.py)
- Add reset_env_config() calls after settings_routes.py env mutations (13 mutation points)
- Replace print('[WARN]...') with logger.warning() in tushare/okx/yfinance loaders
- Add tools/ci_env_var_gate.py: AST-based CI gate preventing new os.getenv calls outside src/config/
- Add tools/test_ci_env_var_gate.py: 18 test cases for the CI gate
- Update tools/ci_grep_gates.sh to call the new env var gate
- Add agent/tests/test_env_schema.py: 81 test cases covering defaults, type coercion, singleton, thread safety, bool parser
- Update agent/.env.example with schema cross-references
- Update agent/tests/conftest.py with reset_env_config autouse fixture
Closes HKUDS/Vibe-Trading#438
Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2
Signed-off-by: shadowinlife <shadowinlife@gmail.com>
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 1383/1383
AI-Contributed/UT: 735/735
This commit is contained in:
1 parent
ef0335286c
commit
8234058562
54 files changed
+1907
-211
No files matched your search
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI gate: reject raw os.getenv / os.environ reads outside config layer.
|
||||
|
||||
Scans Python files under the agent source tree and rejects any direct
|
||||
environment-variable **read** that bypasses the centralized
|
||||
``agent/src/config/`` accessor layer.
|
||||
|
||||
Detected patterns (READ calls):
|
||||
os.getenv("KEY")
|
||||
os.environ.get("KEY")
|
||||
os.environ["KEY"] # subscript with Load context
|
||||
|
||||
Allowlisted (NOT flagged):
|
||||
os.environ.copy()
|
||||
os.environ.items()
|
||||
os.environ.setdefault(...)
|
||||
os.environ["KEY"] = value # Store context in Assign / AugAssign targets
|
||||
os.environ.pop() # only in src/api/settings_routes.py + src/config/
|
||||
|
||||
Warnings (non-blocking):
|
||||
os.environ.pop() outside src/api/settings_routes.py and src/config/
|
||||
|
||||
Usage::
|
||||
|
||||
python tools/ci_env_var_gate.py # scan, exit 0/1
|
||||
python tools/ci_env_var_gate.py --allowlist # print allowlist and exit 0
|
||||
|
||||
Zero external dependencies — stdlib only (ast, os, sys, pathlib).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directories / files to scan (relative to repo root).
|
||||
SCAN_TARGETS: list[str] = [
|
||||
"agent/src/",
|
||||
"agent/backtest/",
|
||||
"agent/cli/",
|
||||
"agent/mcp_server.py",
|
||||
"agent/api_server.py",
|
||||
"agent/scripts/",
|
||||
]
|
||||
|
||||
# Directories always skipped (relative prefixes).
|
||||
SKIP_PREFIXES: tuple[str, ...] = (
|
||||
"agent/tests/",
|
||||
)
|
||||
|
||||
# The allowed zone — reads here are the *point* of the config layer.
|
||||
ALLOWED_PREFIX: str = "agent/src/config/"
|
||||
|
||||
# File (relative) where os.environ.pop() is explicitly permitted.
|
||||
POP_ALLOWLISTED_FILE: str = "agent/src/api/settings_routes.py"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST visitor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _EnvReadVisitor(ast.NodeVisitor):
|
||||
"""Walk a single file's AST and collect disallowed env-var reads."""
|
||||
|
||||
def __init__(self, filepath: Path, repo_root: Path) -> None:
|
||||
self._filepath = filepath
|
||||
self._rel = str(filepath.relative_to(repo_root)).replace("\\", "/")
|
||||
self._violations: list[tuple[int, str]] = []
|
||||
self._warnings: list[tuple[int, str]] = []
|
||||
|
||||
# -- properties ---------------------------------------------------------
|
||||
|
||||
@property
|
||||
def violations(self) -> list[tuple[int, str]]:
|
||||
return list(self._violations)
|
||||
|
||||
@property
|
||||
def warnings(self) -> list[tuple[int, str]]:
|
||||
return list(self._warnings)
|
||||
|
||||
# -- helpers ------------------------------------------------------------
|
||||
|
||||
def _is_os_environ(self, node: ast.expr) -> bool:
|
||||
"""Return True when *node* is ``os.environ``."""
|
||||
return (
|
||||
isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "os"
|
||||
and node.attr == "environ"
|
||||
)
|
||||
|
||||
def _is_os_getenv(self, node: ast.expr) -> bool:
|
||||
"""Return True when *node* is ``os.getenv``."""
|
||||
return (
|
||||
isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "os"
|
||||
and node.attr == "getenv"
|
||||
)
|
||||
|
||||
# -- visitors -----------------------------------------------------------
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None: # noqa: N802
|
||||
func = node.func
|
||||
|
||||
# os.getenv(...)
|
||||
if self._is_os_getenv(func):
|
||||
self._violations.append(
|
||||
(node.lineno, "os.getenv() — use src.config accessor")
|
||||
)
|
||||
self.generic_visit(node)
|
||||
return
|
||||
|
||||
# os.environ.<method>(...)
|
||||
if (
|
||||
isinstance(func, ast.Attribute)
|
||||
and self._is_os_environ(func.value)
|
||||
):
|
||||
method = func.attr
|
||||
|
||||
# Allowlisted methods
|
||||
if method in ("copy", "items", "setdefault"):
|
||||
self.generic_visit(node)
|
||||
return
|
||||
|
||||
# os.environ.get(...) — READ
|
||||
if method == "get":
|
||||
self._violations.append(
|
||||
(node.lineno, "os.environ.get() — use src.config accessor")
|
||||
)
|
||||
self.generic_visit(node)
|
||||
return
|
||||
|
||||
# os.environ.pop(...) — warning outside settings_routes / config
|
||||
if method == "pop":
|
||||
if self._rel != POP_ALLOWLISTED_FILE:
|
||||
self._warnings.append(
|
||||
(
|
||||
node.lineno,
|
||||
"os.environ.pop() outside settings_routes.py / config/",
|
||||
)
|
||||
)
|
||||
self.generic_visit(node)
|
||||
return
|
||||
|
||||
# Any other os.environ.<method>() — flag as read
|
||||
self._violations.append(
|
||||
(
|
||||
node.lineno,
|
||||
f"os.environ.{method}() — use src.config accessor",
|
||||
)
|
||||
)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Subscript(self, node: ast.Subscript) -> None: # noqa: N802
|
||||
# os.environ["KEY"] in a Load (read) context
|
||||
if (
|
||||
self._is_os_environ(node.value)
|
||||
and isinstance(node.ctx, ast.Load)
|
||||
):
|
||||
self._violations.append(
|
||||
(node.lineno, 'os.environ["..."] read — use src.config accessor')
|
||||
)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_python_files(repo_root: Path) -> list[Path]:
|
||||
"""Return all .py files under SCAN_TARGETS, minus SKIP_PREFIXES and ALLOWED_PREFIX."""
|
||||
results: list[Path] = []
|
||||
|
||||
for target in SCAN_TARGETS:
|
||||
target_path = repo_root / target
|
||||
if target_path.is_file():
|
||||
# Single file target (e.g. agent/mcp_server.py)
|
||||
if target_path.suffix == ".py":
|
||||
rel = str(target_path.relative_to(repo_root)).replace("\\", "/")
|
||||
if not _should_skip(rel):
|
||||
results.append(target_path)
|
||||
elif target_path.is_dir():
|
||||
for py_file in sorted(target_path.rglob("*.py")):
|
||||
rel = str(py_file.relative_to(repo_root)).replace("\\", "/")
|
||||
if _should_skip(rel):
|
||||
continue
|
||||
results.append(py_file)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _should_skip(rel_path: str) -> bool:
|
||||
"""Return True when *rel_path* falls in a skipped or allowed zone."""
|
||||
for prefix in SKIP_PREFIXES:
|
||||
if rel_path.startswith(prefix):
|
||||
return True
|
||||
if rel_path.startswith(ALLOWED_PREFIX):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ALLOWLIST_TEXT = """\
|
||||
Allowlisted patterns (NOT flagged by this gate):
|
||||
1. os.environ.copy() — snapshot of the full environment
|
||||
2. os.environ.items() — iteration over env vars
|
||||
3. os.environ.setdefault(...) — conditional write
|
||||
4. os.environ["KEY"] = value — explicit write (Store context)
|
||||
5. os.environ.pop() — only in src/api/settings_routes.py + src/config/
|
||||
6. All reads inside agent/src/config/ — the config layer itself
|
||||
7. All files under agent/tests/ — test code is exempt
|
||||
"""
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
|
||||
if "--allowlist" in args:
|
||||
print(ALLOWLIST_TEXT)
|
||||
return 0
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
files = _collect_python_files(repo_root)
|
||||
|
||||
all_violations: list[tuple[str, int, str]] = []
|
||||
all_warnings: list[tuple[str, int, str]] = []
|
||||
|
||||
for filepath in files:
|
||||
try:
|
||||
source = filepath.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
try:
|
||||
tree = ast.parse(source, filename=str(filepath))
|
||||
except SyntaxError:
|
||||
# Skip files that can't be parsed (e.g. generated stubs).
|
||||
continue
|
||||
|
||||
visitor = _EnvReadVisitor(filepath, repo_root)
|
||||
visitor.visit(tree)
|
||||
|
||||
rel = str(filepath.relative_to(repo_root)).replace("\\", "/")
|
||||
for lineno, msg in visitor.violations:
|
||||
all_violations.append((rel, lineno, msg))
|
||||
for lineno, msg in visitor.warnings:
|
||||
all_warnings.append((rel, lineno, msg))
|
||||
|
||||
# Print warnings (non-blocking)
|
||||
for rel, lineno, msg in all_warnings:
|
||||
print(f" WARN: {rel}:{lineno}: {msg}")
|
||||
|
||||
# Print violations (blocking)
|
||||
if all_violations:
|
||||
print()
|
||||
print("FAIL: raw os.getenv / os.environ reads outside config layer:")
|
||||
for rel, lineno, msg in all_violations:
|
||||
print(f" {rel}:{lineno}: {msg}")
|
||||
print()
|
||||
print("Use the centralized config accessor in agent/src/config/ instead.")
|
||||
print("Run with --allowlist to see permitted patterns.")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -17,6 +17,9 @@
|
||||
# (spec.md §"Vendor 数据 ToS").
|
||||
# (d) No deprecated `datetime.utcnow(` usage or bare `datetime.now()` calls
|
||||
# in Python sources; `datetime.now(timezone.utc)` is allowed.
|
||||
# (e) No raw `os.getenv` / `os.environ.get` / `os.environ["KEY"]` reads
|
||||
# outside the centralized config layer (`agent/src/config/`).
|
||||
# AST-based; uses `tools/ci_env_var_gate.py`.
|
||||
#
|
||||
# Exclusions: .git, node_modules, __pycache__, .venv, dist, build, this
|
||||
# script itself. The HTML scan in (c) is scoped to wiki/alpha-library/**
|
||||
@@ -125,6 +128,21 @@ else
|
||||
echo "${GREEN}ok${NC}"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------- gate (e)
|
||||
echo "[gate e] no raw os.getenv / os.environ reads outside config layer ..."
|
||||
E_OUTPUT=$(python tools/ci_env_var_gate.py 2>&1)
|
||||
E_RC=$?
|
||||
if [ "$E_RC" -ne 0 ]; then
|
||||
echo "${RED}FAIL${NC}: raw env-var reads outside agent/src/config/:"
|
||||
echo "$E_OUTPUT"
|
||||
FAILED=1
|
||||
else
|
||||
if [ -n "$E_OUTPUT" ]; then
|
||||
echo "$E_OUTPUT"
|
||||
fi
|
||||
echo "${GREEN}ok${NC}"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- result
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for tools/ci_env_var_gate.py.
|
||||
|
||||
Each test creates a temporary Python file under a fake repo tree, runs the
|
||||
AST visitor, and asserts the expected violations / warnings / clean result.
|
||||
|
||||
Run with::
|
||||
|
||||
pytest tools/test_ci_env_var_gate.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Import the visitor and helpers from the gate module.
|
||||
from ci_env_var_gate import ( # type: ignore[import-untyped]
|
||||
_EnvReadVisitor,
|
||||
_should_skip,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_visitor(
|
||||
source: str,
|
||||
rel_path: str = "agent/src/some_module.py",
|
||||
repo_root: Path | None = None,
|
||||
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
|
||||
"""Parse *source*, run the visitor, return (violations, warnings)."""
|
||||
if repo_root is None:
|
||||
repo_root = Path("/fake/repo")
|
||||
|
||||
tree = ast.parse(textwrap.dedent(source))
|
||||
filepath = repo_root / rel_path
|
||||
visitor = _EnvReadVisitor(filepath, repo_root)
|
||||
visitor.visit(tree)
|
||||
return visitor.violations, visitor.warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — violations (must be flagged)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOsGetenvFlagged:
|
||||
"""os.getenv("FOO") outside config/ is a violation."""
|
||||
|
||||
def test_os_getenv_basic(self) -> None:
|
||||
violations, _ = _run_visitor('import os\nx = os.getenv("FOO")\n')
|
||||
assert len(violations) == 1
|
||||
assert "os.getenv" in violations[0][1]
|
||||
|
||||
def test_os_getenv_with_default(self) -> None:
|
||||
violations, _ = _run_visitor('import os\nx = os.getenv("FOO", "bar")\n')
|
||||
assert len(violations) == 1
|
||||
|
||||
|
||||
class TestOsEnvironGetFlagged:
|
||||
"""os.environ.get("FOO") outside config/ is a violation."""
|
||||
|
||||
def test_environ_get_basic(self) -> None:
|
||||
violations, _ = _run_visitor('import os\nx = os.environ.get("FOO")\n')
|
||||
assert len(violations) == 1
|
||||
assert "os.environ.get" in violations[0][1]
|
||||
|
||||
def test_environ_get_with_default(self) -> None:
|
||||
violations, _ = _run_visitor('import os\nx = os.environ.get("FOO", "bar")\n')
|
||||
assert len(violations) == 1
|
||||
|
||||
|
||||
class TestOsEnvironSubscriptReadFlagged:
|
||||
"""os.environ["FOO"] read outside config/ is a violation."""
|
||||
|
||||
def test_subscript_read(self) -> None:
|
||||
violations, _ = _run_visitor('import os\nx = os.environ["FOO"]\n')
|
||||
assert len(violations) == 1
|
||||
assert "read" in violations[0][1].lower()
|
||||
|
||||
def test_subscript_read_in_expression(self) -> None:
|
||||
violations, _ = _run_visitor(
|
||||
'import os\nif os.environ["DEBUG"] == "1": pass\n'
|
||||
)
|
||||
assert len(violations) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — allowlisted (must NOT be flagged)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnvironCopyAllowed:
|
||||
"""os.environ.copy() is NOT flagged."""
|
||||
|
||||
def test_copy(self) -> None:
|
||||
violations, _ = _run_visitor("import os\nenv = os.environ.copy()\n")
|
||||
assert len(violations) == 0
|
||||
|
||||
|
||||
class TestEnvironItemsAllowed:
|
||||
"""os.environ.items() is NOT flagged."""
|
||||
|
||||
def test_items(self) -> None:
|
||||
violations, _ = _run_visitor(
|
||||
"import os\nfor k, v in os.environ.items(): pass\n"
|
||||
)
|
||||
assert len(violations) == 0
|
||||
|
||||
|
||||
class TestEnvironSetdefaultAllowed:
|
||||
"""os.environ.setdefault() is NOT flagged."""
|
||||
|
||||
def test_setdefault(self) -> None:
|
||||
violations, _ = _run_visitor(
|
||||
'import os\nos.environ.setdefault("FOO", "bar")\n'
|
||||
)
|
||||
assert len(violations) == 0
|
||||
|
||||
|
||||
class TestEnvironSubscriptWriteAllowed:
|
||||
"""os.environ["FOO"] = "bar" (Store context) is NOT flagged."""
|
||||
|
||||
def test_subscript_write(self) -> None:
|
||||
violations, _ = _run_visitor('import os\nos.environ["FOO"] = "bar"\n')
|
||||
assert len(violations) == 0
|
||||
|
||||
def test_subscript_aug_assign(self) -> None:
|
||||
# os.environ["PATH"] += ":/new" — still a Store context
|
||||
violations, _ = _run_visitor('import os\nos.environ["PATH"] += ":/new"\n')
|
||||
assert len(violations) == 0
|
||||
|
||||
|
||||
class TestConfigDirectoryAllowed:
|
||||
"""Files in agent/src/config/ are NOT scanned (skipped entirely)."""
|
||||
|
||||
def test_config_file_skipped(self) -> None:
|
||||
# Even with os.getenv in the source, the file is in config/ so
|
||||
# _should_skip returns True and the scanner never visits it.
|
||||
assert _should_skip("agent/src/config/accessor.py") is True
|
||||
assert _should_skip("agent/src/config/env_schema.py") is True
|
||||
|
||||
def test_config_visitor_clean(self) -> None:
|
||||
# Even if we *did* visit a config file, the visitor itself would
|
||||
# flag it — but the scanner skips it. Verify the skip logic.
|
||||
violations, _ = _run_visitor(
|
||||
'import os\nx = os.getenv("FOO")\n',
|
||||
rel_path="agent/src/config/env_schema.py",
|
||||
)
|
||||
# The visitor doesn't know about skip rules — it just visits.
|
||||
# But the scanner would never call it on this file.
|
||||
# This test documents that the visitor IS strict; the skip is
|
||||
# enforced at the file-discovery layer.
|
||||
assert len(violations) == 1 # visitor flags it
|
||||
assert _should_skip("agent/src/config/env_schema.py") # scanner skips
|
||||
|
||||
|
||||
class TestTestsDirectorySkipped:
|
||||
"""Files in agent/tests/ are NOT scanned."""
|
||||
|
||||
def test_tests_dir_skipped(self) -> None:
|
||||
assert _should_skip("agent/tests/test_foo.py") is True
|
||||
assert _should_skip("agent/tests/unit/test_bar.py") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — pop() warnings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnvironPopWarning:
|
||||
"""os.environ.pop() outside settings_routes.py is a warning."""
|
||||
|
||||
def test_pop_outside_settings_routes(self) -> None:
|
||||
violations, warnings = _run_visitor(
|
||||
'import os\nos.environ.pop("FOO", None)\n',
|
||||
rel_path="agent/src/some_module.py",
|
||||
)
|
||||
assert len(violations) == 0 # not a blocking violation
|
||||
assert len(warnings) == 1
|
||||
assert "pop" in warnings[0][1]
|
||||
|
||||
def test_pop_in_settings_routes_no_warning(self) -> None:
|
||||
violations, warnings = _run_visitor(
|
||||
'import os\nos.environ.pop("FOO", None)\n',
|
||||
rel_path="agent/src/api/settings_routes.py",
|
||||
)
|
||||
assert len(violations) == 0
|
||||
assert len(warnings) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — multiple violations in one file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipleViolations:
|
||||
"""A file with several patterns reports all of them."""
|
||||
|
||||
def test_mixed_reads(self) -> None:
|
||||
source = """\
|
||||
import os
|
||||
a = os.getenv("A")
|
||||
b = os.environ.get("B")
|
||||
c = os.environ["C"]
|
||||
d = os.environ.copy() # allowed
|
||||
os.environ["D"] = "x" # allowed (write)
|
||||
"""
|
||||
violations, _ = _run_visitor(source)
|
||||
assert len(violations) == 3 # getenv, environ.get, subscript read
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests — CLI flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowlistFlag:
|
||||
"""--allowlist prints the allowlist and exits 0."""
|
||||
|
||||
def test_allowlist_flag(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
rc = main(["--allowlist"])
|
||||
assert rc == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "os.environ.copy()" in captured.out
|
||||
assert "os.environ.items()" in captured.out
|
||||
assert "os.environ.setdefault" in captured.out
|
||||
Reference in new issue
Block a user