diff --git a/local-app/python-tools/idc-membership-check/run_user_updates.py b/local-app/python-tools/idc-membership-check/run_user_updates.py index e7ca5c81..652770d5 100755 --- a/local-app/python-tools/idc-membership-check/run_user_updates.py +++ b/local-app/python-tools/idc-membership-check/run_user_updates.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ run_user_updates.py — Execute a user provisioning command with idc_reconcile.csv. -Version: 1.0.3 +Version: 1.0.4 Reads the summary CSV produced by idc_ldap_reconcile.py to build a report of ADD, DELETE, and PENDING-ADD users, then runs a single configurable command @@ -48,9 +48,13 @@ --timeout SEC Command timeout in seconds (default: 900) --log-dir DIR Directory for the run log file; created if absent (default: logs/) + --interactive Show script output on terminal and allow terminal + input. Output is also written to the log file. + tqdm progress bars are suspended while the script + runs and restored afterwards. """ -__version__ = "1.0.3" +__version__ = "1.0.4" import argparse import csv @@ -121,6 +125,10 @@ def parse_args() -> argparse.Namespace: help="Command timeout in seconds (default: 900)") p.add_argument("--log-dir", default="logs", metavar="DIR", help="Directory for the run log file; created if absent (default: logs/)") + p.add_argument("--interactive", action="store_true", + help="Show script output on the terminal (and log file). " + "Also allows the script to receive terminal input. " + "tqdm bars are paused while the script runs.") p.add_argument("--version", action="version", version=f"%(prog)s {__version__}") return p.parse_args() @@ -300,6 +308,32 @@ def _set_lane(status: str) -> None: pass +def _tee_output(src_fd, log_fh, stop_event: threading.Event) -> None: + """ + Background thread: reads bytes from src_fd (a pipe connected to the + subprocess stdout/stderr), writes them to both sys.stdout and log_fh. + + Used in --interactive mode so output appears on the terminal in real time + while also being captured to the log file. The thread exits when + stop_event is set and src_fd reaches EOF. + """ + import io + with io.open(src_fd, "rb", buffering=0) as fh: + while not stop_event.is_set(): + try: + chunk = fh.read(256) + if not chunk: + break + text = chunk.decode("utf-8", errors="replace") + sys.stdout.write(text) + sys.stdout.flush() + if log_fh is not None: + log_fh.write(text) + log_fh.flush() + except OSError: + break + + # --------------------------------------------------------------------------- # Single-run execution # --------------------------------------------------------------------------- @@ -310,6 +344,7 @@ def run_once( summary: UserSummary, timeout: int, dry_run: bool, + interactive: bool, worker_bar: "tqdm", overall_bar: "tqdm", ) -> RunResult: @@ -317,6 +352,18 @@ def run_once( Run cmd once. The command receives the CSV paths and user counts as environment variables and is responsible for iterating over users itself. STATUS_PIPE is available for real-time status updates to the swim-lane. + + interactive=True: + - tqdm bars are paused (via tqdm.external_write_mode) before the + subprocess starts and restored when it finishes. + - subprocess stdout/stderr are tee'd: written to the terminal in real + time AND captured to the log file via a background reader thread. + - subprocess stdin inherits the terminal so prompts work normally. + - The child script requires no changes to support interactive mode. + + interactive=False (default): + - subprocess stdout/stderr go to the log file only. + - stdin is devnull (non-interactive; prompts in the script would hang). """ t0 = datetime.datetime.now() @@ -362,30 +409,75 @@ def run_once( _run_log_fh.write(f"# --- subprocess output: {cmd} ---\n") _run_log_fh.flush() - try: - proc = subprocess.run( - cmd, - shell=True, - cwd=str(Path.cwd()), - env=env, - stdout=_run_log_fh, # captured to log file only, not terminal - stderr=subprocess.STDOUT, - timeout=timeout, + if interactive: + # Pause tqdm so subprocess output prints cleanly below the bars, + # then restore the bars when the subprocess exits. + # stdin=None inherits the terminal so prompts/input work normally. + # A background tee thread mirrors stdout+stderr to the log file. + tee_stop = threading.Event() + rd_fd, wr_fd = os.pipe() + tee_thread = threading.Thread( + target=_tee_output, + args=(rd_fd, _run_log_fh, tee_stop), + daemon=True, ) - rc = proc.returncode - success = rc == 0 - except subprocess.TimeoutExpired: - rc = -2 - success = False - if _run_log_fh is not None: - _run_log_fh.write(f" TIMEOUT after {timeout}s\n") - _run_log_fh.flush() - except Exception as exc: - rc = -3 - success = False - if _run_log_fh is not None: - _run_log_fh.write(f" ERROR: {exc}\n") - _run_log_fh.flush() + tee_thread.start() + wr_file = os.fdopen(wr_fd, "wb", buffering=0) + + try: + with tqdm.external_write_mode(file=sys.stderr, nolock=False): + proc = subprocess.run( + cmd, + shell=True, + cwd=str(Path.cwd()), + env=env, + stdout=wr_file, + stderr=subprocess.STDOUT, + stdin=None, # inherit terminal — allows prompts + timeout=timeout, + ) + rc = proc.returncode + success = rc == 0 + except subprocess.TimeoutExpired: + rc = -2 + success = False + except Exception as exc: + rc = -3 + success = False + if _run_log_fh is not None: + _run_log_fh.write(f" ERROR: {exc}\n") + finally: + wr_file.close() # signals EOF to the tee thread + tee_stop.set() + tee_thread.join(timeout=3) + else: + # Non-interactive: stdout+stderr captured to log file only. + # stdin closed so any accidental prompts fail immediately. + try: + proc = subprocess.run( + cmd, + shell=True, + cwd=str(Path.cwd()), + env=env, + stdout=_run_log_fh, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + timeout=timeout, + ) + rc = proc.returncode + success = rc == 0 + except subprocess.TimeoutExpired: + rc = -2 + success = False + if _run_log_fh is not None: + _run_log_fh.write(f" TIMEOUT after {timeout}s\n") + _run_log_fh.flush() + except Exception as exc: + rc = -3 + success = False + if _run_log_fh is not None: + _run_log_fh.write(f" ERROR: {exc}\n") + _run_log_fh.flush() t1 = datetime.datetime.now() elapsed = (t1 - t0).total_seconds() @@ -470,6 +562,8 @@ def print_header( out(f" Timeout : {args.timeout}s") if args.dry_run: out(f" Mode : DRY-RUN") + if args.interactive: + out(f" Mode : INTERACTIVE (output on terminal + log file)") out(bar) out(f" Users to ADD : {summary.add_count:>5}") out(f" Users to DELETE : {summary.delete_count:>5}") @@ -576,7 +670,7 @@ def main() -> None: result = run_once( args.cmd, args.summary_csv, args.csv_file, - summary, args.timeout, args.dry_run, + summary, args.timeout, args.dry_run, args.interactive, worker_bar, overall_bar, )