utils_devops 0.1.128__py3-none-any.whl → 0.1.130__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import shutil
11
11
  import time
12
12
  import socket
13
13
  import getpass
14
+ import threading
14
15
  import ctypes # For Windows admin check
15
16
  from typing import Optional, List, Dict, Union, Any, Callable, Tuple
16
17
  from rich.console import Console
@@ -293,157 +294,301 @@ def run(
293
294
  elevated: bool = False,
294
295
  capture: bool = True,
295
296
  logger: Optional[logger] = None,
296
- stream: bool = False # NEW: Add streaming option
297
+ stream: bool = False
297
298
  ) -> subprocess.CompletedProcess:
298
299
  """
299
- Core run() smart about shell and elevation.
300
- If elevated=True on Unix, the cached sudo password is used (prompted once per process).
301
- If sudo authentication fails we clear the cached password and raise a CalledProcessError
302
- with a helpful message.
303
-
304
- NEW: If stream=True, output is streamed in real-time to the logger.
305
- Returns subprocess.CompletedProcess
300
+ Robust run() with cross-platform threaded streaming + smart carriage-return handling.
301
+
302
+ - stream=True : streams output in real-time using threads, understands '\r' updates.
303
+ - capture controls whether stdout/stderr are returned in CompletedProcess (when stream=True,
304
+ capture=True will also collect into strings).
305
+ - If streaming fails for any reason we fallback to communicate() to collect remaining output.
306
+ - Works on Linux and Windows.
306
307
  """
307
- logger = logger or DEFAULT_LOGGER
308
+ logger = logger or DEFAULT_LOGGER
309
+
308
310
  if shell is None:
309
311
  shell = isinstance(cmd, str)
310
- # Normalize cmd into either str or list form for subprocess
312
+
313
+ # Normalize command forms
311
314
  if isinstance(cmd, (list, tuple)):
312
315
  cmd_str = subprocess.list2cmdline(cmd)
313
316
  cmd_list: Union[List[str], str] = list(cmd)
314
317
  else:
315
318
  cmd_str = str(cmd)
316
319
  cmd_list = cmd_str if shell else [cmd_str]
320
+
317
321
  if dry_run:
318
322
  logger.info(f"[DRY-RUN] {cmd_str}")
319
323
  return subprocess.CompletedProcess(cmd_list if not shell else cmd_str, 0, stdout="", stderr="")
324
+
320
325
  stdin_input: Optional[str] = None
321
326
  use_list: Union[List[str], str] = cmd_list
327
+
328
+ # Determine platform
329
+ try:
330
+ is_win = (is_windows())
331
+ except NameError:
332
+ is_win = (os.name == "nt") or sys.platform.startswith("win")
333
+
334
+ # Handle elevated
322
335
  if elevated:
323
- if is_windows():
324
- # For Windows, use Start-Process -Verb RunAs via powershell
336
+ if is_win:
337
+ # Windows: use Start-Process via powershell RunAs
325
338
  ps = f"Start-Process -Verb RunAs -FilePath powershell -ArgumentList '-NoProfile','-Command','{cmd_str}' -Wait -PassThru"
326
339
  use_list = ["powershell", "-NoProfile", "-Command", ps]
327
340
  shell = False
328
341
  else:
329
- # Unix: prepend sudo -S and provide password via stdin
330
- pw = _get_sudo_password()
331
- # Build command list safely (avoid shell where possible)
342
+ # Unix: attempt to get cached sudo password via user-provided helper
343
+ try:
344
+ pw = _get_sudo_password()
345
+ except NameError:
346
+ raise RuntimeError("elevated=True requested but _get_sudo_password() is not implemented in the environment.")
332
347
  if isinstance(cmd, (list, tuple)):
333
348
  base_list = list(cmd)
334
349
  else:
335
- # if original was a string and shell=True, pass the string to sudo as a single shell invocation
336
350
  base_list = [cmd_str] if shell else [cmd_str]
337
351
  use_list = ["sudo", "-S"] + base_list
