Skip to content

Commit

Permalink
add --interactive
Browse files Browse the repository at this point in the history
  • Loading branch information
badra001 committed Jul 8, 2026
1 parent 9133f73 commit a3defb1
Showing 1 changed file with 120 additions and 26 deletions.
146 changes: 120 additions & 26 deletions local-app/python-tools/idc-membership-check/run_user_updates.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -310,13 +344,26 @@ def run_once(
summary: UserSummary,
timeout: int,
dry_run: bool,
interactive: bool,
worker_bar: "tqdm",
overall_bar: "tqdm",
) -> RunResult:
"""
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()

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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,
)

Expand Down

0 comments on commit a3defb1

Please sign in to comment.