1313import ast
1414import builtins
1515import importlib .util
16+ import logging
1617import os
1718import re
1819import shutil
@@ -151,12 +152,52 @@ def reset_mock():
151152_module_counter = 0
152153_subprocess_orig = None
153154_shutil_orig = None
155+ _ISOLATED_HOME = tempfile .mkdtemp (prefix = "lpb_test_home_" )
156+
157+
158+ class _OutputCapture :
159+ """Redirect stdout+stderr to a sink while active (suppresses expected noise).
160+
161+ Also redirects the root logger's handler streams — lpb.py's err()/warn()
162+ write through BOTH print(..., file=sys.stderr) and logger.error, and the
163+ logger handler captured the original sys.stderr at import time, so it
164+ would bypass a plain sys.stderr swap.
165+ """
166+
167+ def __init__ (self ):
168+ self .out = []
169+ self ._old = None
170+ self ._old_streams = None
171+
172+ def write (self , s ):
173+ self .out .append (s )
174+
175+ def flush (self ):
176+ pass
177+
178+ def __enter__ (self ):
179+ self ._old = (sys .stdout , sys .stderr )
180+ sys .stdout , sys .stderr = self , self
181+ self ._old_streams = [h .stream for h in logging .getLogger ().handlers ]
182+ for h in logging .getLogger ().handlers :
183+ h .stream = self
184+ return self
185+
186+ def __exit__ (self , * exc ):
187+ sys .stdout , sys .stderr = self ._old
188+ for h , stream in zip (logging .getLogger ().handlers , self ._old_streams ):
189+ h .stream = stream
190+ return False
191+
154192
155193def make_module (lpb_path : str | None = None ):
156194 """Import lpb.py with all mocks in place. Each call gets unique module name.
157195
158196 lpb_path: override which lpb source file to import (used by regression
159197 mutation tests to load a deliberately-broken copy).
198+
199+ HOME is redirected to a private temp dir, so the module never touches the
200+ real ~/.localpibox (token / last-project / last-image / state defaults).
160201 """
161202 global _module_counter , _subprocess_orig , _shutil_orig
162203 _module_counter += 1
@@ -169,10 +210,12 @@ def make_module(lpb_path: str | None = None):
169210 subprocess .run = mock_run
170211 shutil .which = mock_which
171212
172- # Remove LPB_* env vars so tests get clean defaults
213+ # Remove LPB_* env vars so tests get clean defaults, and point HOME at a
214+ # private temp dir so the module's default paths are isolated per run.
173215 for key in list (os .environ .keys ()):
174216 if key .startswith ("LPB_" ):
175217 os .environ .pop (key , None )
218+ os .environ ["HOME" ] = _ISOLATED_HOME
176219
177220 # Use unique name to bypass sys.modules cache
178221 mod_name = f"lpb_test_{ _module_counter } _{ id (make_module )} "
@@ -243,7 +286,8 @@ def test_remove():
243286 mod = make_module ()
244287 mod .parse_cli (["--remove" ])
245288 mod .apply_overrides ()
246- mod .cmd_remove ()
289+ with _OutputCapture ():
290+ mod .cmd_remove ()
247291 assert not MOCK_STATE ["exists" ], "container should be removed"
248292 print (" PASS\n " )
249293
@@ -278,26 +322,28 @@ def test_logs_missing():
278322 mod = make_module ()
279323 mod .parse_cli (["--logs" ])
280324 mod .apply_overrides ()
281- try :
282- mod .cmd_logs ()
283- except SystemExit :
284- pass
325+ with _OutputCapture ():
326+ try :
327+ mod .cmd_logs ()
328+ except SystemExit :
329+ pass
285330 print (" PASS\n " )
286331
287332
288333def test_unknown_flag ():
289334 print ("TEST: --foo-bar → error" )
290335 reset_mock ()
291336 mod = make_module ()
292- try :
293- mod .parse_cli (["--foo-bar" ])
294- mod .apply_overrides ()
295- mod .cmd_run ()
296- assert False , "should have raised an error"
297- except (SystemExit , mod .DevstackError ) as e :
298- # DevstackError (new) or SystemExit(code=1) (legacy path)
299- if isinstance (e , SystemExit ):
300- assert e .code == 1
337+ with _OutputCapture ():
338+ try :
339+ mod .parse_cli (["--foo-bar" ])
340+ mod .apply_overrides ()
341+ mod .cmd_run ()
342+ assert False , "should have raised an error"
343+ except (SystemExit , mod .DevstackError ) as e :
344+ # DevstackError (new) or SystemExit(code=1) (legacy path)
345+ if isinstance (e , SystemExit ):
346+ assert e .code == 1
301347 print (" PASS\n " )
302348
303349
@@ -571,7 +617,8 @@ def test_cmd_remove_with_dirs():
571617 orig_input = builtins .input
572618 builtins .input = lambda prompt = "" : "y"
573619 try :
574- mod .cmd_remove ()
620+ with _OutputCapture ():
621+ mod .cmd_remove ()
575622 finally :
576623 builtins .input = orig_input
577624 assert not MOCK_STATE ["exists" ], "container should be removed"
@@ -596,7 +643,8 @@ def test_cmd_remove_abort():
596643 orig_input = builtins .input
597644 builtins .input = lambda prompt = "" : "n"
598645 try :
599- mod .cmd_remove ()
646+ with _OutputCapture ():
647+ mod .cmd_remove ()
600648 finally :
601649 builtins .input = orig_input
602650 assert os .path .exists (state ), "state dir must survive an aborted remove"
@@ -620,9 +668,10 @@ def test_cmd_update_runs():
620668 mod .ContainerClient .images_pull = lambda self , name : pulled .append (name ) or 0
621669 mod .parse_cli (["--update" ])
622670 mod .apply_overrides ()
623- mod .cmd_update ()
671+ with _OutputCapture ():
672+ mod .cmd_update ()
624673 assert pulled , f"expected image pulls, got { pulled } "
625- print (f" pulled: { pulled } " )
674+ print (f" pulled: { pulled } (via mocked images_pull) " )
626675 print (" PASS\n " )
627676
628677
@@ -649,7 +698,8 @@ def spy_containers_run(*a, **kw):
649698 return ("cid123" , "cid123" , "" , 0 )
650699
651700 mod .ContainerClient .containers_run = spy_containers_run
652- mod .cmd_run ()
701+ with _OutputCapture ():
702+ mod .cmd_run ()
653703 assert mod .cfg .project_name == "tmp"
654704 env_vars = captured .get ("env" , [])
655705 env_str = "\n " .join (env_vars )
0 commit comments