338
352
  stdin_input = (pw + "\n") if pw is not None else None
339
- shell = False # we pass a list to Popen
340
- # Log the final command representation (avoid logging password)
353
+ shell = False
354
+
355
+ # Log final command (avoid logging sensitive data)
341
356
  try:
342
357
  if isinstance(use_list, list):
343
358
  logger.debug(f"Executing (list): {' '.join(use_list)}")
344
359
  else:
345
360
  logger.debug(f"Executing (shell): {use_list}")
346
-
361
+
347
362
  proc = subprocess.Popen(
348
363
  use_list if not shell else (cmd_str),
349
364
  cwd=str(cwd) if cwd else None,
350
365
  env=env,
351
- stdout=subprocess.PIPE if capture else None,
352
- stderr=subprocess.PIPE if capture else None,
366
+ stdout=subprocess.PIPE if (capture or stream) else None,
367
+ stderr=subprocess.PIPE if (capture or stream) else None,
353
368
  stdin=subprocess.PIPE if stdin_input is not None else None,
354
369
  text=True,
355
370
  shell=shell,
356
- bufsize=1, # Line buffered
371
+ bufsize=1,
357
372
  universal_newlines=True,
358
373
  )
359
-
374
+
375
+ # Send sudo password if needed
360
376
  if stdin_input is not None and proc.stdin:
361
- # write password and flush; do not keep password in logs
362
377
  try:
363
378
  proc.stdin.write(stdin_input)
364
379
  proc.stdin.flush()
365
380
  proc.stdin.close()
366
381
  except Exception:
367
- # If writing fails, ensure we close and continue to wait for process
368
382
  try:
369
383
  proc.stdin.close()
370
384
  except Exception:
371
385
  pass
372
-
373
- stdout_lines = []
374
- stderr_lines = []
375
-
376
- if stream and capture:
377
- # Stream output in real-time
386
+
387
+ # Output collectors
388
+ stdout_lines: List[str] = []
389
+ stderr_lines: List[str] = []
390
+
391
+ # --- Smart threaded streaming implementation ---
392
+ def _smart_reader(pipe, log_func, collector: Optional[List[str]], stop_event: threading.Event):
393
+ """
394
+ Read from pipe in chunks, handle '\r' (line replace) and '\n' (new line).
395
+ Appends to collector (if provided) and logs via log_func.
396
+ """
397
+ try:
398
+ buffer = ""
399
+ last_rendered = None # used to avoid repeated identical logs for \r updates
400
+
401
+ # We'll read in reasonably-sized chunks. read() will block, but on separate thread that's fine.
402
+ while not stop_event.is_set():
403
+ chunk = pipe.read(1024)
404
+ if not chunk:
405
+ # EOF reached
406
+ break
407
+ buffer += chunk
408
+
409
+ # Process as long as there's control chars
410
+ while True:
411
+ # find next control char indices
412
+ idx_n = buffer.find("\n")
413
+ idx_r = buffer.find("\r")
414
+
415
+ if idx_n == -1 and idx_r == -1:
416
+ break
417
+
418
+ # Which control comes first?
419
+ if idx_r != -1 and (idx_n == -1 or idx_r < idx_n):
420
+ # Carriage return: replace current line
421
+ line = buffer[:idx_r]
422
+ buffer = buffer[idx_r + 1:]
423
+ # Only log if changed (avoids spamming identical updates)
424
+ if line != last_rendered:
425
+ # strip trailing CR/LF but preserve internal whitespace
426
+ to_log = line.rstrip("\r\n")
427
+ try:
428
+ log_func(to_log)
429
+ except Exception:
430
+ # logging should not raise to user
431
+ pass
432
+ if collector is not None:
433
+ collector.append(line + ("\n" if collector is not None else ""))
434
+ last_rendered = line
435
+ else:
436
+ # Newline: finalize this line
437
+ line = buffer[:idx_n]
438
+ buffer = buffer[idx_n + 1:]
439
+ to_log = line.rstrip("\r\n")
440
+ try:
441
+ log_func(to_log)
442
+ except Exception:
443
+ pass
444
+ if collector is not None:
445
+ collector.append(line + "\n")
446
+ last_rendered = None
447
+
448
+ # Flush whatever remains in buffer
449
+ if buffer:
450
+ buf_strip = buffer.rstrip("\r\n")
451
+ if buf_strip:
452
+ try:
453
+ log_func(buf_strip)
454
+ except Exception:
455
+ pass
456
+ if collector is not None:
457
+ collector.append(buffer)
458
+ # close pipe
459
+ try:
460
+ pipe.close()
461
+ except Exception:
462
+ pass
463
+ except Exception as exc:
464
+ # Ensure we don't crash the thread; bubble up via logging
465
+ logger.exception(f"stream reader error: {exc}")
466
+ try:
467
+ pipe.close()
468
+ except Exception:
469
+ pass
470
+ # re-raise to let outer context know (we'll catch in caller via threads status)
471
+ raise
472
+
473
+ rc = None
474
+ if stream and (proc.stdout is not None or proc.stderr is not None):
378
475
  logger.info("Streaming command output...")
379
-
380
- # Create file descriptors for reading stdout and stderr
381
- import select
382
- import sys
383
-
384
- # Read from stdout and stderr simultaneously
385
- while True:
386
- # Check if process has terminated
387
- if proc.poll() is not None:
388
- break
389
-
390
- # Use select to wait for data
391
- read_fds = []
476
+ stop_event = threading.Event()
477
+ threads: List[threading.Thread] = []
478
+ stream_exception = None
479
+
480
+ # Start threads
481
+ try:
392
482
  if proc.stdout:
393
- read_fds.append(proc.stdout)
483
+ t_out = threading.Thread(
484
+ target=_smart_reader,
485
+ args=(proc.stdout, logger.info, stdout_lines if capture else None, stop_event),
486
+ daemon=True
487
+ )
488
+ t_out.start()
489
+ threads.append(t_out)
490
+
394
491
  if proc.stderr:
395
- read_fds.append(proc.stderr)
396
-
397
- if not read_fds:
398
- break
399
-
400
- rlist, _, _ = select.select(read_fds, [], [], 0.1)
401
-
402
- for fd in rlist:
403
- if fd is proc.stdout:
404
- line = proc.stdout.readline()
405
- if line:
406
- logger.info(line.rstrip())
407
- stdout_lines.append(line)
408
- elif fd is proc.stderr:
409
- line = proc.stderr.readline()
410
- if line:
411
- logger.warning(line.rstrip())
412
- stderr_lines.append(line)
413
-
414
- # Read any remaining output
415
- if proc.stdout:
416
- for line in proc.stdout:
417
- logger.info(line.rstrip())
418
- stdout_lines.append(line)
419
- if proc.stderr:
420
- for line in proc.stderr:
421
- logger.warning(line.rstrip())
422
- stderr_lines.append(line)
423
-
424
- stdout = "".join(stdout_lines)
425
- stderr = "".join(stderr_lines)
426
- rc = proc.wait()
427
-
492
+ t_err = threading.Thread(
493
+ target=_smart_reader,
494
+ args=(proc.stderr, logger.warning, stderr_lines if capture else None, stop_event),
495
+ daemon=True
496
+ )
497
+ t_err.start()
498
+ threads.append(t_err)
499
+
500
+ # Wait for process while allowing KeyboardInterrupt
501
+ try:
502
+ rc = proc.wait()
503
+ except KeyboardInterrupt:
504
+ logger.debug("KeyboardInterrupt caught: terminating child process")
505
+ stop_event.set()
506
+ try:
507
+ proc.terminate()
508
+ except Exception:
509
+ pass
510
+ # wait a bit then force kill
511
+ try:
512
+ proc.wait(timeout=2)
513
+ except Exception:
514
+ try:
515
+ proc.kill()
516
+ except Exception:
517
+ pass
518
+ rc = proc.returncode if proc.returncode is not None else -1
519
+
520
+ except Exception as exc:
521
+ # If any exception occurs while starting/monitoring threads -> fallback
522
+ stream_exception = exc
523
+ logger.exception(f"Streaming failed, falling back to communicate(): {exc}")
524
+ finally:
525
+ # Signal threads to stop and join
526
+ stop_event.set()
527
+ for t in threads:
528
+ t.join(timeout=1)
529
+
530
+ # If streaming had exception, fallback to communicate to collect remaining output safely.
531
+ if stream_exception is not None:
532
+ try:
533
+ # communicate will return remaining output that reader threads didn't capture
534
+ comm_out, comm_err = proc.communicate(timeout=5)
535
+ except Exception:
536
+ try:
537
+ # best-effort: kill and read whatever
538
+ proc.kill()
539
+ except Exception:
540
+ pass
541
+ try:
542
+ comm_out, comm_err = proc.communicate(timeout=5)
543
+ except Exception:
544
+ comm_out, comm_err = ("", "")
545
+ # append remaining to collectors if capture True
546
+ if capture:
547
+ if comm_out:
548
+ stdout_lines.append(comm_out)
549
+ if comm_err:
550
+ stderr_lines.append(comm_err)
551
+ # set rc if not set
552
+ if rc is None:
553
+ rc = proc.returncode if proc.returncode is not None else 0
554
+
555
+ # Compose final stdout/stderr
556
+ stdout = "".join(stdout_lines) if capture else ""
557
+ stderr = "".join(stderr_lines) if capture else ""
558
+ if rc is None:
559
+ rc = proc.returncode if proc.returncode is not None else 0
560
+
428
561
  else:
429
- # Original behavior: wait for completion
430
- stdout, stderr = proc.communicate()
562
+ # original behavior: blocking wait & capture (or not)
563
+ try:
564
+ stdout, stderr = proc.communicate()
565
+ except Exception as exc:
566
+ # if communicate fails, try to kill and fallback
567
+ logger.exception(f"proc.communicate() failed: {exc}")
568
+ try:
569
+ proc.kill()
570
+ except Exception:
571
+ pass
572
+ try:
573
+ stdout, stderr = proc.communicate(timeout=5)
574
+ except Exception:
575
+ stdout, stderr = ("", "")
431
576
  rc = proc.returncode
432
-
577
+
578
+ # Build CompletedProcess result
433
579
  result = subprocess.CompletedProcess(
434
580
  use_list if not shell else cmd_str,
435
581
  rc,
436
582
  stdout=stdout or "",
437
583
  stderr=stderr or "",
438
584
  )
439
-
585
+
586
+ # Post-run handling & sudo auth checks (Unix)
440
587
  if rc == 0:
441
588
  logger.info(f"Command succeeded (rc={rc})")
442
589
  else:
443
- # If elevated on Unix and sudo-auth failure detected, clear cache and raise helpful error
444
- if elevated and not is_windows():
590
+ if elevated and not is_win:
445
591
  lowerr = (stderr or "").lower()
446
- # Common sudo auth failure tokens
447
592
  auth_tokens = [
448
593
  "incorrect password",
449
594
  "authentication failure",
@@ -454,25 +599,26 @@ def run(
454
599
  "pam_authenticate",
455
600
  ]
456
601
  if any(tok in lowerr for tok in auth_tokens):
457
- # Clear cached password so user isn't forced to keep using wrong one
458
- clear_sudo_password()
459
- logger.error("sudo authentication failed. Cached sudo password cleared.")
460
- # Raise with stderr included for context
602
+ try:
603
+ clear_sudo_password()
604
+ except NameError:
605
+ logger.error("sudo authentication failed and clear_sudo_password() not implemented.")
606
+ else:
607
+ logger.error("sudo authentication failed. Cached sudo password cleared.")
461
608
  raise subprocess.CalledProcessError(rc, use_list if not shell else cmd_str, output=stdout, stderr=stderr)
462
- # General logging for failure
463
- logger.error(f"Command failed (rc={rc}) – {stderr.strip() if stderr else ''}")
464
-
609
+ logger.error(f"Command failed (rc={rc}) { (stderr or '').strip() }")
610
+
465
611
  if rc != 0 and not no_die:
466
612
  raise subprocess.CalledProcessError(rc, use_list if not shell else cmd_str, output=stdout, stderr=stderr)
467
-
613
+
468
614
  return result
469
-
615
+
470
616
  except subprocess.CalledProcessError:
471
- # Re-raise CalledProcessError so callers can handle; do not clear cached password here
472
617
  raise
473
618
  except Exception as e:
474
619
  logger.exception(f"Unexpected error running command: {e}")
475
620
  raise
621
+
476
622
  # -------------------------------------------------
477
623
  # Exec helper – shows command + output + logs
478
624
  # -------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utils_devops
3
- Version: 0.1.128
3
+ Version: 0.1.130
4
4
  Summary: Lightweight DevOps utilities for automation scripts: config editing (YAML/JSON/INI/.env), templating, diffing, and CLI tools
5
5
  License: MIT
6
6
  Keywords: devops,automation,nginx,cli,jinja2,yaml,config,diff,templating,logging,docker,compose,file-ops
@@ -6,7 +6,7 @@ utils_devops/core/files.py,sha256=WwLkEy83PTyIQT2yhYJhJA6xkrMi2mfKoDT45BswlvQ,50
6
6
  utils_devops/core/logs.py,sha256=xHVSPLE1a7kivrBqQWQdOYjYfm69e2C9yQ7vBu3U68k,30829
7
7
  utils_devops/core/script_helpers.py,sha256=NcOh1f1X_TYm5uuP5YFCYpNF5tr2NVE8DehLj9DMo3c,31480
8
8
  utils_devops/core/strings.py,sha256=8s0GSjcyTKwLjJjsJ_XfOJxPtyb549icDlU9SUxSvHI,42481
9
- utils_devops/core/systems.py,sha256=D73G_lvd-OoGmByJ3_gMTifkLm6PuU4AxpFm0vfDiJo,41629
9
+ utils_devops/core/systems.py,sha256=wNbEFUAvbMPdqWN-iXvTzvj5iE9xaWfjZYYvD0EZAH0,47577
10
10
  utils_devops/extras/__init__.py,sha256=ZXHeVLHO3_qiW9AY-UQ_YA9cQzmkLGv54a2UbyvtlM0,3571
11
11
  utils_devops/extras/aws_ops.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  utils_devops/extras/docker_ops.py,sha256=7941chrSL1OnbvQCOM9Uz7TRtalKDPs4yNFkPre3YUA,175029
@@ -19,7 +19,7 @@ utils_devops/extras/notification_ops.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMp
19
19
  utils_devops/extras/performance_ops.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
20
  utils_devops/extras/ssh_ops.py,sha256=tBhydL7z-XzJUy8Cv4QWn_tHFToyRK0Enx3EllnZS4A,68976
21
21
  utils_devops/extras/vault_ops.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
- utils_devops-0.1.128.dist-info/METADATA,sha256=c_26nyafN05p2tZngWy4RRlUJrmeaqgfv2sK5szioyA,1903
23
- utils_devops-0.1.128.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
24
- utils_devops-0.1.128.dist-info/entry_points.txt,sha256=ei3B6ZL5yu6dOq-U1r8wsBdkXeg63RAyV7m8_ADaE6k,53
25
- utils_devops-0.1.128.dist-info/RECORD,,
22
+ utils_devops-0.1.130.dist-info/METADATA,sha256=Qz7EmI-m2ZtIJiLLzRj98dtCYvpbJGfk9EMGQu5hfRQ,1903
23
+ utils_devops-0.1.130.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
24
+ utils_devops-0.1.130.dist-info/entry_points.txt,sha256=ei3B6ZL5yu6dOq-U1r8wsBdkXeg63RAyV7m8_ADaE6k,53
25
+ utils_devops-0.1.130.dist-info/RECORD,,